Implement auto-update functionality and enhance build configuration

- Integrated electron-updater for automatic updates, including IPC handlers for checking, downloading, and installing updates.
- Updated electron-builder configuration to include entitlements and notarization scripts for macOS.
- Enhanced README with installation instructions and updated features.
- Added new components for update notifications and dialogs in the renderer.
- Improved CI workflow permissions for better release management.
This commit is contained in:
matt 2026-02-11 21:23:40 +09:00
parent fc48f6e099
commit 540aefc3d3
22 changed files with 880 additions and 69 deletions

View file

@ -6,6 +6,9 @@ on:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
package-mac:
runs-on: macos-latest

162
README.md
View file

@ -1,70 +1,134 @@
# Claude Code Context
<p align="center">
<img src="resources/icons/png/128x128.png" alt="Claude Code Context" width="120" />
</p>
Desktop app for exploring Claude Code session context usage.
<h1 align="center">Claude Code Context</h1>
It helps you inspect session timelines, search across sessions, debug context injections (`CLAUDE.md`, mentioned files, tool outputs), and configure notification triggers.
<p align="center">
<strong>Stop guessing. See exactly what Claude is doing.</strong>
<br />
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
</p>
## Features
- Repository/worktree-aware project grouping
- Session search with context snippets
- Structured conversation/chunk parsing from Claude JSONL logs
- Context usage inspection (CLAUDE.md + mentioned files + tool output)
- Native notifications with configurable trigger rules
- Real-time updates from Claude session/todo file changes
<p align="center">
<a href="https://github.com/matt1398/claude-code-context/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
<a href="#"><img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey.svg" alt="Platform" /></a>
<a href="#"><img src="https://img.shields.io/badge/electron-40-47848F.svg?logo=electron" alt="Electron" /></a>
<a href="#"><img src="https://img.shields.io/badge/react-18-61DAFB.svg?logo=react" alt="React" /></a>
<a href="#"><img src="https://img.shields.io/badge/typescript-5-3178C6.svg?logo=typescript" alt="TypeScript" /></a>
</p>
## Tech Stack
- Electron + electron-vite
- React + TypeScript + Zustand
- Tailwind CSS
- Vitest + ESLint
<br />
## Requirements
- Node.js 20+
- pnpm 10+
- macOS or Windows
<p align="center">
<!-- TODO: Replace with actual demo GIF/video -->
<img src="docs/assets/demo.gif" alt="Claude Code Context Demo" width="900" />
</p>
---
## Why This Exists
There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Kanban, 1Code, ccswitch, and others. I tried them all. None of them solved the actual problem:
**They wrap Claude Code.** They inject their own prompts, add their own abstractions, and change how Claude behaves. If you love the terminal — and I do — you don't want that. You want Claude Code exactly as it is.
**They only show their own sessions.** Run something in the terminal? It doesn't exist in their UI. You can only see what was executed through *their* tool. The terminal and the GUI are two separate worlds.
**You can't debug what went wrong.** A session failed — but why? The context filled up too fast — but what consumed it? A subagent spawned 5 child agents — but what did they do? Even in the terminal, scrolling back through a long session to reconstruct what happened is nearly impossible.
**You can't monitor what matters.** Want to know when Claude reads `.env`? When a single tool call exceeds 4K tokens of context? When a teammate sends a shutdown request? You'd have to wire up hooks manually, every time, for every project.
**Claude Code Context takes a different approach.** It doesn't wrap or modify Claude Code at all. It reads the session logs that already exist on your machine (`~/.claude/`) and turns them into a rich, interactive interface — regardless of whether the session ran in the terminal, in an IDE, or through another tool.
> Zero configuration. No API keys. Works with every session you've ever run.
---
## Key Features
### :mag: Visible Context Tracking
See exactly what's eating your context window. The **Session Context Panel** breaks down token usage across 6 categories — CLAUDE.md files, @-mentioned files, tool outputs, extended thinking, team coordination, and user messages — so you can instantly identify what's consuming tokens and optimize your workflow.
### :hammer_and_wrench: Rich Tool Call Inspector
Every tool call is paired with its result in an expandable card. Specialized viewers render each tool natively:
- **Read** calls show syntax-highlighted code with line numbers
- **Edit** calls show inline diffs with added/removed highlighting
- **Bash** calls show command output
- **Subagent** calls show the full execution tree, expandable in-place
### :bell: Custom Notification Triggers
Define rules for when you want to be notified. Match on regex patterns, assign colors, and filter your inbox by trigger. Built-in triggers catch common errors out of the box; add your own for project-specific patterns.
### :busts_in_silhouette: Team & Subagent Visualization
When Claude uses multi-agent orchestration, see the full picture. Teammate messages render as color-coded cards. Subagent sessions are expandable inline with their own execution traces, metrics, and tool calls.
### :zap: Command Palette & Cross-Session Search
Hit **Cmd+K** for a Spotlight-style command palette. Search across all sessions in a project — results show context snippets with highlighted keywords. Navigate directly to the exact message.
### :bar_chart: Multi-Pane Layout
Open multiple sessions side-by-side. Drag-and-drop tabs between panes, split views, and compare sessions in parallel — like a proper IDE for your AI conversations.
---
## Getting Started
### Prerequisites
- **Node.js** 20+
- **pnpm** 10+
- macOS or Windows
### Install & Run
```bash
git clone https://github.com/matt1398/claude-code-context.git
cd claude-code-context
pnpm install
pnpm dev
```
## Data Source
The app reads Claude local data from:
- `~/.claude/projects/`
- `~/.claude/todos/`
That's it. The app auto-discovers your Claude Code projects from `~/.claude/`.
### Build for Distribution
## Scripts
```bash
pnpm dev # Run app in development
pnpm typecheck # TypeScript checks
pnpm lint # ESLint (no auto-fix)
pnpm test # Unit tests
pnpm build # Electron/Vite production build
pnpm check # Full local quality gate
pnpm dist:mac # Package macOS app (electron-builder)
pnpm dist:win # Package Windows app (electron-builder)
pnpm dist # Package both targets
pnpm dist:mac # macOS (.dmg)
pnpm dist:win # Windows (.exe)
pnpm dist # Both platforms
```
## Packaging and Release
- Packaging is configured with `electron-builder.yml`.
- CI workflow (`.github/workflows/ci.yml`) runs typecheck/lint/test/build on macOS + Windows.
- Release workflow (`.github/workflows/release.yml`) builds distributables on tags (`v*`).
- Code signing/notarization uses GitHub secrets:
- `CSC_LINK`, `CSC_KEY_PASSWORD`
- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID` (macOS notarization)
---
## Security Notes
- IPC handlers validate IDs/inputs and apply strict path containment checks.
- File reads for context injection are constrained to project root and `~/.claude`.
- Sensitive credential path patterns are blocked.
## Scripts
| Command | Description |
|---------|-------------|
| `pnpm dev` | Development with hot reload |
| `pnpm build` | Production build |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm lint:fix` | Lint and auto-fix |
| `pnpm test` | Run all tests |
| `pnpm test:watch` | Watch mode |
| `pnpm test:coverage` | Coverage report |
| `pnpm check` | Full quality gate (types + lint + test + build) |
---
## Contributing
See:
- `CONTRIBUTING.md`
- `CODE_OF_CONDUCT.md`
- `SECURITY.md`
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).
## Security
IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](SECURITY.md) for details.
## License
MIT (`LICENSE`)
[MIT](LICENSE)

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

View file

@ -22,8 +22,9 @@ mac:
hardenedRuntime: true
gatekeeperAssess: false
icon: resources/icons/mac/icon.icns
notarize:
teamId: ${env.APPLE_TEAM_ID}
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
afterSign: scripts/notarize.cjs
dmg:
sign: false

View file

@ -44,6 +44,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-virtual": "^3.10.8",
"date-fns": "^3.6.0",
"electron-updater": "^6.7.3",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"react": "^18.3.1",

View file

@ -23,6 +23,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
electron-updater:
specifier: ^6.7.3
version: 6.7.3
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@18.3.1)
@ -1532,6 +1535,10 @@ packages:
resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==}
engines: {node: '>=12.0.0'}
builder-util-runtime@9.5.1:
resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==}
engines: {node: '>=12.0.0'}
builder-util@24.13.1:
resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==}
@ -1830,6 +1837,9 @@ packages:
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
electron-updater@6.7.3:
resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==}
electron-vite@2.3.0:
resolution: {integrity: sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -2679,9 +2689,16 @@ packages:
lodash.difference@4.5.0:
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@ -3634,6 +3651,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -5308,6 +5328,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
builder-util-runtime@9.5.1:
dependencies:
debug: 4.4.3
sax: 1.4.4
transitivePeerDependencies:
- supports-color
builder-util@24.13.1:
dependencies:
7zip-bin: 5.2.0
@ -5653,6 +5680,19 @@ snapshots:
electron-to-chromium@1.5.267: {}
electron-updater@6.7.3:
dependencies:
builder-util-runtime: 9.5.1
fs-extra: 10.1.0
js-yaml: 4.1.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.3
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
electron-vite@2.3.0(vite@5.4.21(@types/node@25.0.7)):
dependencies:
'@babel/core': 7.28.6
@ -6757,8 +6797,12 @@ snapshots:
lodash.difference@4.5.0: {}
lodash.escaperegexp@4.1.2: {}
lodash.flatten@4.4.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
@ -8017,6 +8061,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
tiny-typed-emitter@2.1.0: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}

