diff --git a/README.md b/README.md index 610488f4..cf1b1e36 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@

+ Website  Latest Release  CI Status  Downloads  @@ -22,6 +23,9 @@

+ + Website +    Download for macOS    @@ -48,8 +52,7 @@ | Platform | Download | Notes | |----------|----------|-------| -| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Drag to Applications. On first launch: right-click → Open (unsigned) | -| **macOS** (Apple Silicon) | [`.zip`](https://github.com/matt1398/claude-devtools/releases/latest) | Extract and run | +| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Drag to Applications. On first launch: right-click → Open | | **Windows** | [`.exe`](https://github.com/matt1398/claude-devtools/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" | The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login. @@ -90,9 +93,11 @@ There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Ka Claude Code doesn't expose what's actually in the context window. claude-devtools reverse-engineers it. -The engine walks each turn of the session and reconstructs the full set of context injections — **CLAUDE.md files** (global, project, and directory-level), **@-mentioned files**, **tool call inputs and outputs**, **extended thinking**, **team coordination overhead**, and **user prompt text** — then accumulates them across turns with compaction-phase awareness. When a context reset occurs mid-session, the tracker detects the boundary, measures the token delta, and starts a new phase. +The engine walks each turn of the session and reconstructs the full set of context injections — **CLAUDE.md files** (broken down by global, project, and directory-level), **skill activations**, **@-mentioned files**, **tool call inputs and outputs**, **extended thinking**, **team coordination overhead**, and **user prompt text** — then accumulates them across turns with compaction awareness. -The result is a per-turn breakdown of estimated token attribution across 6 categories, surfaced in three places: a **Context Badge** on each assistant response, a **Token Usage popover** with percentage breakdowns, and a dedicated **Session Context Panel** with phase-filtered drill-down into every injection. +**Compaction visualization.** When Claude Code hits its context limit, it silently compresses your conversation and continues. Most tools don't even notice. claude-devtools detects these compaction boundaries, measures the token delta before and after, and visualizes how your context fills, compresses, and refills over the course of a session. You can see exactly what was in the window at any point, and how the composition shifted after each compaction. + +The result is a per-turn breakdown of estimated token attribution across 7 categories, surfaced in three places: a **Context Badge** on each assistant response, a **Token Usage popover** with percentage breakdowns, and a dedicated **Session Context Panel** with drill-down into every injection across compaction boundaries. ### :hammer_and_wrench: Rich Tool Call Inspector @@ -138,7 +143,7 @@ Open multiple sessions side-by-side. Drag-and-drop tabs between panes, split vie | `Read 3 files` | Exact file paths, syntax-highlighted content with line numbers | | `Searched for 1 pattern` | The regex pattern, every matching file, and the matched lines | | `Edited 2 files` | Inline diffs with added/removed highlighting per file | -| A three-segment context bar | Per-turn token attribution across 6 categories with compaction-phase tracking | +| A three-segment context bar | Per-turn token attribution across 7 categories — CLAUDE.md breakdown, skills, @-mentions, tool I/O, thinking, teams, user text — with compaction visualization showing how context fills, compresses, and refills | | Subagent output interleaved with the main thread | Isolated execution trees per agent, expandable inline with their own metrics | | Teammate messages buried in session logs | Color-coded teammate cards with name, message, and full team lifecycle visibility | | `--verbose` JSON dump | Structured, filterable, navigable interface — no noise | diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5e486e92..dcff5e79 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -49,6 +49,7 @@ import { } from './updater'; import { registerUtilityHandlers, removeUtilityHandlers } from './utility'; import { registerValidationHandlers, removeValidationHandlers } from './validation'; +import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { ServiceContext, @@ -90,6 +91,7 @@ export function initializeIpcHandlers( registerUpdaterHandlers(ipcMain); registerSshHandlers(ipcMain); registerContextHandlers(ipcMain); + registerWindowHandlers(ipcMain); logger.info('All handlers registered'); } @@ -110,6 +112,7 @@ export function removeIpcHandlers(): void { removeUpdaterHandlers(ipcMain); removeSshHandlers(ipcMain); removeContextHandlers(ipcMain); + removeWindowHandlers(ipcMain); logger.info('All handlers removed'); } diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts new file mode 100644 index 00000000..d2cb1edc --- /dev/null +++ b/src/main/ipc/window.ts @@ -0,0 +1,52 @@ +/** + * IPC Handlers for native window controls. + * Used when the title bar is hidden (e.g. Windows / Linux) so the renderer + * can provide conventional min / maximize / close buttons. + */ + +import { createLogger } from '@shared/utils/logger'; +import { BrowserWindow, type IpcMain } from 'electron'; + +const logger = createLogger('IPC:window'); + +function getMainWindow(): BrowserWindow | null { + const win = BrowserWindow.getFocusedWindow(); + if (win && !win.isDestroyed()) return win; + const all = BrowserWindow.getAllWindows(); + return all.length > 0 ? all[0] : null; +} + +export function registerWindowHandlers(ipcMain: IpcMain): void { + ipcMain.handle('window:minimize', () => { + const win = getMainWindow(); + if (win && !win.isDestroyed()) win.minimize(); + }); + + ipcMain.handle('window:maximize', () => { + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + if (win.isMaximized()) win.unmaximize(); + else win.maximize(); + } + }); + + ipcMain.handle('window:close', () => { + const win = getMainWindow(); + if (win && !win.isDestroyed()) win.close(); + }); + + ipcMain.handle('window:isMaximized', (): boolean => { + const win = getMainWindow(); + return win != null && !win.isDestroyed() && win.isMaximized(); + }); + + logger.info('Window handlers registered'); +} + +export function removeWindowHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('window:minimize'); + ipcMain.removeHandler('window:maximize'); + ipcMain.removeHandler('window:close'); + ipcMain.removeHandler('window:isMaximized'); + logger.info('Window handlers removed'); +} diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 4fed0ca7..29678468 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -63,18 +63,19 @@ export class ProjectScanner { private readonly todosDir: string; private readonly contentPresenceCache = new Map< string, - { mtimeMs: number; hasContent: boolean } + { mtimeMs: number; size: number; hasContent: boolean } >(); private readonly sessionMetadataCache = new Map< string, { mtimeMs: number; + size: number; metadata: Awaited>; } >(); private readonly sessionPreviewCache = new Map< string, - { mtimeMs: number; preview: { text: string; timestamp: string } | null } + { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null } >(); // Delegated services @@ -228,7 +229,7 @@ export class ProjectScanner { this.fsProvider.type === 'ssh' ? 32 : 128, async (file) => { const filePath = path.join(projectPath, file.name); - const { mtimeMs, birthtimeMs } = await this.resolveFileTimes(file, filePath); + const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath); let cwd: string | null = null; // Over SSH, avoid reading every file body during project discovery. @@ -416,10 +417,15 @@ export class ProjectScanner { const sessionId = extractSessionId(file.name); const filePath = path.join(projectPath, file.name); const prefetchedMtimeMs = file.mtimeMs; + const prefetchedSize = file.size; if (shouldFilterNoise) { // Check if session has non-noise messages (delegated to SessionContentFilter) - const hasContent = await this.hasDisplayableContent(filePath, prefetchedMtimeMs); + const hasContent = await this.hasDisplayableContent( + filePath, + prefetchedMtimeMs, + prefetchedSize + ); if (!hasContent) { return null; // Filter out noise-only sessions } @@ -431,7 +437,8 @@ export class ProjectScanner { sessionId, filePath, decodedPath, - prefetchedMtimeMs + prefetchedMtimeMs, + prefetchedSize ); }) ); @@ -495,6 +502,7 @@ export class ProjectScanner { timestamp: number; filePath: string; mtimeMs: number; + size: number; } const fileInfos = await this.collectFulfilledInBatches( @@ -502,13 +510,14 @@ export class ProjectScanner { this.fsProvider.type === 'ssh' ? 48 : 200, async (file) => { const filePath = path.join(projectPath, file.name); - const { mtimeMs } = await this.resolveFileTimes(file, filePath); + const fileDetails = await this.resolveFileDetails(file, filePath); return { name: file.name, sessionId: extractSessionId(file.name), - timestamp: mtimeMs, + timestamp: fileDetails.mtimeMs, filePath, - mtimeMs, + mtimeMs: fileDetails.mtimeMs, + size: fileDetails.size, } satisfies SessionFileInfo; } ); @@ -530,7 +539,11 @@ export class ProjectScanner { const contentResults = await Promise.allSettled( fileInfos.map(async (fileInfo) => ({ sessionId: fileInfo.sessionId, - hasContent: await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs), + hasContent: await this.hasDisplayableContent( + fileInfo.filePath, + fileInfo.mtimeMs, + fileInfo.size + ), })) ); validSessionIds = new Set(); @@ -596,7 +609,11 @@ export class ProjectScanner { const contentResults = await Promise.allSettled( batch.map(async (fileInfo) => ({ fileInfo, - hasContent: await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs), + hasContent: await this.hasDisplayableContent( + fileInfo.filePath, + fileInfo.mtimeMs, + fileInfo.size + ), })) ); contentBatch = contentResults @@ -624,7 +641,8 @@ export class ProjectScanner { fileInfo.sessionId, fileInfo.filePath, decodedPath, - fileInfo.mtimeMs + fileInfo.mtimeMs, + fileInfo.size ) ); sessions.push(...builtSessions); @@ -684,19 +702,26 @@ export class ProjectScanner { sessionId: string, filePath: string, projectPath: string, - prefetchedMtimeMs?: number + prefetchedMtimeMs?: number, + prefetchedSize?: number ): Promise { - const usePrefetchedTimes = typeof prefetchedMtimeMs === 'number'; - const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath); + const usePrefetchedStats = + typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number'; + const stats = usePrefetchedStats ? null : await this.fsProvider.stat(filePath); const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); + const effectiveSize = prefetchedSize ?? stats?.size ?? -1; const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime; const cachedMetadata = this.sessionMetadataCache.get(filePath); const metadata = - cachedMetadata?.mtimeMs === effectiveMtime + cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize ? cachedMetadata.metadata : await analyzeSessionFileMetadata(filePath, this.fsProvider); - if (cachedMetadata?.mtimeMs !== effectiveMtime) { - this.sessionMetadataCache.set(filePath, { mtimeMs: effectiveMtime, metadata }); + if (cachedMetadata?.mtimeMs !== effectiveMtime || cachedMetadata.size !== effectiveSize) { + this.sessionMetadataCache.set(filePath, { + mtimeMs: effectiveMtime, + size: effectiveSize, + metadata, + }); } // Check for subagents and load task list data in parallel @@ -731,19 +756,26 @@ export class ProjectScanner { sessionId: string, filePath: string, projectPath: string, - prefetchedMtimeMs?: number + prefetchedMtimeMs?: number, + prefetchedSize?: number ): Promise { - const times = - typeof prefetchedMtimeMs === 'number' - ? { mtimeMs: prefetchedMtimeMs, birthtimeMs: prefetchedMtimeMs } - : await this.resolveFileTimes(undefined, filePath); + const usePrefetchedStats = + typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number'; + const stats = usePrefetchedStats ? null : await this.fsProvider.stat(filePath); + const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); + const effectiveSize = prefetchedSize ?? stats?.size ?? -1; + const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime; const cachedPreview = this.sessionPreviewCache.get(filePath); const preview = - cachedPreview?.mtimeMs === times.mtimeMs + cachedPreview?.mtimeMs === effectiveMtime && cachedPreview.size === effectiveSize ? cachedPreview.preview : await this.extractLightPreviewWithRetry(filePath); - if (cachedPreview?.mtimeMs !== times.mtimeMs) { - this.sessionPreviewCache.set(filePath, { mtimeMs: times.mtimeMs, preview }); + if (cachedPreview?.mtimeMs !== effectiveMtime || cachedPreview.size !== effectiveSize) { + this.sessionPreviewCache.set(filePath, { + mtimeMs: effectiveMtime, + size: effectiveSize, + preview, + }); } const metadataLevel: SessionMetadataLevel = 'light'; @@ -751,7 +783,7 @@ export class ProjectScanner { id: sessionId, projectId, projectPath, - createdAt: Math.floor(times.birthtimeMs), + createdAt: Math.floor(birthtimeMs), firstMessage: preview?.text, messageTimestamp: preview?.timestamp, hasSubagents: false, @@ -770,7 +802,8 @@ export class ProjectScanner { sessionId: string, filePath: string, projectPath: string, - prefetchedMtimeMs?: number + prefetchedMtimeMs?: number, + prefetchedSize?: number ): Promise { if (metadataLevel === 'light') { return this.buildLightSessionMetadata( @@ -778,7 +811,8 @@ export class ProjectScanner { sessionId, filePath, projectPath, - prefetchedMtimeMs + prefetchedMtimeMs, + prefetchedSize ); } @@ -788,7 +822,8 @@ export class ProjectScanner { sessionId, filePath, projectPath, - prefetchedMtimeMs + prefetchedMtimeMs, + prefetchedSize ); } catch (error) { // In SSH mode, never drop a visible session row due to transient deep-parse failures. @@ -802,7 +837,8 @@ export class ProjectScanner { sessionId, filePath, projectPath, - prefetchedMtimeMs + prefetchedMtimeMs, + prefetchedSize ); } } @@ -995,14 +1031,20 @@ export class ProjectScanner { /** * Resolve best-available file timestamps from directory entry metadata or stat fallback. */ - private async resolveFileTimes( + private async resolveFileDetails( entry: FsDirent | undefined, filePath: string - ): Promise<{ mtimeMs: number; birthtimeMs: number }> { - if (entry && typeof entry.mtimeMs === 'number') { + ): Promise<{ mtimeMs: number; birthtimeMs: number; size: number }> { + if ( + entry && + typeof entry.mtimeMs === 'number' && + typeof entry.birthtimeMs === 'number' && + typeof entry.size === 'number' + ) { return { mtimeMs: entry.mtimeMs, - birthtimeMs: entry.birthtimeMs ?? entry.mtimeMs, + birthtimeMs: entry.birthtimeMs, + size: entry.size, }; } @@ -1010,6 +1052,7 @@ export class ProjectScanner { return { mtimeMs: stats.mtimeMs, birthtimeMs: stats.birthtimeMs, + size: stats.size, }; } @@ -1114,13 +1157,20 @@ export class ProjectScanner { /** * Checks whether a session file has non-noise displayable content. - * Uses mtime-based memoization to avoid expensive re-parsing on repeated requests. + * Uses mtime+size memoization to avoid expensive re-parsing on repeated requests. */ - private async hasDisplayableContent(filePath: string, mtimeMs?: number): Promise { + private async hasDisplayableContent( + filePath: string, + mtimeMs?: number, + size?: number + ): Promise { try { - const effectiveMtime = mtimeMs ?? (await this.fsProvider.stat(filePath)).mtimeMs; + const hasPrefetched = typeof mtimeMs === 'number' && typeof size === 'number'; + const stats = hasPrefetched ? null : await this.fsProvider.stat(filePath); + const effectiveMtime = mtimeMs ?? stats?.mtimeMs ?? Date.now(); + const effectiveSize = size ?? stats?.size ?? -1; const cached = this.contentPresenceCache.get(filePath); - if (cached?.mtimeMs === effectiveMtime) { + if (cached?.mtimeMs === effectiveMtime && cached.size === effectiveSize) { return cached.hasContent; } @@ -1128,7 +1178,11 @@ export class ProjectScanner { filePath, this.fsProvider ); - this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, hasContent }); + this.contentPresenceCache.set(filePath, { + mtimeMs: effectiveMtime, + size: effectiveSize, + hasContent, + }); return hasContent; } catch { return false; diff --git a/src/main/services/infrastructure/DataCache.ts b/src/main/services/infrastructure/DataCache.ts index 964c9690..004e86bf 100644 --- a/src/main/services/infrastructure/DataCache.ts +++ b/src/main/services/infrastructure/DataCache.ts @@ -224,21 +224,42 @@ export class DataCache { * Invalidates a cache entry by project and session IDs. */ invalidateSession(projectId: string, sessionId: string): void { - this.invalidate(DataCache.buildKey(projectId, sessionId)); - this.invalidateSubagentSession(projectId, sessionId); + const keysToDelete: string[] = []; + const sessionToken = `-${sessionId}-`; + + for (const key of this.cache.keys()) { + const parsed = DataCache.parseKey(key); + if ( + parsed?.sessionId === sessionId && + this.matchesProjectOrComposite(parsed.projectId, projectId) + ) { + keysToDelete.push(key); + continue; + } + + if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.cache.delete(key); + } } /** * Invalidates all cached subagent details for a session. */ invalidateSubagentSession(projectId: string, sessionId: string): void { - const prefix = `subagent-${projectId}-${sessionId}-`; + const sessionToken = `-${sessionId}-`; const keysToDelete: string[] = []; + for (const key of this.cache.keys()) { - if (key.startsWith(prefix)) { + if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) { keysToDelete.push(key); } } + for (const key of keysToDelete) { this.cache.delete(key); } @@ -252,7 +273,13 @@ export class DataCache { const keysToDelete: string[] = []; for (const key of this.cache.keys()) { - if (key.startsWith(`${projectId}/`)) { + const parsed = DataCache.parseKey(key); + if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) { + keysToDelete.push(key); + continue; + } + + if (this.isSubagentKeyForProject(key, projectId)) { keysToDelete.push(key); } } @@ -344,17 +371,27 @@ export class DataCache { const sessionIds: string[] = []; for (const key of this.cache.keys()) { - if (key.startsWith(`${projectId}/`)) { - const parsed = DataCache.parseKey(key); - if (parsed) { - sessionIds.push(parsed.sessionId); - } + const parsed = DataCache.parseKey(key); + if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) { + sessionIds.push(parsed.sessionId); } } return sessionIds; } + private matchesProjectOrComposite(projectId: string, baseProjectId: string): boolean { + return projectId === baseProjectId || projectId.startsWith(`${baseProjectId}::`); + } + + private isSubagentKeyForProject(key: string, baseProjectId: string): boolean { + if (!key.startsWith('subagent-')) { + return false; + } + const prefix = `subagent-${baseProjectId}`; + return key.startsWith(`${prefix}-`) || key.startsWith(`${prefix}::`); + } + /** * Disposes the cache and prevents further use. * Clears all cached data and disables caching. diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 4f450f7c..b359e226 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -73,9 +73,11 @@ export class FileWatcher extends EventEmitter { /** Timer for SSH polling mode (replaces fs.watch) */ private pollingTimer: NodeJS.Timeout | null = null; /** Polling interval for SSH mode */ - private static readonly SSH_POLL_INTERVAL_MS = 10000; + private static readonly SSH_POLL_INTERVAL_MS = 3000; /** Guard to prevent overlapping SSH polling runs */ private pollingInProgress = false; + /** Indicates whether the first polling baseline snapshot has completed */ + private sshPollPrimed = false; /** Track file sizes for SSH polling change detection */ private polledFileSizes = new Map(); /** Files currently being processed (concurrency guard) */ @@ -179,6 +181,7 @@ export class FileWatcher extends EventEmitter { this.pollingTimer = null; } this.pollingInProgress = false; + this.sshPollPrimed = false; this.polledFileSizes.clear(); // Clear error detection tracking @@ -374,7 +377,7 @@ export class FileWatcher extends EventEmitter { if (this.pollingTimer) return; logger.info('FileWatcher: Starting SSH polling mode'); - this.pollingTimer = setInterval(() => { + const runPoll = (): void => { if (this.pollingInProgress) { return; } @@ -387,7 +390,11 @@ export class FileWatcher extends EventEmitter { .finally(() => { this.pollingInProgress = false; }); - }, FileWatcher.SSH_POLL_INTERVAL_MS); + }; + + // Prime immediately so newly created sessions appear without waiting a full interval. + runPoll(); + this.pollingTimer = setInterval(runPoll, FileWatcher.SSH_POLL_INTERVAL_MS); } /** @@ -395,6 +402,7 @@ export class FileWatcher extends EventEmitter { */ private async pollForChanges(): Promise { try { + const seenFiles = new Set(); const projectDirs = await this.fsProvider.readdir(this.projectsPath); for (const dir of projectDirs) { if (!dir.isDirectory()) continue; @@ -411,26 +419,50 @@ export class FileWatcher extends EventEmitter { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; const fullPath = path.join(projectPath, entry.name); + seenFiles.add(fullPath); try { const observedSize = typeof entry.size === 'number' ? entry.size : (await this.fsProvider.stat(fullPath)).size; const lastSize = this.polledFileSizes.get(fullPath); + const relativePath = path.join(dir.name, entry.name); if (lastSize === undefined) { - // First time seeing this file + // First time seeing this file: after baseline, emit add. this.polledFileSizes.set(fullPath, observedSize); + if (this.sshPollPrimed) { + this.handleProjectsChange('rename', relativePath); + } } else if (observedSize !== lastSize) { // File changed this.polledFileSizes.set(fullPath, observedSize); - this.handleProjectsChange('change', path.join(dir.name, entry.name)); + this.handleProjectsChange('change', relativePath); } } catch { continue; } } } + + // Detect deleted files after baseline is established. + if (this.sshPollPrimed) { + const removedFiles: string[] = []; + for (const trackedPath of this.polledFileSizes.keys()) { + if (!seenFiles.has(trackedPath)) { + removedFiles.push(trackedPath); + } + } + for (const removedPath of removedFiles) { + this.polledFileSizes.delete(removedPath); + const relativePath = path.relative(this.projectsPath, removedPath); + if (relativePath && !relativePath.startsWith('..')) { + this.handleProjectsChange('rename', relativePath); + } + } + } else { + this.sshPollPrimed = true; + } } catch (err) { logger.error('Error polling for changes:', err); } @@ -461,12 +493,21 @@ export class FileWatcher extends EventEmitter { * Process a debounced projects change. */ private async processProjectsChange(eventType: string, filename: string): Promise { - const parts = filename.split(path.sep); + const fullPath = path.isAbsolute(filename) + ? path.normalize(filename) + : path.join(this.projectsPath, filename); + const relativePath = path.relative(this.projectsPath, fullPath); + + // Ignore events outside of the watched projects root. + if (relativePath.startsWith('..')) { + return; + } + + // Normalize separators to support platform/event source differences. + const parts = relativePath.split(/[\\/]/).filter(Boolean); const projectId = parts[0]; if (!projectId) return; - - const fullPath = path.join(this.projectsPath, filename); const fileExists = await this.fsProvider.exists(fullPath); // Determine change type @@ -482,11 +523,11 @@ export class FileWatcher extends EventEmitter { let isSubagent = false; // Session file at project root: projectId/sessionId.jsonl - if (parts.length === 2) { + if (parts.length === 2 && parts[1].endsWith('.jsonl')) { sessionId = path.basename(parts[1], '.jsonl'); } // Subagent file: projectId/sessionId/subagents/agent-hash.jsonl - else if (parts.length === 4 && parts[2] === 'subagents') { + else if (parts.length === 4 && parts[2] === 'subagents' && parts[3].endsWith('.jsonl')) { sessionId = parts[1]; isSubagent = true; } @@ -510,7 +551,7 @@ export class FileWatcher extends EventEmitter { this.emit('file-change', event); logger.info( - `FileWatcher: ${changeType} ${isSubagent ? 'subagent' : 'session'} - ${filename}` + `FileWatcher: ${changeType} ${isSubagent ? 'subagent' : 'session'} - ${relativePath}` ); // Detect errors in changed session files (not deleted files) diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 550c10ae..82c7336e 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -134,3 +134,19 @@ export const HTTP_SERVER_STOP = 'httpServer:stop'; /** Get HTTP server status */ export const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; + +// ============================================================================= +// Window Controls API (Windows / Linux — native title bar is hidden) +// ============================================================================= + +/** Minimize window */ +export const WINDOW_MINIMIZE = 'window:minimize'; + +/** Maximize or restore window */ +export const WINDOW_MAXIMIZE = 'window:maximize'; + +/** Close window */ +export const WINDOW_CLOSE = 'window:close'; + +/** Whether the window is currently maximized */ +export const WINDOW_IS_MAXIMIZED = 'window:isMaximized'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4220cc04..e84df012 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,10 @@ import { UPDATER_DOWNLOAD, UPDATER_INSTALL, UPDATER_STATUS, + WINDOW_CLOSE, + WINDOW_IS_MAXIMIZED, + WINDOW_MAXIMIZE, + WINDOW_MINIMIZE, } from './constants/ipcChannels'; import { CONFIG_ADD_IGNORE_REGEX, @@ -308,6 +312,14 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('shell:openPath', targetPath, projectRoot), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + // Window controls (when title bar is hidden, e.g. Windows / Linux) + windowControls: { + minimize: () => ipcRenderer.invoke(WINDOW_MINIMIZE), + maximize: () => ipcRenderer.invoke(WINDOW_MAXIMIZE), + close: () => ipcRenderer.invoke(WINDOW_CLOSE), + isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise, + }, + onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void => callback(data); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 232dbeb8..186c5fa3 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -465,6 +465,13 @@ export class HttpAPIClient implements ElectronAPI { return { success: true }; }; + windowControls = { + minimize: async (): Promise => {}, + maximize: async (): Promise => {}, + close: async (): Promise => {}, + isMaximized: async (): Promise => false, + }; + // --------------------------------------------------------------------------- // Updater (browser no-ops) // --------------------------------------------------------------------------- diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index 7ec6e6b9..e63121e1 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -2,10 +2,41 @@ * UpdateDialog - Modal dialog shown when a new version is available. * * Prompts the user to download the update or dismiss it. + * Release notes may be HTML from the updater; we normalize to text and render as markdown. */ +import { useEffect, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { markdownComponents } from '@renderer/components/chat/markdownComponents'; import { useStore } from '@renderer/store'; import { X } from 'lucide-react'; +import remarkGfm from 'remark-gfm'; + +/** + * Normalize release notes: strip HTML tags and convert block elements to newlines. + * Uses DOMParser for proper HTML entity decoding (handles all entities like —, ', etc.) + */ +function normalizeReleaseNotes(html: string): string { + if (!html?.trim()) return ''; + + // Convert block elements to newlines for better formatting + const processed = html + .replace(/<\/p>\s*/gi, '\n\n') + .replace(/\s*/gi, '\n') + .replace(/<\/div>\s*/gi, '\n') + .replace(/<\/li>\s*/gi, '\n') + .replace(/<\/h[1-6]>\s*/gi, '\n\n'); + + // Use DOMParser to decode HTML entities and strip remaining tags + // This properly handles all HTML entities ( , —, ', etc.) + const parser = new DOMParser(); + const doc = parser.parseFromString(processed, 'text/html'); + const text = doc.body.textContent || ''; + + // Normalize multiple newlines + return text.replace(/\n{3,}/g, '\n\n').trim(); +} export const UpdateDialog = (): React.JSX.Element | null => { const showUpdateDialog = useStore((s) => s.showUpdateDialog); @@ -14,6 +45,58 @@ export const UpdateDialog = (): React.JSX.Element | null => { const downloadUpdate = useStore((s) => s.downloadUpdate); const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog); + const dialogRef = useRef(null); + + // Handle ESC key to close dialog + useEffect(() => { + if (!showUpdateDialog) return; + + const handleEscape = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + dismissUpdateDialog(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [showUpdateDialog, dismissUpdateDialog]); + + // Focus trap: keep focus within dialog + useEffect(() => { + if (!showUpdateDialog || !dialogRef.current) return; + + const dialog = dialogRef.current; + const focusableElements = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + // Focus first element when dialog opens + firstElement?.focus(); + + const handleTab = (e: KeyboardEvent): void => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + // Shift+Tab: if on first element, go to last + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else { + // Tab: if on last element, go to first + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } + } + }; + + dialog.addEventListener('keydown', handleTab); + return () => dialog.removeEventListener('keydown', handleTab); + }, [showUpdateDialog]); + if (!showUpdateDialog) return null; return ( @@ -27,6 +110,7 @@ export const UpdateDialog = (): React.JSX.Element | null => { tabIndex={-1} />

{ )}
- {/* Release notes */} + {/* Release notes — normalize HTML then render as markdown */} {releaseNotes && (
- {releaseNotes} + + {normalizeReleaseNotes(releaseNotes)} +
)} diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 820e9c62..7abcca82 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -18,6 +18,7 @@ import { CommandPalette } from '../search/CommandPalette'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; +import { WindowsTitleBar } from './WindowsTitleBar'; export const TabbedLayout = (): React.JSX.Element => { // Enable keyboard shortcuts @@ -32,6 +33,7 @@ export const TabbedLayout = (): React.JSX.Element => { { '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties } > +
{/* Command Palette (Cmd+K) */} diff --git a/src/renderer/components/layout/WindowsTitleBar.tsx b/src/renderer/components/layout/WindowsTitleBar.tsx new file mode 100644 index 00000000..bde601c0 --- /dev/null +++ b/src/renderer/components/layout/WindowsTitleBar.tsx @@ -0,0 +1,97 @@ +/** + * WindowsTitleBar - Conventional title bar for Windows when the native frame is hidden. + * + * Renders a draggable top strip with window controls (minimize, maximize/restore, close) + * on the right, matching Windows conventions. Only shown in Electron on Windows (win32). + */ + +import { useEffect, useState } from 'react'; + +import { isElectronMode } from '@renderer/api'; +import { Minus, Square, X } from 'lucide-react'; + +const TITLE_BAR_HEIGHT = 32; + +function isWindowsDesktop(): boolean { + if (!isElectronMode()) return false; + return window.navigator.userAgent.includes('Windows'); +} + +export const WindowsTitleBar = (): React.JSX.Element | null => { + const [isMaximized, setIsMaximized] = useState(false); + const isWin = isWindowsDesktop(); + const api = typeof window !== 'undefined' ? window.electronAPI?.windowControls : null; + + useEffect(() => { + if (api) void api.isMaximized().then(setIsMaximized); + }, [api]); + + if (!isWin || !api) return null; + + const { minimize, maximize, close, isMaximized: getIsMaximized } = api; + + const handleMaximize = async (): Promise => { + await maximize(); + const maximized = await getIsMaximized(); + setIsMaximized(maximized); + }; + + const buttonBase = + 'flex h-full w-12 items-center justify-center transition-colors border-0 outline-none'; + const buttonHover = 'hover:bg-white/10'; + + const titleBarStyle = { + height: `${TITLE_BAR_HEIGHT}px`, + backgroundColor: 'var(--color-surface-sidebar)', + borderBottom: '1px solid var(--color-border)', + WebkitAppRegion: 'drag', + } as React.CSSProperties; + + return ( +
+ {/* Draggable area — app title optional */} +
+ + claude-devtools + +
+ + {/* Window controls — no-drag so they receive clicks */} +
+ + + +
+
+ ); +}; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 14b1dd00..4c45e1b7 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -65,12 +65,18 @@ export function initializeNotificationListeners(): () => void { const pendingProjectRefreshTimers = new Map>(); const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; + const getBaseProjectId = (projectId: string | null | undefined): string | null => { + if (!projectId) return null; + const separatorIndex = projectId.indexOf('::'); + return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId; + }; const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { const key = `${projectId}/${sessionId}`; - const existingTimer = pendingSessionRefreshTimers.get(key); - if (existingTimer) { - clearTimeout(existingTimer); + // Throttle (not trailing debounce): keep at most one pending refresh per session. + // Debounce can starve under continuous writes and delay UI updates indefinitely. + if (pendingSessionRefreshTimers.has(key)) { + return; } const timer = setTimeout(() => { pendingSessionRefreshTimers.delete(key); @@ -81,9 +87,9 @@ export function initializeNotificationListeners(): () => void { }; const scheduleProjectRefresh = (projectId: string): void => { - const existingTimer = pendingProjectRefreshTimers.get(projectId); - if (existingTimer) { - clearTimeout(existingTimer); + // Throttle (not trailing debounce): keep at most one pending refresh per project. + if (pendingProjectRefreshTimers.has(projectId)) { + return; } const timer = setTimeout(() => { pendingProjectRefreshTimers.delete(projectId); @@ -207,25 +213,43 @@ export function initializeNotificationListeners(): () => void { } const state = useStore.getState(); + const selectedProjectId = state.selectedProjectId; + const selectedProjectBaseId = getBaseProjectId(selectedProjectId); + const eventProjectBaseId = getBaseProjectId(event.projectId); + const matchesSelectedProject = + !!selectedProjectId && + (eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId); - // Handle new session added to a project (main session files only) - if (event.type === 'add' && !event.isSubagent && event.projectId) { - // Refresh sessions list if viewing this project (without loading state) - if (state.selectedProjectId === event.projectId) { - scheduleProjectRefresh(event.projectId); + // Refresh sidebar session list only when a new top-level session file is added. + // Refreshing on every "change" causes excessive list churn while Claude is writing. + if (event.type === 'add' && !event.isSubagent) { + if (matchesSelectedProject && selectedProjectId) { + scheduleProjectRefresh(selectedProjectId); } - return; } - // Handle session or subagent content change - if (event.type === 'change' && event.projectId && event.sessionId) { - // Check if the changed session is visible in ANY pane (not just focused) - const isViewingSession = - state.selectedSessionId === event.sessionId || isSessionVisibleInAnyPane(event.sessionId); + // Keep opened session view in sync on content changes. + if (event.type === 'change' && selectedProjectId) { + const activeSessionId = state.selectedSessionId; + const eventSessionId = event.sessionId; + const isViewingEventSession = + !!eventSessionId && + (activeSessionId === eventSessionId || isSessionVisibleInAnyPane(eventSessionId)); + const shouldFallbackRefreshActiveSession = + matchesSelectedProject && !eventSessionId && !!activeSessionId; + const sessionIdToRefresh = + (isViewingEventSession ? eventSessionId : null) ?? + (shouldFallbackRefreshActiveSession ? activeSessionId : null); + + if (sessionIdToRefresh) { + const allTabs = state.getAllPaneTabs(); + const visibleSessionTab = allTabs.find( + (tab) => tab.type === 'session' && tab.sessionId === sessionIdToRefresh + ); + const refreshProjectId = visibleSessionTab?.projectId ?? selectedProjectId; - if (isViewingSession) { // Use refreshSessionInPlace to avoid flickering and preserve UI state - scheduleSessionRefresh(event.projectId, event.sessionId); + scheduleSessionRefresh(refreshProjectId, sessionIdToRefresh); } } }); diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index b5e85eb7..c4b9715b 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -17,11 +17,6 @@ import { resolveFilePath } from '../utils/pathResolution'; const logger = createLogger('Store:sessionDetail'); -/** - * Tracks latest refresh generation per session to avoid stale overwrites when - * many file-change events trigger concurrent in-place refreshes. - */ -const sessionRefreshGeneration = new Map(); const sessionRefreshInFlight = new Set(); const sessionRefreshQueued = new Set(); let sessionDetailFetchGeneration = 0; @@ -462,8 +457,6 @@ export const createSessionDetailSlice: StateCreator t.type === 'session' && t.sessionId === sessionId + ); const stillViewingSession = latestState.selectedSessionId === sessionId || + latestTabsViewingSession.length > 0 || (latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId); if (!stillViewingSession) { return; @@ -534,7 +526,7 @@ export const createSessionDetailSlice: StateCreator + const updatedSessions = latestState.sessions.map((s) => s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s ); diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index 8e61d582..4c9fd169 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -11,11 +11,8 @@ import type { StateCreator } from 'zustand'; const logger = createLogger('Store:session'); -/** - * Tracks the latest in-place refresh generation per project. - * Used to guarantee last-write-wins under rapid file change events. - */ -const projectRefreshGeneration = new Map(); +const projectRefreshInFlight = new Set(); +const projectRefreshQueued = new Set(); // ============================================================================= // Slice Interface @@ -141,10 +138,18 @@ export const createSessionSlice: StateCreator = const newSessions = result.sessions.filter((s) => !existingIds.has(s.id)); set((prevState) => { // Deduplicate: pinned sessions fetched earlier may appear in paginated results. + const nextSessions = [...prevState.sessions, ...newSessions]; + const inferredTotalLowerBound = nextSessions.length + (result.hasMore ? 1 : 0); + const stableTotalCount = Math.max( + prevState.sessionsTotalCount, + result.totalCount, + inferredTotalLowerBound + ); return { - sessions: [...prevState.sessions, ...newSessions], + sessions: nextSessions, sessionsCursor: result.nextCursor, sessionsHasMore: result.hasMore, + sessionsTotalCount: stableTotalCount, sessionsLoadingMore: false, }; }); @@ -209,8 +214,14 @@ export const createSessionSlice: StateCreator = return; } - const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1; - projectRefreshGeneration.set(projectId, generation); + // Coalesce duplicate in-flight refreshes for the same project. + // Without this, frequent file-change events can keep invalidating responses + // before they commit, making the sidebar look stale until writes stop. + if (projectRefreshInFlight.has(projectId)) { + projectRefreshQueued.add(projectId); + return; + } + projectRefreshInFlight.add(projectId); try { const { connectionMode } = get(); @@ -220,30 +231,32 @@ export const createSessionSlice: StateCreator = metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep', }); - // Drop stale responses from older in-flight refreshes - if (projectRefreshGeneration.get(projectId) !== generation) { - return; - } - - // Preserve pinned sessions that are beyond page 1 - const { pinnedSessionIds, sessions: prevSessions } = get(); - const newPageIds = new Set(result.sessions.map((s) => s.id)); - const pinnedSet = new Set(pinnedSessionIds); - const pinnedToRetain = prevSessions.filter( - (s) => pinnedSet.has(s.id) && !newPageIds.has(s.id) - ); + const { sessions: prevSessions, sessionsTotalCount: prevTotalCount } = get(); + const refreshedIds = new Set(result.sessions.map((s) => s.id)); + // Keep previously loaded tail sessions so the sidebar does not collapse + // from N loaded rows back to page-1 rows on every in-place refresh. + const retainedTail = prevSessions.filter((s) => !refreshedIds.has(s.id)); + const mergedSessions = [...result.sessions, ...retainedTail]; + const inferredTotalLowerBound = mergedSessions.length + (result.hasMore ? 1 : 0); + const stableTotalCount = Math.max(prevTotalCount, result.totalCount, inferredTotalLowerBound); // Update sessions without loading state set({ - sessions: [...result.sessions, ...pinnedToRetain], + sessions: mergedSessions, sessionsCursor: result.nextCursor, sessionsHasMore: result.hasMore, - sessionsTotalCount: result.totalCount, + sessionsTotalCount: stableTotalCount, // Don't touch sessionsLoading - keep it as-is }); } catch (error) { logger.error('refreshSessionsInPlace error:', error); // Don't set error state - this is a background refresh + } finally { + projectRefreshInFlight.delete(projectId); + if (projectRefreshQueued.has(projectId)) { + projectRefreshQueued.delete(projectId); + void get().refreshSessionsInPlace(projectId); + } } }, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index f6e29a53..55832127 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -348,6 +348,14 @@ export interface ElectronAPI { ) => Promise<{ success: boolean; error?: string }>; openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; + // Window controls (when title bar is hidden, e.g. Windows / Linux) + windowControls: { + minimize: () => Promise; + maximize: () => Promise; + close: () => Promise; + isMaximized: () => Promise; + }; + // Updater API updater: UpdaterAPI;