feat(window): implement custom title bar for Windows with native controls

- Added a WindowsTitleBar component to provide a conventional title bar experience when the native frame is hidden.
- Integrated window control functionalities (minimize, maximize, close) using IPC handlers for better user interaction.
- Updated the TabbedLayout to include the new WindowsTitleBar, ensuring a consistent UI across platforms.
- Enhanced the README with additional details about the new window controls feature.

This commit improves the user interface on Windows by providing familiar window management controls, enhancing usability and consistency.
This commit is contained in:
matt 2026-02-13 04:44:34 +09:00
parent a9ea131546
commit 49fd2b592f
16 changed files with 571 additions and 122 deletions

View file

@ -12,6 +12,7 @@
<p align="center">
<a href="https://claude-dev.tools"><img src="https://img.shields.io/badge/Website-claude--dev.tools-blue?style=flat-square" alt="Website" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases/latest"><img src="https://img.shields.io/github/v/release/matt1398/claude-devtools?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml"><img src="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases"><img src="https://img.shields.io/github/downloads/matt1398/claude-devtools/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp;
@ -22,6 +23,9 @@
<br />
<p align="center">
<a href="https://claude-dev.tools">
<img src="https://img.shields.io/badge/Website-claude--dev.tools-171717?logo=googlechrome&logoColor=white&style=flat" alt="Website" height="30" />
</a>&nbsp;&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases/latest">
<img src="https://img.shields.io/badge/macOS-Download-black?logo=apple&logoColor=white&style=flat" alt="Download for macOS" height="30" />
</a>&nbsp;&nbsp;
@ -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 |

View file

@ -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');
}

52
src/main/ipc/window.ts Normal file
View file

@ -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');
}

View file

@ -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<ReturnType<typeof analyzeSessionFileMetadata>>;
}
>();
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<string>();
@ -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<Session> {
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<Session> {
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<Session> {
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<boolean> {
private async hasDisplayableContent(
filePath: string,
mtimeMs?: number,
size?: number
): Promise<boolean> {
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;

View file

@ -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.

View file

@ -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<string, number>();
/** 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<void> {
try {
const seenFiles = new Set<string>();
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<void> {
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)

View file

@ -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';

View file

@ -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<boolean>,
},
onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
callback(data);

View file

@ -465,6 +465,13 @@ export class HttpAPIClient implements ElectronAPI {
return { success: true };
};
windowControls = {
minimize: async (): Promise<void> => {},
maximize: async (): Promise<void> => {},
close: async (): Promise<void> => {},
isMaximized: async (): Promise<boolean> => false,
};
// ---------------------------------------------------------------------------
// Updater (browser no-ops)
// ---------------------------------------------------------------------------

View file

@ -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 &mdash;, &#39;, 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(/<br\s*\/?>\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 (&nbsp;, &mdash;, &#39;, 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<HTMLDivElement>(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<HTMLElement>(
'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}
/>
<div
ref={dialogRef}
className="relative mx-4 w-full max-w-sm rounded-md border p-4 shadow-lg"
role="dialog"
aria-modal="true"
@ -56,17 +140,19 @@ export const UpdateDialog = (): React.JSX.Element | null => {
)}
</div>
{/* Release notes */}
{/* Release notes — normalize HTML then render as markdown */}
{releaseNotes && (
<div
className="mb-4 max-h-32 overflow-y-auto rounded border p-2 text-xs"
className="prose prose-sm mb-4 max-h-48 overflow-y-auto rounded border p-2 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{releaseNotes}
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{normalizeReleaseNotes(releaseNotes)}
</ReactMarkdown>
</div>
)}

View file

@ -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
}
>
<WindowsTitleBar />
<UpdateBanner />
<div className="flex flex-1 overflow-hidden">
{/* Command Palette (Cmd+K) */}

View file

@ -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<void> => {
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 (
<div className="flex shrink-0 select-none items-stretch" style={titleBarStyle}>
{/* Draggable area — app title optional */}
<div className="flex flex-1 items-center pl-4" style={{ minWidth: 0 }}>
<span
className="truncate text-sm font-semibold"
style={{ color: 'var(--color-text-muted)' }}
>
claude-devtools
</span>
</div>
{/* Window controls — no-drag so they receive clicks */}
<div className="flex shrink-0" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button
type="button"
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void minimize()}
title="Minimize"
aria-label="Minimize"
>
<Minus className="size-4" strokeWidth={2.5} />
</button>
<button
type="button"
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void handleMaximize()}
title={isMaximized ? 'Restore' : 'Maximize'}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
<Square className="size-3.5" strokeWidth={2.5} />
</button>
<button
type="button"
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void close()}
title="Close"
aria-label="Close"
>
<X className="size-4" strokeWidth={2.5} />
</button>
</div>
</div>
);
};

View file

@ -65,12 +65,18 @@ export function initializeNotificationListeners(): () => void {
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
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);
}
}
});

View file

@ -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<string, number>();
const sessionRefreshInFlight = new Set<string>();
const sessionRefreshQueued = new Set<string>();
let sessionDetailFetchGeneration = 0;
@ -462,8 +457,6 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
}
const refreshKey = `${projectId}/${sessionId}`;
const generation = (sessionRefreshGeneration.get(refreshKey) ?? 0) + 1;
sessionRefreshGeneration.set(refreshKey, generation);
// Coalesce duplicate in-flight refreshes for the same session.
if (sessionRefreshInFlight.has(refreshKey)) {
@ -475,11 +468,6 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
try {
const detail = await api.getSessionDetail(projectId, sessionId);
// Drop stale responses if a newer refresh started while this one was in flight.
if (sessionRefreshGeneration.get(refreshKey) !== generation) {
return;
}
if (!detail) {
return;
}
@ -502,8 +490,12 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
const latestState = get();
const latestActiveTab = latestState.getActiveTab();
const latestTabsViewingSession = getAllTabs(latestState.paneLayout).filter(
(t) => 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<AppState, [], [], SessionDet
// Also update the session's isOngoing in the sessions array
// This keeps the sidebar in sync with the chat view
const updatedSessions = currentState.sessions.map((s) =>
const updatedSessions = latestState.sessions.map((s) =>
s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s
);

View file

@ -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<string, number>();
const projectRefreshInFlight = new Set<string>();
const projectRefreshQueued = new Set<string>();
// =============================================================================
// Slice Interface
@ -141,10 +138,18 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
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<AppState, [], [], SessionSlice> =
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<AppState, [], [], SessionSlice> =
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);
}
}
},

View file

@ -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<void>;
maximize: () => Promise<void>;
close: () => Promise<void>;
isMaximized: () => Promise<boolean>;
};
// Updater API
updater: UpdaterAPI;