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:
parent
a9ea131546
commit
49fd2b592f
16 changed files with 571 additions and 122 deletions
15
README.md
15
README.md
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
|
|
@ -22,6 +23,9 @@
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
||||||
<a href="https://github.com/matt1398/claude-devtools/releases/latest">
|
<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" />
|
<img src="https://img.shields.io/badge/macOS-Download-black?logo=apple&logoColor=white&style=flat" alt="Download for macOS" height="30" />
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -48,8 +52,7 @@
|
||||||
|
|
||||||
| Platform | Download | Notes |
|
| 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) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Drag to Applications. On first launch: right-click → Open |
|
||||||
| **macOS** (Apple Silicon) | [`.zip`](https://github.com/matt1398/claude-devtools/releases/latest) | Extract and run |
|
|
||||||
| **Windows** | [`.exe`](https://github.com/matt1398/claude-devtools/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
|
| **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.
|
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.
|
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
|
### :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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| `--verbose` JSON dump | Structured, filterable, navigable interface — no noise |
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
} from './updater';
|
} from './updater';
|
||||||
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
||||||
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
||||||
|
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ServiceContext,
|
ServiceContext,
|
||||||
|
|
@ -90,6 +91,7 @@ export function initializeIpcHandlers(
|
||||||
registerUpdaterHandlers(ipcMain);
|
registerUpdaterHandlers(ipcMain);
|
||||||
registerSshHandlers(ipcMain);
|
registerSshHandlers(ipcMain);
|
||||||
registerContextHandlers(ipcMain);
|
registerContextHandlers(ipcMain);
|
||||||
|
registerWindowHandlers(ipcMain);
|
||||||
|
|
||||||
logger.info('All handlers registered');
|
logger.info('All handlers registered');
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +112,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeUpdaterHandlers(ipcMain);
|
removeUpdaterHandlers(ipcMain);
|
||||||
removeSshHandlers(ipcMain);
|
removeSshHandlers(ipcMain);
|
||||||
removeContextHandlers(ipcMain);
|
removeContextHandlers(ipcMain);
|
||||||
|
removeWindowHandlers(ipcMain);
|
||||||
|
|
||||||
logger.info('All handlers removed');
|
logger.info('All handlers removed');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/main/ipc/window.ts
Normal file
52
src/main/ipc/window.ts
Normal 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');
|
||||||
|
}
|
||||||
|
|
@ -63,18 +63,19 @@ export class ProjectScanner {
|
||||||
private readonly todosDir: string;
|
private readonly todosDir: string;
|
||||||
private readonly contentPresenceCache = new Map<
|
private readonly contentPresenceCache = new Map<
|
||||||
string,
|
string,
|
||||||
{ mtimeMs: number; hasContent: boolean }
|
{ mtimeMs: number; size: number; hasContent: boolean }
|
||||||
>();
|
>();
|
||||||
private readonly sessionMetadataCache = new Map<
|
private readonly sessionMetadataCache = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;
|
metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
private readonly sessionPreviewCache = new Map<
|
private readonly sessionPreviewCache = new Map<
|
||||||
string,
|
string,
|
||||||
{ mtimeMs: number; preview: { text: string; timestamp: string } | null }
|
{ mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// Delegated services
|
// Delegated services
|
||||||
|
|
@ -228,7 +229,7 @@ export class ProjectScanner {
|
||||||
this.fsProvider.type === 'ssh' ? 32 : 128,
|
this.fsProvider.type === 'ssh' ? 32 : 128,
|
||||||
async (file) => {
|
async (file) => {
|
||||||
const filePath = path.join(projectPath, file.name);
|
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;
|
let cwd: string | null = null;
|
||||||
|
|
||||||
// Over SSH, avoid reading every file body during project discovery.
|
// Over SSH, avoid reading every file body during project discovery.
|
||||||
|
|
@ -416,10 +417,15 @@ export class ProjectScanner {
|
||||||
const sessionId = extractSessionId(file.name);
|
const sessionId = extractSessionId(file.name);
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
const prefetchedMtimeMs = file.mtimeMs;
|
const prefetchedMtimeMs = file.mtimeMs;
|
||||||
|
const prefetchedSize = file.size;
|
||||||
|
|
||||||
if (shouldFilterNoise) {
|
if (shouldFilterNoise) {
|
||||||
// Check if session has non-noise messages (delegated to SessionContentFilter)
|
// 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) {
|
if (!hasContent) {
|
||||||
return null; // Filter out noise-only sessions
|
return null; // Filter out noise-only sessions
|
||||||
}
|
}
|
||||||
|
|
@ -431,7 +437,8 @@ export class ProjectScanner {
|
||||||
sessionId,
|
sessionId,
|
||||||
filePath,
|
filePath,
|
||||||
decodedPath,
|
decodedPath,
|
||||||
prefetchedMtimeMs
|
prefetchedMtimeMs,
|
||||||
|
prefetchedSize
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -495,6 +502,7 @@ export class ProjectScanner {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInfos = await this.collectFulfilledInBatches(
|
const fileInfos = await this.collectFulfilledInBatches(
|
||||||
|
|
@ -502,13 +510,14 @@ export class ProjectScanner {
|
||||||
this.fsProvider.type === 'ssh' ? 48 : 200,
|
this.fsProvider.type === 'ssh' ? 48 : 200,
|
||||||
async (file) => {
|
async (file) => {
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
const { mtimeMs } = await this.resolveFileTimes(file, filePath);
|
const fileDetails = await this.resolveFileDetails(file, filePath);
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
sessionId: extractSessionId(file.name),
|
sessionId: extractSessionId(file.name),
|
||||||
timestamp: mtimeMs,
|
timestamp: fileDetails.mtimeMs,
|
||||||
filePath,
|
filePath,
|
||||||
mtimeMs,
|
mtimeMs: fileDetails.mtimeMs,
|
||||||
|
size: fileDetails.size,
|
||||||
} satisfies SessionFileInfo;
|
} satisfies SessionFileInfo;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -530,7 +539,11 @@ export class ProjectScanner {
|
||||||
const contentResults = await Promise.allSettled(
|
const contentResults = await Promise.allSettled(
|
||||||
fileInfos.map(async (fileInfo) => ({
|
fileInfos.map(async (fileInfo) => ({
|
||||||
sessionId: fileInfo.sessionId,
|
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>();
|
validSessionIds = new Set<string>();
|
||||||
|
|
@ -596,7 +609,11 @@ export class ProjectScanner {
|
||||||
const contentResults = await Promise.allSettled(
|
const contentResults = await Promise.allSettled(
|
||||||
batch.map(async (fileInfo) => ({
|
batch.map(async (fileInfo) => ({
|
||||||
fileInfo,
|
fileInfo,
|
||||||
hasContent: await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs),
|
hasContent: await this.hasDisplayableContent(
|
||||||
|
fileInfo.filePath,
|
||||||
|
fileInfo.mtimeMs,
|
||||||
|
fileInfo.size
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
contentBatch = contentResults
|
contentBatch = contentResults
|
||||||
|
|
@ -624,7 +641,8 @@ export class ProjectScanner {
|
||||||
fileInfo.sessionId,
|
fileInfo.sessionId,
|
||||||
fileInfo.filePath,
|
fileInfo.filePath,
|
||||||
decodedPath,
|
decodedPath,
|
||||||
fileInfo.mtimeMs
|
fileInfo.mtimeMs,
|
||||||
|
fileInfo.size
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
sessions.push(...builtSessions);
|
sessions.push(...builtSessions);
|
||||||
|
|
@ -684,19 +702,26 @@ export class ProjectScanner {
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
prefetchedMtimeMs?: number
|
prefetchedMtimeMs?: number,
|
||||||
|
prefetchedSize?: number
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const usePrefetchedTimes = typeof prefetchedMtimeMs === 'number';
|
const usePrefetchedStats =
|
||||||
const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath);
|
typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number';
|
||||||
|
const stats = usePrefetchedStats ? null : await this.fsProvider.stat(filePath);
|
||||||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||||
|
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||||
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
|
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
|
||||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||||
const metadata =
|
const metadata =
|
||||||
cachedMetadata?.mtimeMs === effectiveMtime
|
cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize
|
||||||
? cachedMetadata.metadata
|
? cachedMetadata.metadata
|
||||||
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||||
if (cachedMetadata?.mtimeMs !== effectiveMtime) {
|
if (cachedMetadata?.mtimeMs !== effectiveMtime || cachedMetadata.size !== effectiveSize) {
|
||||||
this.sessionMetadataCache.set(filePath, { mtimeMs: effectiveMtime, metadata });
|
this.sessionMetadataCache.set(filePath, {
|
||||||
|
mtimeMs: effectiveMtime,
|
||||||
|
size: effectiveSize,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for subagents and load task list data in parallel
|
// Check for subagents and load task list data in parallel
|
||||||
|
|
@ -731,19 +756,26 @@ export class ProjectScanner {
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
prefetchedMtimeMs?: number
|
prefetchedMtimeMs?: number,
|
||||||
|
prefetchedSize?: number
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const times =
|
const usePrefetchedStats =
|
||||||
typeof prefetchedMtimeMs === 'number'
|
typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number';
|
||||||
? { mtimeMs: prefetchedMtimeMs, birthtimeMs: prefetchedMtimeMs }
|
const stats = usePrefetchedStats ? null : await this.fsProvider.stat(filePath);
|
||||||
: await this.resolveFileTimes(undefined, 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 cachedPreview = this.sessionPreviewCache.get(filePath);
|
||||||
const preview =
|
const preview =
|
||||||
cachedPreview?.mtimeMs === times.mtimeMs
|
cachedPreview?.mtimeMs === effectiveMtime && cachedPreview.size === effectiveSize
|
||||||
? cachedPreview.preview
|
? cachedPreview.preview
|
||||||
: await this.extractLightPreviewWithRetry(filePath);
|
: await this.extractLightPreviewWithRetry(filePath);
|
||||||
if (cachedPreview?.mtimeMs !== times.mtimeMs) {
|
if (cachedPreview?.mtimeMs !== effectiveMtime || cachedPreview.size !== effectiveSize) {
|
||||||
this.sessionPreviewCache.set(filePath, { mtimeMs: times.mtimeMs, preview });
|
this.sessionPreviewCache.set(filePath, {
|
||||||
|
mtimeMs: effectiveMtime,
|
||||||
|
size: effectiveSize,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const metadataLevel: SessionMetadataLevel = 'light';
|
const metadataLevel: SessionMetadataLevel = 'light';
|
||||||
|
|
||||||
|
|
@ -751,7 +783,7 @@ export class ProjectScanner {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
projectPath,
|
projectPath,
|
||||||
createdAt: Math.floor(times.birthtimeMs),
|
createdAt: Math.floor(birthtimeMs),
|
||||||
firstMessage: preview?.text,
|
firstMessage: preview?.text,
|
||||||
messageTimestamp: preview?.timestamp,
|
messageTimestamp: preview?.timestamp,
|
||||||
hasSubagents: false,
|
hasSubagents: false,
|
||||||
|
|
@ -770,7 +802,8 @@ export class ProjectScanner {
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
prefetchedMtimeMs?: number
|
prefetchedMtimeMs?: number,
|
||||||
|
prefetchedSize?: number
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
if (metadataLevel === 'light') {
|
if (metadataLevel === 'light') {
|
||||||
return this.buildLightSessionMetadata(
|
return this.buildLightSessionMetadata(
|
||||||
|
|
@ -778,7 +811,8 @@ export class ProjectScanner {
|
||||||
sessionId,
|
sessionId,
|
||||||
filePath,
|
filePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
prefetchedMtimeMs
|
prefetchedMtimeMs,
|
||||||
|
prefetchedSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -788,7 +822,8 @@ export class ProjectScanner {
|
||||||
sessionId,
|
sessionId,
|
||||||
filePath,
|
filePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
prefetchedMtimeMs
|
prefetchedMtimeMs,
|
||||||
|
prefetchedSize
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// In SSH mode, never drop a visible session row due to transient deep-parse failures.
|
// In SSH mode, never drop a visible session row due to transient deep-parse failures.
|
||||||
|
|
@ -802,7 +837,8 @@ export class ProjectScanner {
|
||||||
sessionId,
|
sessionId,
|
||||||
filePath,
|
filePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
prefetchedMtimeMs
|
prefetchedMtimeMs,
|
||||||
|
prefetchedSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -995,14 +1031,20 @@ export class ProjectScanner {
|
||||||
/**
|
/**
|
||||||
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
|
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
|
||||||
*/
|
*/
|
||||||
private async resolveFileTimes(
|
private async resolveFileDetails(
|
||||||
entry: FsDirent | undefined,
|
entry: FsDirent | undefined,
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<{ mtimeMs: number; birthtimeMs: number }> {
|
): Promise<{ mtimeMs: number; birthtimeMs: number; size: number }> {
|
||||||
if (entry && typeof entry.mtimeMs === 'number') {
|
if (
|
||||||
|
entry &&
|
||||||
|
typeof entry.mtimeMs === 'number' &&
|
||||||
|
typeof entry.birthtimeMs === 'number' &&
|
||||||
|
typeof entry.size === 'number'
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
mtimeMs: entry.mtimeMs,
|
mtimeMs: entry.mtimeMs,
|
||||||
birthtimeMs: entry.birthtimeMs ?? entry.mtimeMs,
|
birthtimeMs: entry.birthtimeMs,
|
||||||
|
size: entry.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1010,6 +1052,7 @@ export class ProjectScanner {
|
||||||
return {
|
return {
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs: stats.mtimeMs,
|
||||||
birthtimeMs: stats.birthtimeMs,
|
birthtimeMs: stats.birthtimeMs,
|
||||||
|
size: stats.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,13 +1157,20 @@ export class ProjectScanner {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a session file has non-noise displayable content.
|
* 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 {
|
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);
|
const cached = this.contentPresenceCache.get(filePath);
|
||||||
if (cached?.mtimeMs === effectiveMtime) {
|
if (cached?.mtimeMs === effectiveMtime && cached.size === effectiveSize) {
|
||||||
return cached.hasContent;
|
return cached.hasContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1128,7 +1178,11 @@ export class ProjectScanner {
|
||||||
filePath,
|
filePath,
|
||||||
this.fsProvider
|
this.fsProvider
|
||||||
);
|
);
|
||||||
this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, hasContent });
|
this.contentPresenceCache.set(filePath, {
|
||||||
|
mtimeMs: effectiveMtime,
|
||||||
|
size: effectiveSize,
|
||||||
|
hasContent,
|
||||||
|
});
|
||||||
return hasContent;
|
return hasContent;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -224,21 +224,42 @@ export class DataCache {
|
||||||
* Invalidates a cache entry by project and session IDs.
|
* Invalidates a cache entry by project and session IDs.
|
||||||
*/
|
*/
|
||||||
invalidateSession(projectId: string, sessionId: string): void {
|
invalidateSession(projectId: string, sessionId: string): void {
|
||||||
this.invalidate(DataCache.buildKey(projectId, sessionId));
|
const keysToDelete: string[] = [];
|
||||||
this.invalidateSubagentSession(projectId, sessionId);
|
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.
|
* Invalidates all cached subagent details for a session.
|
||||||
*/
|
*/
|
||||||
invalidateSubagentSession(projectId: string, sessionId: string): void {
|
invalidateSubagentSession(projectId: string, sessionId: string): void {
|
||||||
const prefix = `subagent-${projectId}-${sessionId}-`;
|
const sessionToken = `-${sessionId}-`;
|
||||||
const keysToDelete: string[] = [];
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
for (const key of this.cache.keys()) {
|
for (const key of this.cache.keys()) {
|
||||||
if (key.startsWith(prefix)) {
|
if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) {
|
||||||
keysToDelete.push(key);
|
keysToDelete.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of keysToDelete) {
|
for (const key of keysToDelete) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +273,13 @@ export class DataCache {
|
||||||
const keysToDelete: string[] = [];
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
for (const key of this.cache.keys()) {
|
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);
|
keysToDelete.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -344,17 +371,27 @@ export class DataCache {
|
||||||
const sessionIds: string[] = [];
|
const sessionIds: string[] = [];
|
||||||
|
|
||||||
for (const key of this.cache.keys()) {
|
for (const key of this.cache.keys()) {
|
||||||
if (key.startsWith(`${projectId}/`)) {
|
const parsed = DataCache.parseKey(key);
|
||||||
const parsed = DataCache.parseKey(key);
|
if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) {
|
||||||
if (parsed) {
|
sessionIds.push(parsed.sessionId);
|
||||||
sessionIds.push(parsed.sessionId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionIds;
|
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.
|
* Disposes the cache and prevents further use.
|
||||||
* Clears all cached data and disables caching.
|
* Clears all cached data and disables caching.
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,11 @@ export class FileWatcher extends EventEmitter {
|
||||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||||
private pollingTimer: NodeJS.Timeout | null = null;
|
private pollingTimer: NodeJS.Timeout | null = null;
|
||||||
/** Polling interval for SSH mode */
|
/** 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 */
|
/** Guard to prevent overlapping SSH polling runs */
|
||||||
private pollingInProgress = false;
|
private pollingInProgress = false;
|
||||||
|
/** Indicates whether the first polling baseline snapshot has completed */
|
||||||
|
private sshPollPrimed = false;
|
||||||
/** Track file sizes for SSH polling change detection */
|
/** Track file sizes for SSH polling change detection */
|
||||||
private polledFileSizes = new Map<string, number>();
|
private polledFileSizes = new Map<string, number>();
|
||||||
/** Files currently being processed (concurrency guard) */
|
/** Files currently being processed (concurrency guard) */
|
||||||
|
|
@ -179,6 +181,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.pollingTimer = null;
|
this.pollingTimer = null;
|
||||||
}
|
}
|
||||||
this.pollingInProgress = false;
|
this.pollingInProgress = false;
|
||||||
|
this.sshPollPrimed = false;
|
||||||
this.polledFileSizes.clear();
|
this.polledFileSizes.clear();
|
||||||
|
|
||||||
// Clear error detection tracking
|
// Clear error detection tracking
|
||||||
|
|
@ -374,7 +377,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
if (this.pollingTimer) return;
|
if (this.pollingTimer) return;
|
||||||
|
|
||||||
logger.info('FileWatcher: Starting SSH polling mode');
|
logger.info('FileWatcher: Starting SSH polling mode');
|
||||||
this.pollingTimer = setInterval(() => {
|
const runPoll = (): void => {
|
||||||
if (this.pollingInProgress) {
|
if (this.pollingInProgress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -387,7 +390,11 @@ export class FileWatcher extends EventEmitter {
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.pollingInProgress = false;
|
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> {
|
private async pollForChanges(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const seenFiles = new Set<string>();
|
||||||
const projectDirs = await this.fsProvider.readdir(this.projectsPath);
|
const projectDirs = await this.fsProvider.readdir(this.projectsPath);
|
||||||
for (const dir of projectDirs) {
|
for (const dir of projectDirs) {
|
||||||
if (!dir.isDirectory()) continue;
|
if (!dir.isDirectory()) continue;
|
||||||
|
|
@ -411,26 +419,50 @@ export class FileWatcher extends EventEmitter {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||||
|
|
||||||
const fullPath = path.join(projectPath, entry.name);
|
const fullPath = path.join(projectPath, entry.name);
|
||||||
|
seenFiles.add(fullPath);
|
||||||
try {
|
try {
|
||||||
const observedSize =
|
const observedSize =
|
||||||
typeof entry.size === 'number'
|
typeof entry.size === 'number'
|
||||||
? entry.size
|
? entry.size
|
||||||
: (await this.fsProvider.stat(fullPath)).size;
|
: (await this.fsProvider.stat(fullPath)).size;
|
||||||
const lastSize = this.polledFileSizes.get(fullPath);
|
const lastSize = this.polledFileSizes.get(fullPath);
|
||||||
|
const relativePath = path.join(dir.name, entry.name);
|
||||||
|
|
||||||
if (lastSize === undefined) {
|
if (lastSize === undefined) {
|
||||||
// First time seeing this file
|
// First time seeing this file: after baseline, emit add.
|
||||||
this.polledFileSizes.set(fullPath, observedSize);
|
this.polledFileSizes.set(fullPath, observedSize);
|
||||||
|
if (this.sshPollPrimed) {
|
||||||
|
this.handleProjectsChange('rename', relativePath);
|
||||||
|
}
|
||||||
} else if (observedSize !== lastSize) {
|
} else if (observedSize !== lastSize) {
|
||||||
// File changed
|
// File changed
|
||||||
this.polledFileSizes.set(fullPath, observedSize);
|
this.polledFileSizes.set(fullPath, observedSize);
|
||||||
this.handleProjectsChange('change', path.join(dir.name, entry.name));
|
this.handleProjectsChange('change', relativePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
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) {
|
} catch (err) {
|
||||||
logger.error('Error polling for changes:', err);
|
logger.error('Error polling for changes:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -461,12 +493,21 @@ export class FileWatcher extends EventEmitter {
|
||||||
* Process a debounced projects change.
|
* Process a debounced projects change.
|
||||||
*/
|
*/
|
||||||
private async processProjectsChange(eventType: string, filename: string): Promise<void> {
|
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];
|
const projectId = parts[0];
|
||||||
|
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
const fullPath = path.join(this.projectsPath, filename);
|
|
||||||
const fileExists = await this.fsProvider.exists(fullPath);
|
const fileExists = await this.fsProvider.exists(fullPath);
|
||||||
|
|
||||||
// Determine change type
|
// Determine change type
|
||||||
|
|
@ -482,11 +523,11 @@ export class FileWatcher extends EventEmitter {
|
||||||
let isSubagent = false;
|
let isSubagent = false;
|
||||||
|
|
||||||
// Session file at project root: projectId/sessionId.jsonl
|
// 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');
|
sessionId = path.basename(parts[1], '.jsonl');
|
||||||
}
|
}
|
||||||
// Subagent file: projectId/sessionId/subagents/agent-hash.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];
|
sessionId = parts[1];
|
||||||
isSubagent = true;
|
isSubagent = true;
|
||||||
}
|
}
|
||||||
|
|
@ -510,7 +551,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
|
|
||||||
this.emit('file-change', event);
|
this.emit('file-change', event);
|
||||||
logger.info(
|
logger.info(
|
||||||
`FileWatcher: ${changeType} ${isSubagent ? 'subagent' : 'session'} - ${filename}`
|
`FileWatcher: ${changeType} ${isSubagent ? 'subagent' : 'session'} - ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detect errors in changed session files (not deleted files)
|
// Detect errors in changed session files (not deleted files)
|
||||||
|
|
|
||||||
|
|
@ -134,3 +134,19 @@ export const HTTP_SERVER_STOP = 'httpServer:stop';
|
||||||
|
|
||||||
/** Get HTTP server status */
|
/** Get HTTP server status */
|
||||||
export const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
|
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';
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ import {
|
||||||
UPDATER_DOWNLOAD,
|
UPDATER_DOWNLOAD,
|
||||||
UPDATER_INSTALL,
|
UPDATER_INSTALL,
|
||||||
UPDATER_STATUS,
|
UPDATER_STATUS,
|
||||||
|
WINDOW_CLOSE,
|
||||||
|
WINDOW_IS_MAXIMIZED,
|
||||||
|
WINDOW_MAXIMIZE,
|
||||||
|
WINDOW_MINIMIZE,
|
||||||
} from './constants/ipcChannels';
|
} from './constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
CONFIG_ADD_IGNORE_REGEX,
|
CONFIG_ADD_IGNORE_REGEX,
|
||||||
|
|
@ -308,6 +312,14 @@ const electronAPI: ElectronAPI = {
|
||||||
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
|
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
|
||||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
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) => {
|
onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
|
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
|
||||||
callback(data);
|
callback(data);
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,13 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
return { success: true };
|
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)
|
// Updater (browser no-ops)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,41 @@
|
||||||
* UpdateDialog - Modal dialog shown when a new version is available.
|
* UpdateDialog - Modal dialog shown when a new version is available.
|
||||||
*
|
*
|
||||||
* Prompts the user to download the update or dismiss it.
|
* 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 { useStore } from '@renderer/store';
|
||||||
import { X } from 'lucide-react';
|
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(/<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 ( , —, ', 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 => {
|
export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
||||||
|
|
@ -14,6 +45,58 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
const downloadUpdate = useStore((s) => s.downloadUpdate);
|
const downloadUpdate = useStore((s) => s.downloadUpdate);
|
||||||
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
|
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;
|
if (!showUpdateDialog) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,6 +110,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
className="relative mx-4 w-full max-w-sm rounded-md border p-4 shadow-lg"
|
className="relative mx-4 w-full max-w-sm rounded-md border p-4 shadow-lg"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
@ -56,17 +140,19 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Release notes */}
|
{/* Release notes — normalize HTML then render as markdown */}
|
||||||
{releaseNotes && (
|
{releaseNotes && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface)',
|
backgroundColor: 'var(--color-surface)',
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'var(--color-border)',
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{releaseNotes}
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
|
{normalizeReleaseNotes(releaseNotes)}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { CommandPalette } from '../search/CommandPalette';
|
||||||
|
|
||||||
import { PaneContainer } from './PaneContainer';
|
import { PaneContainer } from './PaneContainer';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { WindowsTitleBar } from './WindowsTitleBar';
|
||||||
|
|
||||||
export const TabbedLayout = (): React.JSX.Element => {
|
export const TabbedLayout = (): React.JSX.Element => {
|
||||||
// Enable keyboard shortcuts
|
// Enable keyboard shortcuts
|
||||||
|
|
@ -32,6 +33,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
||||||
{ '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties
|
{ '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<WindowsTitleBar />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Command Palette (Cmd+K) */}
|
{/* Command Palette (Cmd+K) */}
|
||||||
|
|
|
||||||
97
src/renderer/components/layout/WindowsTitleBar.tsx
Normal file
97
src/renderer/components/layout/WindowsTitleBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -65,12 +65,18 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const SESSION_REFRESH_DEBOUNCE_MS = 150;
|
const SESSION_REFRESH_DEBOUNCE_MS = 150;
|
||||||
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
|
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 scheduleSessionRefresh = (projectId: string, sessionId: string): void => {
|
||||||
const key = `${projectId}/${sessionId}`;
|
const key = `${projectId}/${sessionId}`;
|
||||||
const existingTimer = pendingSessionRefreshTimers.get(key);
|
// Throttle (not trailing debounce): keep at most one pending refresh per session.
|
||||||
if (existingTimer) {
|
// Debounce can starve under continuous writes and delay UI updates indefinitely.
|
||||||
clearTimeout(existingTimer);
|
if (pendingSessionRefreshTimers.has(key)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
pendingSessionRefreshTimers.delete(key);
|
pendingSessionRefreshTimers.delete(key);
|
||||||
|
|
@ -81,9 +87,9 @@ export function initializeNotificationListeners(): () => void {
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleProjectRefresh = (projectId: string): void => {
|
const scheduleProjectRefresh = (projectId: string): void => {
|
||||||
const existingTimer = pendingProjectRefreshTimers.get(projectId);
|
// Throttle (not trailing debounce): keep at most one pending refresh per project.
|
||||||
if (existingTimer) {
|
if (pendingProjectRefreshTimers.has(projectId)) {
|
||||||
clearTimeout(existingTimer);
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
pendingProjectRefreshTimers.delete(projectId);
|
pendingProjectRefreshTimers.delete(projectId);
|
||||||
|
|
@ -207,25 +213,43 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = useStore.getState();
|
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)
|
// Refresh sidebar session list only when a new top-level session file is added.
|
||||||
if (event.type === 'add' && !event.isSubagent && event.projectId) {
|
// Refreshing on every "change" causes excessive list churn while Claude is writing.
|
||||||
// Refresh sessions list if viewing this project (without loading state)
|
if (event.type === 'add' && !event.isSubagent) {
|
||||||
if (state.selectedProjectId === event.projectId) {
|
if (matchesSelectedProject && selectedProjectId) {
|
||||||
scheduleProjectRefresh(event.projectId);
|
scheduleProjectRefresh(selectedProjectId);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle session or subagent content change
|
// Keep opened session view in sync on content changes.
|
||||||
if (event.type === 'change' && event.projectId && event.sessionId) {
|
if (event.type === 'change' && selectedProjectId) {
|
||||||
// Check if the changed session is visible in ANY pane (not just focused)
|
const activeSessionId = state.selectedSessionId;
|
||||||
const isViewingSession =
|
const eventSessionId = event.sessionId;
|
||||||
state.selectedSessionId === event.sessionId || isSessionVisibleInAnyPane(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
|
// Use refreshSessionInPlace to avoid flickering and preserve UI state
|
||||||
scheduleSessionRefresh(event.projectId, event.sessionId);
|
scheduleSessionRefresh(refreshProjectId, sessionIdToRefresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,6 @@ import { resolveFilePath } from '../utils/pathResolution';
|
||||||
|
|
||||||
const logger = createLogger('Store:sessionDetail');
|
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 sessionRefreshInFlight = new Set<string>();
|
||||||
const sessionRefreshQueued = new Set<string>();
|
const sessionRefreshQueued = new Set<string>();
|
||||||
let sessionDetailFetchGeneration = 0;
|
let sessionDetailFetchGeneration = 0;
|
||||||
|
|
@ -462,8 +457,6 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshKey = `${projectId}/${sessionId}`;
|
const refreshKey = `${projectId}/${sessionId}`;
|
||||||
const generation = (sessionRefreshGeneration.get(refreshKey) ?? 0) + 1;
|
|
||||||
sessionRefreshGeneration.set(refreshKey, generation);
|
|
||||||
|
|
||||||
// Coalesce duplicate in-flight refreshes for the same session.
|
// Coalesce duplicate in-flight refreshes for the same session.
|
||||||
if (sessionRefreshInFlight.has(refreshKey)) {
|
if (sessionRefreshInFlight.has(refreshKey)) {
|
||||||
|
|
@ -475,11 +468,6 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
try {
|
try {
|
||||||
const detail = await api.getSessionDetail(projectId, sessionId);
|
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) {
|
if (!detail) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -502,8 +490,12 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
|
|
||||||
const latestState = get();
|
const latestState = get();
|
||||||
const latestActiveTab = latestState.getActiveTab();
|
const latestActiveTab = latestState.getActiveTab();
|
||||||
|
const latestTabsViewingSession = getAllTabs(latestState.paneLayout).filter(
|
||||||
|
(t) => t.type === 'session' && t.sessionId === sessionId
|
||||||
|
);
|
||||||
const stillViewingSession =
|
const stillViewingSession =
|
||||||
latestState.selectedSessionId === sessionId ||
|
latestState.selectedSessionId === sessionId ||
|
||||||
|
latestTabsViewingSession.length > 0 ||
|
||||||
(latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId);
|
(latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId);
|
||||||
if (!stillViewingSession) {
|
if (!stillViewingSession) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -534,7 +526,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
|
|
||||||
// Also update the session's isOngoing in the sessions array
|
// Also update the session's isOngoing in the sessions array
|
||||||
// This keeps the sidebar in sync with the chat view
|
// 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
|
s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,8 @@ import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:session');
|
const logger = createLogger('Store:session');
|
||||||
|
|
||||||
/**
|
const projectRefreshInFlight = new Set<string>();
|
||||||
* Tracks the latest in-place refresh generation per project.
|
const projectRefreshQueued = new Set<string>();
|
||||||
* Used to guarantee last-write-wins under rapid file change events.
|
|
||||||
*/
|
|
||||||
const projectRefreshGeneration = new Map<string, number>();
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Slice Interface
|
// Slice Interface
|
||||||
|
|
@ -141,10 +138,18 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
const newSessions = result.sessions.filter((s) => !existingIds.has(s.id));
|
const newSessions = result.sessions.filter((s) => !existingIds.has(s.id));
|
||||||
set((prevState) => {
|
set((prevState) => {
|
||||||
// Deduplicate: pinned sessions fetched earlier may appear in paginated results.
|
// 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 {
|
return {
|
||||||
sessions: [...prevState.sessions, ...newSessions],
|
sessions: nextSessions,
|
||||||
sessionsCursor: result.nextCursor,
|
sessionsCursor: result.nextCursor,
|
||||||
sessionsHasMore: result.hasMore,
|
sessionsHasMore: result.hasMore,
|
||||||
|
sessionsTotalCount: stableTotalCount,
|
||||||
sessionsLoadingMore: false,
|
sessionsLoadingMore: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -209,8 +214,14 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1;
|
// Coalesce duplicate in-flight refreshes for the same project.
|
||||||
projectRefreshGeneration.set(projectId, generation);
|
// 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 {
|
try {
|
||||||
const { connectionMode } = get();
|
const { connectionMode } = get();
|
||||||
|
|
@ -220,30 +231,32 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drop stale responses from older in-flight refreshes
|
const { sessions: prevSessions, sessionsTotalCount: prevTotalCount } = get();
|
||||||
if (projectRefreshGeneration.get(projectId) !== generation) {
|
const refreshedIds = new Set(result.sessions.map((s) => s.id));
|
||||||
return;
|
// 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));
|
||||||
// Preserve pinned sessions that are beyond page 1
|
const mergedSessions = [...result.sessions, ...retainedTail];
|
||||||
const { pinnedSessionIds, sessions: prevSessions } = get();
|
const inferredTotalLowerBound = mergedSessions.length + (result.hasMore ? 1 : 0);
|
||||||
const newPageIds = new Set(result.sessions.map((s) => s.id));
|
const stableTotalCount = Math.max(prevTotalCount, result.totalCount, inferredTotalLowerBound);
|
||||||
const pinnedSet = new Set(pinnedSessionIds);
|
|
||||||
const pinnedToRetain = prevSessions.filter(
|
|
||||||
(s) => pinnedSet.has(s.id) && !newPageIds.has(s.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update sessions without loading state
|
// Update sessions without loading state
|
||||||
set({
|
set({
|
||||||
sessions: [...result.sessions, ...pinnedToRetain],
|
sessions: mergedSessions,
|
||||||
sessionsCursor: result.nextCursor,
|
sessionsCursor: result.nextCursor,
|
||||||
sessionsHasMore: result.hasMore,
|
sessionsHasMore: result.hasMore,
|
||||||
sessionsTotalCount: result.totalCount,
|
sessionsTotalCount: stableTotalCount,
|
||||||
// Don't touch sessionsLoading - keep it as-is
|
// Don't touch sessionsLoading - keep it as-is
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('refreshSessionsInPlace error:', error);
|
logger.error('refreshSessionsInPlace error:', error);
|
||||||
// Don't set error state - this is a background refresh
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,14 @@ export interface ElectronAPI {
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
openExternal: (url: string) => 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 API
|
||||||
updater: UpdaterAPI;
|
updater: UpdaterAPI;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue