diff --git a/README.md b/README.md
index 610488f4..cf1b1e36 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
+
@@ -22,6 +23,9 @@
+
+
+
@@ -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;