22
scripts/notarize.cjs Normal file
View file

@ -0,0 +1,22 @@
const { notarize } = require('@electron/notarize');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') return;
if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD || !process.env.APPLE_TEAM_ID) {
console.log('Skipping notarization: Apple credentials not set');
return;
}
const appName = context.packager.appInfo.productFilename;
return await notarize({
tool: 'notarytool',
appBundleId: 'com.claudecode.context',
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
});
};

View file

@ -44,6 +44,7 @@ import {
ProjectScanner,
SessionParser,
SubagentResolver,
UpdaterService,
} from './services';
// =============================================================================
@ -60,6 +61,7 @@ let chunkBuilder: ChunkBuilder;
let dataCache: DataCache;
let fileWatcher: FileWatcher;
let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let cleanupInterval: NodeJS.Timeout | null = null;
/**
@ -75,11 +77,19 @@ function initializeServices(): void {
chunkBuilder = new ChunkBuilder();
const disableCache = process.env.CLAUDE_CONTEXT_DISABLE_CACHE === '1';
dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, !disableCache);
updaterService = new UpdaterService();
logger.info(`Projects directory: ${projectScanner.getProjectsDir()}`);
// Initialize IPC handlers
initializeIpcHandlers(projectScanner, sessionParser, subagentResolver, chunkBuilder, dataCache);
initializeIpcHandlers(
projectScanner,
sessionParser,
subagentResolver,
chunkBuilder,
dataCache,
updaterService
);
// Initialize notification manager using singleton pattern
// This ensures IPC handlers and FileWatcher use the same instance
@ -171,10 +181,12 @@ function createWindow(): void {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
// Set traffic light position + notify renderer on first load
// Set traffic light position + notify renderer on first load, and auto-check for updates
mainWindow.webContents.on('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
syncTrafficLightPosition(mainWindow);
// Auto-check for updates 3 seconds after window loads
setTimeout(() => updaterService.checkForUpdates(), 3000);
}
});
@ -214,10 +226,13 @@ function createWindow(): void {
mainWindow.on('closed', () => {
mainWindow = null;
// Clear main window reference from notification manager
// Clear main window references
if (notificationManager) {
notificationManager.setMainWindow(null);
}
if (updaterService) {
updaterService.setMainWindow(null);
}
});
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
@ -226,10 +241,13 @@ function createWindow(): void {
// Could show an error dialog or attempt to reload the window
});
// Set main window reference for notification manager
// Set main window reference for notification manager and updater
if (notificationManager) {
notificationManager.setMainWindow(mainWindow);
}
if (updaterService) {
updaterService.setMainWindow(mainWindow);
}
logger.info('Main window created');
}

View file

@ -35,6 +35,11 @@ import {
registerSubagentHandlers,
removeSubagentHandlers,
} from './subagents';
import {
initializeUpdaterHandlers,
registerUpdaterHandlers,
removeUpdaterHandlers,
} from './updater';
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
import { registerValidationHandlers, removeValidationHandlers } from './validation';
@ -44,6 +49,7 @@ import type {
ProjectScanner,
SessionParser,
SubagentResolver,
UpdaterService,
} from '../services';
/**
@ -54,13 +60,15 @@ export function initializeIpcHandlers(
parser: SessionParser,
resolver: SubagentResolver,
builder: ChunkBuilder,
cache: DataCache
cache: DataCache,
updater: UpdaterService
): void {
// Initialize domain handlers with their required services
initializeProjectHandlers(scanner);
initializeSessionHandlers(scanner, parser, resolver, builder, cache);
initializeSearchHandlers(scanner);
initializeSubagentHandlers(builder, cache, parser, resolver);
initializeUpdaterHandlers(updater);
// Register all handlers
registerProjectHandlers(ipcMain);
@ -71,6 +79,7 @@ export function initializeIpcHandlers(
registerUtilityHandlers(ipcMain);
registerNotificationHandlers(ipcMain);
registerConfigHandlers(ipcMain);
registerUpdaterHandlers(ipcMain);
logger.info('All handlers registered');
}
@ -88,6 +97,7 @@ export function removeIpcHandlers(): void {
removeUtilityHandlers(ipcMain);
removeNotificationHandlers(ipcMain);
removeConfigHandlers(ipcMain);
removeUpdaterHandlers(ipcMain);
logger.info('All handlers removed');
}

75
src/main/ipc/updater.ts Normal file
View file

@ -0,0 +1,75 @@
/**
* IPC Handlers for Update Operations.
*
* Handlers:
* - updater:check: Check for available updates
* - updater:download: Download the available update
* - updater:install: Quit and install the downloaded update
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import type { UpdaterService } from '../services';
const logger = createLogger('IPC:updater');
let updaterService: UpdaterService;
/**
* Initializes updater handlers with the service instance.
*/
export function initializeUpdaterHandlers(service: UpdaterService): void {
updaterService = service;
}
/**
* Registers all updater-related IPC handlers.
*/
export function registerUpdaterHandlers(ipcMain: IpcMain): void {
ipcMain.handle('updater:check', handleCheck);
ipcMain.handle('updater:download', handleDownload);
ipcMain.handle('updater:install', handleInstall);
logger.info('Updater handlers registered');
}
/**
* Removes all updater IPC handlers.
*/
export function removeUpdaterHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('updater:check');
ipcMain.removeHandler('updater:download');
ipcMain.removeHandler('updater:install');
logger.info('Updater handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
async function handleCheck(_event: IpcMainInvokeEvent): Promise<void> {
try {
await updaterService.checkForUpdates();
} catch (error) {
logger.error('Error in updater:check:', getErrorMessage(error));
}
}
async function handleDownload(_event: IpcMainInvokeEvent): Promise<void> {
try {
await updaterService.downloadUpdate();
} catch (error) {
logger.error('Error in updater:download:', getErrorMessage(error));
}
}
function handleInstall(_event: IpcMainInvokeEvent): void {
try {
updaterService.quitAndInstall();
} catch (error) {
logger.error('Error in updater:install:', getErrorMessage(error));
}
}

View file

@ -0,0 +1,118 @@
/**
* UpdaterService - Wraps electron-updater's autoUpdater for OTA updates.
*
* Forwards update lifecycle events to the renderer via IPC.
* Auto-download is disabled so users must confirm before downloading.
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import electronUpdater from 'electron-updater';
const { autoUpdater } = electronUpdater;
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
const logger = createLogger('UpdaterService');
export class UpdaterService {
private mainWindow: BrowserWindow | null = null;
constructor() {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
this.bindEvents();
}
/**
* Set the main window reference for sending status events.
*/
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
/**
* Check for available updates.
*/
async checkForUpdates(): Promise<void> {
try {
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error('Check for updates failed:', getErrorMessage(error));
}
}
/**
* Download the available update.
*/
async downloadUpdate(): Promise<void> {
try {
await autoUpdater.downloadUpdate();
} catch (error) {
logger.error('Download update failed:', getErrorMessage(error));
}
}
/**
* Quit the app and install the downloaded update.
*/
quitAndInstall(): void {
autoUpdater.quitAndInstall();
}
private sendStatus(status: UpdaterStatus): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('updater:status', status);
}
}
private bindEvents(): void {
autoUpdater.on('checking-for-update', () => {
logger.info('Checking for update...');
this.sendStatus({ type: 'checking' });
});
autoUpdater.on('update-available', (info) => {
logger.info('Update available:', info.version);
this.sendStatus({
type: 'available',
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
});
});
autoUpdater.on('update-not-available', () => {
logger.info('No update available');
this.sendStatus({ type: 'not-available' });
});
autoUpdater.on('download-progress', (progress) => {
this.sendStatus({
type: 'downloading',
progress: {
percent: progress.percent,
transferred: progress.transferred,
total: progress.total,
},
});
});
autoUpdater.on('update-downloaded', (info) => {
logger.info('Update downloaded:', info.version);
this.sendStatus({
type: 'downloaded',
version: info.version,
});
});
autoUpdater.on('error', (error) => {
logger.error('Updater error:', getErrorMessage(error));
this.sendStatus({
type: 'error',
error: getErrorMessage(error),
});
});
}
}

