diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index aef5e755..1f97b3f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,6 +6,9 @@ on:
- 'v*'
workflow_dispatch:
+permissions:
+ contents: write
+
jobs:
package-mac:
runs-on: macos-latest
diff --git a/README.md b/README.md
index 2f67f067..11049db1 100644
--- a/README.md
+++ b/README.md
@@ -1,70 +1,134 @@
-# Claude Code Context
+
+
+
-Desktop app for exploring Claude Code session context usage.
+Claude Code Context
-It helps you inspect session timelines, search across sessions, debug context injections (`CLAUDE.md`, mentioned files, tool outputs), and configure notification triggers.
+
+ Stop guessing. See exactly what Claude is doing.
+
+ A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
+
-## 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
+
+
+
+
+
+
+
-## Tech Stack
-- Electron + electron-vite
-- React + TypeScript + Zustand
-- Tailwind CSS
-- Vitest + ESLint
+
-## Requirements
-- Node.js 20+
-- pnpm 10+
-- macOS or Windows
+
+
+
+
+
+---
+
+## 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)
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
new file mode 100644
index 00000000..90031d93
--- /dev/null
+++ b/build/entitlements.mac.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+
diff --git a/electron-builder.yml b/electron-builder.yml
index 61c1c22b..5ccd7770 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -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
diff --git a/package.json b/package.json
index 369a9be3..55190386 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ef59154a..f41a9066 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/scripts/notarize.cjs b/scripts/notarize.cjs
new file mode 100644
index 00000000..7a7af681
--- /dev/null
+++ b/scripts/notarize.cjs
@@ -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,
+ });
+};
diff --git a/src/main/index.ts b/src/main/index.ts
index 6aa253de..c90a3f1f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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');
}
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index cdc6b905..e0f59226 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -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');
}
diff --git a/src/main/ipc/updater.ts b/src/main/ipc/updater.ts
new file mode 100644
index 00000000..7c709ca0
--- /dev/null
+++ b/src/main/ipc/updater.ts
@@ -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 {
+ try {
+ await updaterService.checkForUpdates();
+ } catch (error) {
+ logger.error('Error in updater:check:', getErrorMessage(error));
+ }
+}
+
+async function handleDownload(_event: IpcMainInvokeEvent): Promise {
+ 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));
+ }
+}
diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts
new file mode 100644
index 00000000..50eb7566
--- /dev/null
+++ b/src/main/services/infrastructure/UpdaterService.ts
@@ -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 {
+ try {
+ await autoUpdater.checkForUpdates();
+ } catch (error) {
+ logger.error('Check for updates failed:', getErrorMessage(error));
+ }
+ }
+
+ /**
+ * Download the available update.
+ */
+ async downloadUpdate(): Promise {
+ 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),
+ });
+ });
+ }
+}
diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts
index 8a928c7d..78cb02b0 100644
--- a/src/main/services/infrastructure/index.ts
+++ b/src/main/services/infrastructure/index.ts
@@ -14,3 +14,4 @@ export * from './DataCache';
export * from './FileWatcher';
export * from './NotificationManager';
export * from './TriggerManager';
+export * from './UpdaterService';
diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts
index 845dee5a..6e373e1c 100644
--- a/src/preload/constants/ipcChannels.ts
+++ b/src/preload/constants/ipcChannels.ts
@@ -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';
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3d1ce1e1..1f114471 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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
diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx
new file mode 100644
index 00000000..f0feaba6
--- /dev/null
+++ b/src/renderer/components/common/UpdateBanner.tsx
@@ -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 (
+
+ {isDownloading ? (
+ <>
+
+
+ Downloading update... {percent}%
+
+
+ >
+ ) : (
+ <>
+
+
+ Update ready{availableVersion ? ` (v${availableVersion})` : ''}
+
+
+ Restart to Update
+
+ >
+ )}
+
+ {/* Dismiss */}
+
+
+
+
+ );
+};
diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx
new file mode 100644
index 00000000..73ac3d9a
--- /dev/null
+++ b/src/renderer/components/common/UpdateDialog.tsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+ {/* Close button */}
+
+
+
+
+ {/* Title */}
+
+ Update Available
+
+
+ {/* Body */}
+
+ Version {availableVersion} is available. Would you like to download it?
+
+
+ {/* Release notes */}
+ {releaseNotes && (
+
+ {releaseNotes}
+
+ )}
+
+ {/* Actions */}
+
+
+ Later
+
+
+
+ Download Update
+
+
+
+
+ );
+};
diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx
index fcde1397..3a71aef7 100644
--- a/src/renderer/components/layout/TabbedLayout.tsx
+++ b/src/renderer/components/layout/TabbedLayout.tsx
@@ -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 (
- {/* Command Palette (Cmd+K) */}
-
+
+
+ {/* Command Palette (Cmd+K) */}
+
- {/* Sidebar - Project dropdown + Sessions (280px) */}
-
+ {/* Sidebar - Project dropdown + Sessions (280px) */}
+
- {/* Multi-pane content area */}
-
+ {/* Multi-pane content area */}
+
+
+
);
};
diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx
index 975aff08..2bfc71eb 100644
--- a/src/renderer/components/settings/sections/AdvancedSection.tsx
+++ b/src/renderer/components/settings/sections/AdvancedSection.tsx
@@ -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('');
+ 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>();
+ 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 (
+ <>
+
+ Checking...
+ >
+ );
+ case 'not-available':
+ return (
+ <>
+
+ Up to date
+ >
+ );
+ case 'available':
+ case 'downloaded':
+ return (
+ <>
+
+ {updateStatus === 'downloaded' ? 'Update ready' : `v${availableVersion} available`}
+ >
+ );
+ default:
+ return (
+ <>
+
+ Check for Updates
+ >
+ );
+ }
+ };
+
return (
@@ -87,9 +142,27 @@ export const AdvancedSection = ({
-
- Claude Code Context
-
+
+
+ Claude Code Context
+
+
+ {getUpdateButtonContent()}
+
+
Version {version || '...'}
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts
index 42762c39..cb0c286d 100644
--- a/src/renderer/store/index.ts
+++ b/src/renderer/store/index.ts
@@ -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
()((...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()) {
diff --git a/src/renderer/store/slices/updateSlice.ts b/src/renderer/store/slices/updateSlice.ts
new file mode 100644
index 00000000..dd9061ae
--- /dev/null
+++ b/src/renderer/store/slices/updateSlice.ts
@@ -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 = (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 });
+ },
+});
diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts
index de0bb833..6b1c2ce1 100644
--- a/src/renderer/store/types.ts
+++ b/src/renderer/store/types.ts
@@ -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;
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index c60727ed..4748f54b 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -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;
+ download: () => Promise;
+ install: () => Promise;
+ 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;
}
// =============================================================================