View file

@ -14,3 +14,4 @@ export * from './DataCache';
export * from './FileWatcher';
export * from './NotificationManager';
export * from './TriggerManager';
export * from './UpdaterService';

View file

@ -58,3 +58,19 @@ export const CONFIG_PIN_SESSION = 'config:pinSession';
/** Unpin a session */
export const CONFIG_UNPIN_SESSION = 'config:unpinSession';
// =============================================================================
// Updater API Channels
// =============================================================================
/** Check for updates */
export const UPDATER_CHECK = 'updater:check';
/** Download available update */
export const UPDATER_DOWNLOAD = 'updater:download';
/** Quit and install downloaded update */
export const UPDATER_INSTALL = 'updater:install';
/** Status event channel (main -> renderer) */
export const UPDATER_STATUS = 'updater:status';

View file

@ -1,6 +1,12 @@
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
import { contextBridge, ipcRenderer } from 'electron';
import {
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
UPDATER_STATUS,
} from './constants/ipcChannels';
import {
CONFIG_ADD_IGNORE_REGEX,
CONFIG_ADD_IGNORE_REPOSITORY,
@ -285,6 +291,25 @@ const electronAPI: ElectronAPI = {
ipcRenderer.removeListener('todo-change', listener);
};
},
// Updater API
updater: {
check: () => ipcRenderer.invoke(UPDATER_CHECK),
download: () => ipcRenderer.invoke(UPDATER_DOWNLOAD),
install: () => ipcRenderer.invoke(UPDATER_INSTALL),
onStatus: (callback: (event: unknown, status: unknown) => void): (() => void) => {
ipcRenderer.on(
UPDATER_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
UPDATER_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -0,0 +1,71 @@
/**
* UpdateBanner - Slim top banner for download progress and restart prompt.
*
* Visible during download and after the update is ready to install.
*/
import { useStore } from '@renderer/store';
import { CheckCircle, Loader2, X } from 'lucide-react';
export const UpdateBanner = (): React.JSX.Element | null => {
const showUpdateBanner = useStore((s) => s.showUpdateBanner);
const updateStatus = useStore((s) => s.updateStatus);
const downloadProgress = useStore((s) => s.downloadProgress);
const availableVersion = useStore((s) => s.availableVersion);
const installUpdate = useStore((s) => s.installUpdate);
const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner);
if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) {
return null;
}
const isDownloading = updateStatus === 'downloading';
const percent = Math.round(downloadProgress);
return (
<div
className="relative flex items-center gap-3 border-b px-4 py-2 text-sm"
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
}}
>
{isDownloading ? (
<>
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
<span style={{ color: 'var(--color-text-secondary)' }}>
Downloading update... {percent}%
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
</>
) : (
<>
<CheckCircle className="size-4 shrink-0 text-green-400" />
<span style={{ color: 'var(--color-text-secondary)' }}>
Update ready{availableVersion ? ` (v${availableVersion})` : ''}
</span>
<button
onClick={installUpdate}
className="ml-auto rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-500"
>
Restart to Update
</button>
</>
)}
{/* Dismiss */}
<button
onClick={dismissUpdateBanner}
className="shrink-0 rounded p-0.5 transition-colors hover:bg-white/10"
style={{ color: 'var(--color-text-muted)' }}
>
<X className="size-3.5" />
</button>
</div>
);
};

View file

@ -0,0 +1,89 @@
/**
* UpdateDialog - Modal dialog shown when a new version is available.
*
* Prompts the user to download the update or dismiss it.
*/
import { useStore } from '@renderer/store';
import { Download, X } from 'lucide-react';
export const UpdateDialog = (): React.JSX.Element | null => {
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
const availableVersion = useStore((s) => s.availableVersion);
const releaseNotes = useStore((s) => s.releaseNotes);
const downloadUpdate = useStore((s) => s.downloadUpdate);
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
if (!showUpdateDialog) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={dismissUpdateDialog}
>
<div
className="relative mx-4 w-full max-w-md rounded-lg border p-6 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={dismissUpdateDialog}
className="absolute right-3 top-3 rounded p-1 transition-colors hover:bg-white/10"
style={{ color: 'var(--color-text-muted)' }}
>
<X className="size-4" />
</button>
{/* Title */}
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Update Available
</h2>
{/* Body */}
<p className="mt-2 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Version {availableVersion} is available. Would you like to download it?
</p>
{/* Release notes */}
{releaseNotes && (
<div
className="mt-3 max-h-40 overflow-y-auto rounded border p-3 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{releaseNotes}
</div>
)}
{/* Actions */}
<div className="mt-5 flex justify-end gap-3">
<button
onClick={dismissUpdateDialog}
className="rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
Later
</button>
<button
onClick={downloadUpdate}
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500"
>
<Download className="size-4" />
Download Update
</button>
</div>
</div>
</div>
);
};

View file

@ -10,6 +10,8 @@ import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
import { UpdateBanner } from '../common/UpdateBanner';
import { UpdateDialog } from '../common/UpdateDialog';
import { CommandPalette } from '../search/CommandPalette';
import { PaneContainer } from './PaneContainer';
@ -23,19 +25,23 @@ export const TabbedLayout = (): React.JSX.Element => {
return (
<div
className="flex h-screen bg-claude-dark-bg text-claude-dark-text"
className="flex h-screen flex-col bg-claude-dark-bg text-claude-dark-text"
style={
{ '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties
}
>
{/* Command Palette (Cmd+K) */}
<CommandPalette />
<UpdateBanner />
<div className="flex flex-1 overflow-hidden">
{/* Command Palette (Cmd+K) */}
<CommandPalette />
{/* Sidebar - Project dropdown + Sessions (280px) */}
<Sidebar />
{/* Sidebar - Project dropdown + Sessions (280px) */}
<Sidebar />
{/* Multi-pane content area */}
<PaneContainer />
{/* Multi-pane content area */}
<PaneContainer />
</div>
<UpdateDialog />
</div>
);
};

View file

@ -2,10 +2,11 @@
* AdvancedSection - Advanced settings including config management and about info.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import appIcon from '@renderer/favicon.png';
import { Code2, Download, RefreshCw, Upload } from 'lucide-react';
import { useStore } from '@renderer/store';
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
import { SettingsSectionHeader } from '../components';
@ -25,11 +26,65 @@ export const AdvancedSection = ({
onOpenInEditor,
}: AdvancedSectionProps): React.JSX.Element => {
const [version, setVersion] = useState<string>('');
const updateStatus = useStore((s) => s.updateStatus);
const availableVersion = useStore((s) => s.availableVersion);
const checkForUpdates = useStore((s) => s.checkForUpdates);
// Auto-revert "not-available" / "error" status back to idle after a brief display
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (updateStatus === 'not-available' || updateStatus === 'error') {
revertTimerRef.current = setTimeout(() => {
useStore.setState({ updateStatus: 'idle' });
}, 3000);
}
return () => {
if (revertTimerRef.current) clearTimeout(revertTimerRef.current);
};
}, [updateStatus]);
useEffect(() => {
window.electronAPI.getAppVersion().then(setVersion).catch(console.error);
}, []);
const handleCheckForUpdates = useCallback(() => {
checkForUpdates();
}, [checkForUpdates]);
const getUpdateButtonContent = (): React.JSX.Element => {
switch (updateStatus) {
case 'checking':
return (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking...
</>
);
case 'not-available':
return (
<>
<CheckCircle className="size-3.5" />
Up to date
</>
);
case 'available':
case 'downloaded':
return (
<>
<Download className="size-3.5" />
{updateStatus === 'downloaded' ? 'Update ready' : `v${availableVersion} available`}
</>
);
default:
return (
<>
<RefreshCw className="size-3.5" />
Check for Updates
</>
);
}
};
return (
<div>
<SettingsSectionHeader title="Configuration" />
@ -87,9 +142,27 @@ export const AdvancedSection = ({
<div className="flex items-start gap-4 py-3">
<img src={appIcon} alt="App Icon" className="size-10 rounded-lg" />
<div>
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Claude Code Context
</p>
<div className="flex items-center gap-3">
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Claude Code Context
</p>
<button
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color:
updateStatus === 'not-available'
? 'var(--color-text-muted)'
: updateStatus === 'available' || updateStatus === 'downloaded'
? '#60a5fa'
: 'var(--color-text-secondary)',
}}
>
{getUpdateButtonContent()}
</button>
</div>
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Version {version || '...'}
</p>

View file

@ -16,9 +16,11 @@ import { createSubagentSlice } from './slices/subagentSlice';
import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice';
import { createUISlice } from './slices/uiSlice';
import { createUpdateSlice } from './slices/updateSlice';
import type { DetectedError } from '../types/data';
import type { AppState } from './types';
import type { UpdaterStatus } from '@shared/types';
// =============================================================================
// Store Creation
@ -37,6 +39,7 @@ export const useStore = create<AppState>()((...args) => ({
...createUISlice(...args),
...createNotificationSlice(...args),
...createConfigSlice(...args),
...createUpdateSlice(...args),
}));
// =============================================================================
@ -226,6 +229,51 @@ export function initializeNotificationListeners(): () => void {
}
}
// Listen for updater status events from main process
if (window.electronAPI.updater?.onStatus) {
const cleanup = window.electronAPI.updater.onStatus((_event: unknown, status: unknown) => {
const s = status as UpdaterStatus;
switch (s.type) {
case 'checking':
useStore.setState({ updateStatus: 'checking' });
break;
case 'available':
useStore.setState({
updateStatus: 'available',
availableVersion: s.version ?? null,
releaseNotes: s.releaseNotes ?? null,
showUpdateDialog: true,
});
break;
case 'not-available':
useStore.setState({ updateStatus: 'not-available' });
break;
case 'downloading':
useStore.setState({
updateStatus: 'downloading',
downloadProgress: s.progress?.percent ?? 0,
});
break;
case 'downloaded':
useStore.setState({
updateStatus: 'downloaded',
downloadProgress: 100,
availableVersion: s.version ?? useStore.getState().availableVersion,
});
break;
case 'error':
useStore.setState({
updateStatus: 'error',
updateError: s.error ?? 'Unknown error',
});
break;
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Return cleanup function
return () => {
for (const timer of pendingSessionRefreshTimers.values()) {

View file

@ -0,0 +1,82 @@
/**
* Update slice - manages OTA auto-update state and actions.
*/
import { createLogger } from '@shared/utils/logger';
import type { AppState } from '../types';
import type { StateCreator } from 'zustand';
const logger = createLogger('Store:update');
// =============================================================================
// Slice Interface
// =============================================================================
export interface UpdateSlice {
// State
updateStatus:
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'downloaded'
| 'error';
availableVersion: string | null;
releaseNotes: string | null;
downloadProgress: number;
updateError: string | null;
showUpdateDialog: boolean;
showUpdateBanner: boolean;
// Actions
checkForUpdates: () => void;
downloadUpdate: () => void;
installUpdate: () => void;
dismissUpdateDialog: () => void;
dismissUpdateBanner: () => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (set) => ({
// Initial state
updateStatus: 'idle',
availableVersion: null,
releaseNotes: null,
downloadProgress: 0,
updateError: null,
showUpdateDialog: false,
showUpdateBanner: false,
checkForUpdates: () => {
set({ updateStatus: 'checking', updateError: null });
window.electronAPI.updater.check().catch((error) => {
logger.error('Failed to check for updates:', error);
});
},
downloadUpdate: () => {
set({ showUpdateDialog: false, showUpdateBanner: true, downloadProgress: 0 });
window.electronAPI.updater.download().catch((error) => {
logger.error('Failed to download update:', error);
});
},
installUpdate: () => {
window.electronAPI.updater.install().catch((error) => {
logger.error('Failed to install update:', error);
});
},
dismissUpdateDialog: () => {
set({ showUpdateDialog: false });
},
dismissUpdateBanner: () => {
set({ showUpdateBanner: false });
},
});

View file

@ -15,6 +15,7 @@ import type { SubagentSlice } from './slices/subagentSlice';
import type { TabSlice } from './slices/tabSlice';
import type { TabUISlice } from './slices/tabUISlice';
import type { UISlice } from './slices/uiSlice';
import type { UpdateSlice } from './slices/updateSlice';
// =============================================================================
// Shared Types
@ -84,4 +85,5 @@ export type AppState = ProjectSlice &
PaneSlice &
UISlice &
NotificationSlice &
ConfigSlice;
ConfigSlice &
UpdateSlice;

View file

@ -119,6 +119,31 @@ export interface ClaudeMdFileInfo {
estimatedTokens: number;
}
// =============================================================================
// Updater API
// =============================================================================
/**
* Status payload sent from the main process updater to the renderer.
*/
export interface UpdaterStatus {
type: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
version?: string;
releaseNotes?: string;
progress?: { percent: number; transferred: number; total: number };
error?: string;
}
/**
* Updater API exposed via preload.
*/
export interface UpdaterAPI {
check: () => Promise<void>;
download: () => Promise<void>;
install: () => Promise<void>;
onStatus: (callback: (event: unknown, status: unknown) => void) => () => void;
}
// =============================================================================
// Main Electron API
// =============================================================================
@ -197,6 +222,9 @@ export interface ElectronAPI {
projectRoot?: string
) => Promise<{ success: boolean; error?: string }>;
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
// Updater API
updater: UpdaterAPI;
}
// =============================================================================