From 0f38a141963348f1c5651c15db6bed67b37808bf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:21:38 -0400 Subject: [PATCH 1/3] feat: terminal sessions, file watcher, PTY reattach, settings, MCP standalone chat Overhaul terminal session management with persisted work view state, collapsible session groups, session card redesign, and view mode toggle. Refactor file watcher to use ref-counted subscriptions with dual-mode search indexes. Add PTY session reattach and transcript resume. Introduce terminal preferences (font size, line height, scrollback) in settings. Hide coordinator tools from standalone MCP chat sessions. Simplify ChatTerminalDrawer to reuse shared TerminalView. Code cleanup: dead code removal, deduplication, accessibility fixes across services and components. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../services/files/fileSearchIndexService.ts | 95 ++-- .../main/services/files/fileService.test.ts | 61 ++- .../src/main/services/files/fileService.ts | 37 +- .../services/files/fileWatcherService.test.ts | 145 ++++++ .../main/services/files/fileWatcherService.ts | 205 ++++++--- .../src/main/services/pty/ptyService.test.ts | 206 ++++++++- .../src/main/services/pty/ptyService.ts | 196 ++++++-- .../services/sessions/sessionService.test.ts | 101 ++++- .../main/services/sessions/sessionService.ts | 20 + .../main/utils/terminalSessionSignals.test.ts | 10 +- .../src/main/utils/terminalSessionSignals.ts | 10 +- .../chat/AgentChatPane.submit.test.tsx | 7 + .../components/chat/AgentChatPane.tsx | 1 - .../components/chat/ChatTerminalDrawer.tsx | 422 +++++------------- .../components/files/FilesPage.test.tsx | 319 +++++++++++-- .../renderer/components/files/FilesPage.tsx | 286 +++++++----- .../components/lanes/useLaneWorkSessions.ts | 1 + .../components/settings/GeneralSection.tsx | 75 +++- .../components/terminals/SessionCard.tsx | 66 ++- .../components/terminals/SessionListPane.tsx | 211 +++++---- .../terminals/TerminalView.test.tsx | 67 ++- .../components/terminals/TerminalView.tsx | 95 +++- .../components/terminals/TerminalsPage.tsx | 2 + .../components/terminals/WorkViewArea.tsx | 90 ++-- .../components/terminals/cliLaunch.test.ts | 16 + .../components/terminals/cliLaunch.ts | 6 +- .../terminals/useWorkSessions.test.ts | 186 +++++++- .../components/terminals/useWorkSessions.ts | 89 ++-- apps/desktop/src/renderer/lib/format.test.ts | 48 ++ apps/desktop/src/renderer/lib/format.ts | 15 + .../src/renderer/lib/sessionListCache.test.ts | 25 ++ .../src/renderer/lib/sessionListCache.ts | 2 + .../desktop/src/renderer/lib/sessions.test.ts | 42 ++ apps/desktop/src/renderer/lib/sessions.ts | 13 + .../src/renderer/state/appStore.test.ts | 42 +- apps/desktop/src/renderer/state/appStore.ts | 211 ++++++++- apps/desktop/src/shared/types/files.ts | 3 + apps/desktop/src/shared/types/sessions.ts | 1 + apps/mcp-server/src/mcpServer.test.ts | 50 +++ apps/mcp-server/src/mcpServer.ts | 45 +- docs/ORCHESTRATOR_OVERHAUL.md | 3 +- docs/architecture/AI_INTEGRATION.md | 3 +- docs/features/AGENTS.md | 2 + docs/features/CHAT.md | 7 + docs/features/FILES_AND_EDITOR.md | 35 +- docs/features/ONBOARDING_AND_SETTINGS.md | 2 +- docs/features/TERMINALS_AND_SESSIONS.md | 42 +- 47 files changed, 2694 insertions(+), 922 deletions(-) create mode 100644 apps/desktop/src/main/services/files/fileWatcherService.test.ts create mode 100644 apps/desktop/src/renderer/lib/format.test.ts create mode 100644 apps/desktop/src/renderer/lib/sessions.test.ts diff --git a/apps/desktop/src/main/services/files/fileSearchIndexService.ts b/apps/desktop/src/main/services/files/fileSearchIndexService.ts index 5382d8601..4298d84ea 100644 --- a/apps/desktop/src/main/services/files/fileSearchIndexService.ts +++ b/apps/desktop/src/main/services/files/fileSearchIndexService.ts @@ -20,13 +20,15 @@ type IndexedFile = { type WorkspaceIndex = { workspaceId: string; rootPath: string; + includeIgnored: boolean; files: Map; totalContentBytes: number; buildingPromise: Promise | null; builtAt: string | null; }; -function shouldSkipPathPrefix(relPath: string): boolean { +function shouldSkipPathPrefix(relPath: string, includeIgnored: boolean): boolean { + if (includeIgnored) return false; return relPath === ".ade" || relPath.startsWith(".ade/"); } @@ -50,19 +52,24 @@ async function cooperativeYield(): Promise { export function createFileSearchIndexService() { const byWorkspace = new Map(); - const getOrCreateWorkspaceIndex = (workspaceId: string, rootPath: string): WorkspaceIndex => { - const existing = byWorkspace.get(workspaceId); + const workspaceIndexKey = (workspaceId: string, includeIgnored: boolean): string => + `${workspaceId}::${includeIgnored ? "all" : "default"}`; + + const getOrCreateWorkspaceIndex = (workspaceId: string, rootPath: string, includeIgnored: boolean): WorkspaceIndex => { + const key = workspaceIndexKey(workspaceId, includeIgnored); + const existing = byWorkspace.get(key); if (existing && existing.rootPath === rootPath) return existing; const next: WorkspaceIndex = { workspaceId, rootPath, + includeIgnored, files: new Map(), totalContentBytes: 0, buildingPromise: null, builtAt: null }; - byWorkspace.set(workspaceId, next); + byWorkspace.set(key, next); return next; }; @@ -134,14 +141,14 @@ export function createFileSearchIndexService() { }); }; - const shouldSkipDirectoryName = (name: string): boolean => { + const shouldSkipDirectoryName = (name: string, includeIgnored: boolean): boolean => { if (name === ".git") return true; - if (name === "node_modules") return true; + if (!includeIgnored && name === "node_modules") return true; return false; }; const buildWorkspace = async (index: WorkspaceIndex, opts: { - shouldIgnore: (relPath: string) => Promise; + shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; }): Promise => { index.files.clear(); index.totalContentBytes = 0; @@ -163,9 +170,9 @@ export function createFileSearchIndexService() { for (const entry of entries) { const relPath = normalizeRelative(path.join(relDir, entry.name)); if (!relPath) continue; - if (shouldSkipPathPrefix(relPath)) continue; - if (entry.isDirectory() && shouldSkipDirectoryName(entry.name)) continue; - if (await opts.shouldIgnore(relPath)) continue; + if (shouldSkipPathPrefix(relPath, index.includeIgnored)) continue; + if (entry.isDirectory() && shouldSkipDirectoryName(entry.name, index.includeIgnored)) continue; + if (await opts.shouldIgnore(relPath, index.includeIgnored)) continue; if (entry.isDirectory()) { stack.push(relPath); @@ -189,9 +196,10 @@ export function createFileSearchIndexService() { }; const ensureBuilt = async (workspaceId: string, rootPath: string, opts: { - shouldIgnore: (relPath: string) => Promise; + includeIgnored: boolean; + shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; }): Promise => { - const index = getOrCreateWorkspaceIndex(workspaceId, rootPath); + const index = getOrCreateWorkspaceIndex(workspaceId, rootPath, opts.includeIgnored); if (index.files.size > 0 || index.builtAt) return index; if (index.buildingPromise) { await index.buildingPromise; @@ -209,9 +217,11 @@ export function createFileSearchIndexService() { async ensureIndexed(args: { workspaceId: string; rootPath: string; - shouldIgnore: (relPath: string) => Promise; + includeIgnored: boolean; + shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; }): Promise { await ensureBuilt(args.workspaceId, args.rootPath, { + includeIgnored: args.includeIgnored, shouldIgnore: args.shouldIgnore }); }, @@ -221,9 +231,11 @@ export function createFileSearchIndexService() { rootPath: string; query: string; limit: number; - shouldIgnore: (relPath: string) => Promise; + includeIgnored: boolean; + shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; }): Promise { const index = await ensureBuilt(args.workspaceId, args.rootPath, { + includeIgnored: args.includeIgnored, shouldIgnore: args.shouldIgnore }); @@ -242,9 +254,11 @@ export function createFileSearchIndexService() { rootPath: string; query: string; limit: number; - shouldIgnore: (relPath: string) => Promise; + includeIgnored: boolean; + shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; }): Promise { const index = await ensureBuilt(args.workspaceId, args.rootPath, { + includeIgnored: args.includeIgnored, shouldIgnore: args.shouldIgnore }); @@ -274,35 +288,44 @@ export function createFileSearchIndexService() { path: string; type: "created" | "modified" | "deleted" | "renamed"; oldPath?: string; - shouldIgnore: (relPath: string) => Promise; + shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; }): void { - const index = getOrCreateWorkspaceIndex(args.workspaceId, args.rootPath); - // If this workspace was never indexed yet, defer indexing until first search/quick-open query. - if (!index.builtAt && index.files.size === 0) return; + const relPath = normalizeRelative(args.path); + const matchingIndexes = Array.from(byWorkspace.values()).filter( + (index) => index.workspaceId === args.workspaceId && index.rootPath === args.rootPath + ); - if (args.oldPath) { - removePath(index, args.oldPath); - } + for (const index of matchingIndexes) { + // If this workspace/mode was never indexed yet, defer indexing until first query. + if (!index.builtAt && index.files.size === 0) continue; - if (args.type === "deleted") { - removePath(index, args.path); - return; - } + if (args.oldPath) { + removePath(index, args.oldPath); + } - const relPath = normalizeRelative(args.path); - void args.shouldIgnore(relPath).then((ignored) => { - if (ignored) { - removePath(index, relPath); - return; + if (args.type === "deleted") { + removePath(index, args.path); + continue; } - upsertFile(index, relPath); - }).catch(() => { - // ignore indexing failures - }); + + void args.shouldIgnore(relPath, index.includeIgnored).then((ignored) => { + if (ignored || shouldSkipPathPrefix(relPath, index.includeIgnored)) { + removePath(index, relPath); + return; + } + upsertFile(index, relPath); + }).catch(() => { + // ignore indexing failures + }); + } }, invalidateWorkspace(workspaceId: string): void { - byWorkspace.delete(workspaceId); + for (const key of byWorkspace.keys()) { + if (key.startsWith(`${workspaceId}::`)) { + byWorkspace.delete(key); + } + } }, dispose(): void { diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index e78067379..8ffec7f02 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -4,6 +4,17 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createFileService } from "./fileService"; +function createLaneServiceStub(rootPath: string) { + return { + resolveWorkspaceById: vi.fn(() => ({ + id: "workspace-1", + laneId: "lane-1", + rootPath, + })), + getFilesWorkspaces: vi.fn(() => []), + } as any; +} + describe("fileService", () => { afterEach(() => { vi.restoreAllMocks(); @@ -16,14 +27,7 @@ describe("fileService", () => { const permissionError = Object.assign(new Error("permission denied"), { code: "EACCES" as const }); const originalLstatSync = fs.lstatSync.bind(fs); - const laneService = { - resolveWorkspaceById: vi.fn(() => ({ - id: "workspace-1", - laneId: "lane-1", - rootPath, - })), - getFilesWorkspaces: vi.fn(() => []), - } as any; + const laneService = createLaneServiceStub(rootPath); const service = createFileService({ laneService }); const spy = vi.spyOn(fs, "lstatSync").mockImplementation(((filePath: fs.PathLike) => { @@ -45,4 +49,45 @@ describe("fileService", () => { fs.rmSync(rootPath, { recursive: true, force: true }); } }); + + it("includes ignored files in quick open and search when requested", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-search-")); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.mkdirSync(path.join(rootPath, ".ade", "context"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "src"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, ".ade", "context", "PRD.ade.md"), "# PRD\nRenderer-safe content\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "src", "index.ts"), "export const visible = true;\n", "utf8"); + + const quickOpenDefault = await service.quickOpen({ + workspaceId: "workspace-1", + query: "prd", + includeIgnored: false, + }); + const quickOpenIgnored = await service.quickOpen({ + workspaceId: "workspace-1", + query: "prd", + includeIgnored: true, + }); + const searchDefault = await service.searchText({ + workspaceId: "workspace-1", + query: "renderer-safe", + includeIgnored: false, + }); + const searchIgnored = await service.searchText({ + workspaceId: "workspace-1", + query: "renderer-safe", + includeIgnored: true, + }); + + expect(quickOpenDefault).toEqual([]); + expect(quickOpenIgnored.map((item) => item.path)).toContain(".ade/context/PRD.ade.md"); + expect(searchDefault).toEqual([]); + expect(searchIgnored.map((item) => item.path)).toContain(".ade/context/PRD.ade.md"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 9c99867f4..02baf5a1b 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -246,6 +246,9 @@ export function createFileService({ } }; + const shouldIgnoreForRoot = (rootPath: string) => + (relPath: string, includeIgnored: boolean) => isIgnoredPath(rootPath, relPath, includeIgnored); + const emitLaneMutation = (workspaceId: string, reason: string) => { if (!onLaneWorktreeMutation) return; const workspace = resolveWorkspace(workspaceId); @@ -471,7 +474,7 @@ export function createFileService({ rootPath: workspace.rootPath, path: normalizedRel, type: "modified", - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); emitLaneMutation(args.workspaceId, "file_write"); }, @@ -489,7 +492,7 @@ export function createFileService({ rootPath: workspace.rootPath, path: normalizedRel, type: "created", - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); emitLaneMutation(args.workspaceId, "file_create"); }, @@ -518,7 +521,7 @@ export function createFileService({ type: "renamed", oldPath: oldRel, path: newRel, - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); emitLaneMutation(args.workspaceId, "file_rename"); }, @@ -539,23 +542,27 @@ export function createFileService({ rootPath: workspace.rootPath, path: normalizedRel, type: "deleted", - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); emitLaneMutation(args.workspaceId, "file_delete"); }, async watchWorkspace(args: FilesWatchArgs, callback: (ev: FileChangeEvent) => void, senderId: number): Promise { const workspace = resolveWorkspace(args.workspaceId); - await indexService.ensureIndexed({ - workspaceId: args.workspaceId, - rootPath: workspace.rootPath, - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) - }); + if (!args.includeIgnored) { + await indexService.ensureIndexed({ + workspaceId: args.workspaceId, + rootPath: workspace.rootPath, + includeIgnored: false, + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) + }); + } watcherService.watch( { workspaceId: args.workspaceId, rootPath: workspace.rootPath, - senderId + senderId, + includeIgnored: Boolean(args.includeIgnored) }, (ev) => { invalidateGitStatusCache(workspace.rootPath); @@ -568,7 +575,7 @@ export function createFileService({ type: ev.type, path: ev.path, oldPath: ev.oldPath, - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); callback(ev); } @@ -576,7 +583,7 @@ export function createFileService({ }, stopWatching(args: FilesWatchArgs, senderId: number): void { - watcherService.stop(args.workspaceId, senderId); + watcherService.stop(args.workspaceId, senderId, Boolean(args.includeIgnored)); }, stopWatchingBySender(senderId: number): void { @@ -593,7 +600,8 @@ export function createFileService({ rootPath: workspace.rootPath, query, limit, - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + includeIgnored: Boolean(args.includeIgnored), + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); }, @@ -607,7 +615,8 @@ export function createFileService({ rootPath: workspace.rootPath, query, limit, - shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false) + includeIgnored: Boolean(args.includeIgnored), + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath) }); }, diff --git a/apps/desktop/src/main/services/files/fileWatcherService.test.ts b/apps/desktop/src/main/services/files/fileWatcherService.test.ts new file mode 100644 index 000000000..e678c178b --- /dev/null +++ b/apps/desktop/src/main/services/files/fileWatcherService.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FileChangeEvent } from "../../../shared/types"; + +const chokidarState = vi.hoisted(() => { + const watchers: Array<{ + handlers: Map void>; + close: ReturnType; + }> = []; + const watchMock = vi.fn((_rootPath: string, _options: unknown) => { + const handlers = new Map void>(); + const close = vi.fn(async () => undefined); + const watcher: { + on: ReturnType; + close: ReturnType; + } = { + on: vi.fn((event: string, cb: (absPath: string) => void) => { + handlers.set(event, cb); + return watcher; + }), + close, + }; + watchers.push({ handlers, close }); + return watcher; + }); + return { watchMock, watchers }; +}); + +vi.mock("chokidar", () => ({ + default: { + watch: chokidarState.watchMock, + }, +})); + +import { createFileWatcherService } from "./fileWatcherService"; + +describe("fileWatcherService", () => { + beforeEach(() => { + chokidarState.watchMock.mockClear(); + chokidarState.watchers.length = 0; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps node_modules filtered even when includeIgnored is requested", () => { + const service = createFileWatcherService(); + + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, vi.fn()); + service.watch({ workspaceId: "ws-2", rootPath: "/repo", senderId: 2, includeIgnored: true }, vi.fn()); + + const defaultIgnored = chokidarState.watchMock.mock.calls[0]?.[1] as { ignored: RegExp[] }; + const includeIgnored = chokidarState.watchMock.mock.calls[1]?.[1] as { ignored: RegExp[] }; + + expect(defaultIgnored.ignored.map((pattern) => String(pattern))).toEqual([ + "/(^|[/\\\\])\\.git($|[/\\\\])/", + "/(^|[/\\\\])node_modules($|[/\\\\])/", + "/(^|[/\\\\])\\.ade($|[/\\\\])/", + ]); + expect(includeIgnored.ignored.map((pattern) => String(pattern))).toEqual([ + "/(^|[/\\\\])\\.git($|[/\\\\])/", + "/(^|[/\\\\])node_modules($|[/\\\\])/", + ]); + }); + + it("forwards ignored-path events when includeIgnored is true but still filters .git", () => { + const service = createFileWatcherService(); + const callback = vi.fn(); + + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1, includeIgnored: true }, callback); + const handlers = chokidarState.watchers[0]?.handlers; + expect(handlers).toBeTruthy(); + + handlers?.get("add")?.("/repo/.ade/context/PRD.ade.md"); + handlers?.get("change")?.("/repo/.git/config"); + vi.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + workspaceId: "ws-1", + type: "created", + path: ".ade/context/PRD.ade.md", + ts: expect.any(String), + }); + }); + + it("continues filtering .ade events when includeIgnored is not enabled", () => { + const service = createFileWatcherService(); + const callback = vi.fn(); + + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, callback); + const handlers = chokidarState.watchers[0]?.handlers; + expect(handlers).toBeTruthy(); + + handlers?.get("add")?.("/repo/.ade/context/PRD.ade.md"); + vi.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("reference-counts watchers for the same sender and workspace", () => { + const service = createFileWatcherService(); + + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, vi.fn()); + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, vi.fn()); + + expect(chokidarState.watchMock).toHaveBeenCalledTimes(1); + service.stop("ws-1", 1, false); + expect(chokidarState.watchers[0]?.close).not.toHaveBeenCalled(); + service.stop("ws-1", 1, false); + expect(chokidarState.watchers[0]?.close).toHaveBeenCalledTimes(1); + }); + + it("upgrades and downgrades includeIgnored mode without dropping active watchers", () => { + const service = createFileWatcherService(); + + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, vi.fn()); + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1, includeIgnored: true }, vi.fn()); + + expect(chokidarState.watchMock).toHaveBeenCalledTimes(2); + expect(chokidarState.watchers[0]?.close).toHaveBeenCalledTimes(1); + + service.stop("ws-1", 1, false); + expect(chokidarState.watchers[1]?.close).not.toHaveBeenCalled(); + + service.stop("ws-1", 1, true); + expect(chokidarState.watchers[1]?.close).toHaveBeenCalledTimes(1); + }); + + it("stops both default and includeIgnored subscriptions when a sender disconnects", () => { + const service = createFileWatcherService(); + + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, vi.fn()); + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1 }, vi.fn()); + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1, includeIgnored: true }, vi.fn()); + service.watch({ workspaceId: "ws-1", rootPath: "/repo", senderId: 1, includeIgnored: true }, vi.fn()); + + expect(chokidarState.watchMock).toHaveBeenCalledTimes(2); + + service.stopAllForSender(1); + + expect(chokidarState.watchers[1]?.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/files/fileWatcherService.ts b/apps/desktop/src/main/services/files/fileWatcherService.ts index bb5d108f3..577cd4658 100644 --- a/apps/desktop/src/main/services/files/fileWatcherService.ts +++ b/apps/desktop/src/main/services/files/fileWatcherService.ts @@ -6,11 +6,14 @@ import { normalizeRelative } from "../shared/utils"; type WatchCallback = (event: FileChangeEvent) => void; type WatchSubscription = { - watcher: FSWatcher; + watcher: FSWatcher | null; workspaceId: string; senderId: number; rootPath: string; callback: WatchCallback; + includeIgnored: boolean; + defaultRefCount: number; + includeIgnoredRefCount: number; }; const EVENT_DEBOUNCE_MS = 140; @@ -25,11 +28,7 @@ export function createFileWatcherService() { const subscriptions = new Map(); const pendingBySub = new Map>(); - const stop = (workspaceId: string, senderId: number): void => { - const key = `${workspaceId}:${senderId}`; - const current = subscriptions.get(key); - if (!current) return; - + const clearPending = (key: string): void => { const pending = pendingBySub.get(key); if (pending) { for (const timeout of pending.values()) { @@ -37,21 +36,113 @@ export function createFileWatcherService() { } pendingBySub.delete(key); } + }; - subscriptions.delete(key); - void current.watcher.close().catch(() => { + const closeWatcher = (subscription: WatchSubscription | undefined): void => { + if (!subscription?.watcher) return; + const watcher = subscription.watcher; + subscription.watcher = null; + void watcher.close().catch(() => { // ignore close errors }); }; + const ALWAYS_IGNORED_PATTERNS: RegExp[] = [ + /(^|[/\\])\.git($|[/\\])/, + /(^|[/\\])node_modules($|[/\\])/, + ]; + const DEFAULT_IGNORED_PATTERNS: RegExp[] = [ + ...ALWAYS_IGNORED_PATTERNS, + /(^|[/\\])\.ade($|[/\\])/, + ]; + + const ignoredPatternsFor = (includeIgnored: boolean): RegExp[] => + includeIgnored ? ALWAYS_IGNORED_PATTERNS : DEFAULT_IGNORED_PATTERNS; + + const startWatcher = (key: string, subscription: WatchSubscription): void => { + closeWatcher(subscription); + + const watcher = chokidar.watch(subscription.rootPath, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 120, + pollInterval: 50 + }, + ignored: ignoredPatternsFor(subscription.includeIgnored) + }); + + const forward = (kind: "add" | "change" | "unlink" | "addDir" | "unlinkDir", absPath: string) => { + const relRaw = path.relative(subscription.rootPath, absPath); + const relPath = normalizeRelative(relRaw); + if (!relPath || relPath.startsWith(".git/") || relPath === ".git") return; + if (!subscription.includeIgnored && (relPath.startsWith(".ade/") || relPath === ".ade")) return; + const fileKey = `${kind}:${relPath}`; + emitDebounced(key, fileKey, () => { + subscription.callback({ + workspaceId: subscription.workspaceId, + type: mapEventType(kind), + path: relPath, + ts: new Date().toISOString() + }); + }); + }; + + watcher.on("error", (error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EMFILE" || code === "ENFILE") { + clearPending(key); + subscriptions.delete(key); + closeWatcher(subscription); + } + // Other errors are non-fatal for chokidar; ignore silently + }); + + watcher.on("add", (absPath) => forward("add", absPath)); + watcher.on("change", (absPath) => forward("change", absPath)); + watcher.on("unlink", (absPath) => forward("unlink", absPath)); + watcher.on("addDir", (absPath) => forward("addDir", absPath)); + watcher.on("unlinkDir", (absPath) => forward("unlinkDir", absPath)); + + subscription.watcher = watcher; + }; + + const stop = (workspaceId: string, senderId: number, includeIgnored = false): void => { + const key = `${workspaceId}:${senderId}`; + const current = subscriptions.get(key); + if (!current) return; + + if (includeIgnored) { + if (current.includeIgnoredRefCount <= 0) return; + current.includeIgnoredRefCount -= 1; + } else { + if (current.defaultRefCount <= 0) return; + current.defaultRefCount -= 1; + } + + if (current.defaultRefCount === 0 && current.includeIgnoredRefCount === 0) { + clearPending(key); + subscriptions.delete(key); + closeWatcher(current); + return; + } + + const nextIncludeIgnored = current.includeIgnoredRefCount > 0; + if (nextIncludeIgnored !== current.includeIgnored) { + current.includeIgnored = nextIncludeIgnored; + startWatcher(key, current); + } + }; + const stopAllForSender = (senderId: number): void => { - const workspaceIds: string[] = []; - for (const sub of subscriptions.values()) { + const toRemove: string[] = []; + for (const [key, sub] of subscriptions) { if (sub.senderId !== senderId) continue; - workspaceIds.push(sub.workspaceId); + clearPending(key); + closeWatcher(sub); + toRemove.push(key); } - for (const workspaceId of workspaceIds) { - stop(workspaceId, senderId); + for (const key of toRemove) { + subscriptions.delete(key); } }; @@ -72,66 +163,42 @@ export function createFileWatcherService() { }; return { - watch(args: { workspaceId: string; rootPath: string; senderId: number }, callback: WatchCallback): void { + watch( + args: { workspaceId: string; rootPath: string; senderId: number; includeIgnored?: boolean }, + callback: WatchCallback + ): void { const key = `${args.workspaceId}:${args.senderId}`; - stop(args.workspaceId, args.senderId); - - const watcher = chokidar.watch(args.rootPath, { - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 120, - pollInterval: 50 - }, - ignored: [ - /(^|[/\\])\.git($|[/\\])/, - /(^|[/\\])node_modules($|[/\\])/, - /(^|[/\\])\.ade($|[/\\])/ - ] - }); - - const forward = (kind: "add" | "change" | "unlink" | "addDir" | "unlinkDir", absPath: string) => { - const relRaw = path.relative(args.rootPath, absPath); - const relPath = normalizeRelative(relRaw); - if (!relPath || relPath.startsWith(".git/") || relPath === ".git" || relPath.startsWith(".ade/") || relPath === ".ade") return; - const fileKey = `${kind}:${relPath}`; - emitDebounced(key, fileKey, () => { - callback({ - workspaceId: args.workspaceId, - type: mapEventType(kind), - path: relPath, - ts: new Date().toISOString() - }); - }); - }; - - watcher.on("error", (error) => { - const code = (error as NodeJS.ErrnoException).code; - if (code === "EMFILE" || code === "ENFILE") { - // File descriptor limit reached β€” close this watcher gracefully - const pending = pendingBySub.get(key); - if (pending) { - for (const timeout of pending.values()) clearTimeout(timeout); - pendingBySub.delete(key); - } - subscriptions.delete(key); - void watcher.close().catch(() => {}); + const requestedIncludeIgnored = Boolean(args.includeIgnored); + const current = subscriptions.get(key); + if (current) { + const rootPathChanged = current.rootPath !== args.rootPath; + current.callback = callback; + current.rootPath = args.rootPath; + if (requestedIncludeIgnored) { + current.includeIgnoredRefCount += 1; + } else { + current.defaultRefCount += 1; } - // Other errors are non-fatal for chokidar; ignore silently - }); - - watcher.on("add", (absPath) => forward("add", absPath)); - watcher.on("change", (absPath) => forward("change", absPath)); - watcher.on("unlink", (absPath) => forward("unlink", absPath)); - watcher.on("addDir", (absPath) => forward("addDir", absPath)); - watcher.on("unlinkDir", (absPath) => forward("unlinkDir", absPath)); + const nextIncludeIgnored = current.includeIgnoredRefCount > 0; + if (rootPathChanged || current.includeIgnored !== nextIncludeIgnored || !current.watcher) { + current.includeIgnored = nextIncludeIgnored; + startWatcher(key, current); + } + return; + } - subscriptions.set(key, { - watcher, + const subscription: WatchSubscription = { + watcher: null, workspaceId: args.workspaceId, senderId: args.senderId, rootPath: args.rootPath, - callback - }); + callback, + includeIgnored: requestedIncludeIgnored, + defaultRefCount: requestedIncludeIgnored ? 0 : 1, + includeIgnoredRefCount: requestedIncludeIgnored ? 1 : 0 + }; + subscriptions.set(key, subscription); + startWatcher(key, subscription); }, stop, @@ -140,9 +207,7 @@ export function createFileWatcherService() { disposeAll(): void { for (const entry of subscriptions.values()) { - void entry.watcher.close().catch(() => { - // ignore close errors - }); + closeWatcher(entry); } subscriptions.clear(); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index e53d8d347..628d42d82 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -35,10 +35,34 @@ const mocks = vi.hoisted(() => { } return { size: 0, isDirectory: () => true }; }), - createWriteStream: vi.fn(() => ({ - write: vi.fn(), - end: vi.fn(), - })), + createWriteStream: vi.fn(() => { + const listeners = { + finish: new Set<() => void>(), + error: new Set<() => void>(), + }; + const stream: any = { + writableFinished: false, + destroyed: false, + write: vi.fn(), + once: vi.fn((event: "finish" | "error", cb: () => void) => { + listeners[event]?.add(cb); + return stream; + }), + removeListener: vi.fn((event: "finish" | "error", cb: () => void) => { + listeners[event]?.delete(cb); + return stream; + }), + end: vi.fn((cb?: () => void) => { + Promise.resolve().then(() => { + stream.writableFinished = true; + cb?.(); + for (const listener of listeners.finish) listener(); + }); + return stream; + }), + }; + return stream; + }), unlinkSync: vi.fn(), writeFileSync: vi.fn(), randomUUID: vi.fn(() => "uuid-" + Math.random().toString(36).slice(2, 10)), @@ -180,6 +204,7 @@ function createHarness(overrides: { create: vi.fn((args: any) => { sessionStore.set(args.sessionId, { ...args, + id: args.sessionId, status: "running", laneName: "Test lane", laneId: args.laneId, @@ -188,12 +213,32 @@ function createHarness(overrides: { }), end: vi.fn((args: any) => { const s = sessionStore.get(args.sessionId); - if (s) { s.status = args.status; s.exitCode = args.exitCode; } + if (s) { + s.status = args.status; + s.exitCode = args.exitCode; + s.endedAt = args.endedAt; + s.ptyId = null; + } + }), + reattach: vi.fn((args: any) => { + const session = sessionStore.get(args.sessionId); + if (!session) return null; + Object.assign(session, { + ptyId: args.ptyId, + status: "running", + endedAt: null, + exitCode: null, + }); + return session; }), get: vi.fn((id: string) => sessionStore.get(id) ?? null), setSummary: vi.fn(), setLastOutputPreview: vi.fn(), - setResumeCommand: vi.fn(), + setResumeCommand: vi.fn((sessionId: string, resumeCommand: string | null) => { + const session = sessionStore.get(sessionId); + if (!session) return; + session.resumeCommand = resumeCommand; + }), setHeadShaStart: vi.fn(), setHeadShaEnd: vi.fn(), updateMeta: vi.fn((args: any) => { @@ -476,6 +521,133 @@ describe("ptyService", () => { ); }); + it("reattaches a resumed tracked session instead of creating a duplicate terminal row", async () => { + const { service, sessionService } = createHarness(); + sessionService.create({ + sessionId: "session-existing", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-existing.log", + toolType: "codex", + resumeCommand: "codex --no-alt-screen resume thread-existing", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-existing", + launch: { permissionMode: "config-toml" }, + }, + }); + sessionService.end({ + sessionId: "session-existing", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + const createCallsBeforeResume = sessionService.create.mock.calls.length; + + const result = await service.create({ + sessionId: "session-existing", + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen resume thread-existing", + }); + + expect(result.sessionId).toBe("session-existing"); + expect(sessionService.reattach).toHaveBeenCalledWith({ + sessionId: "session-existing", + ptyId: expect.any(String), + startedAt: expect.any(String), + }); + expect(sessionService.create).toHaveBeenCalledTimes(createCallsBeforeResume); + }); + + it("rejects reattaching a session into the wrong lane", async () => { + const { service, sessionService } = createHarness(); + sessionService.create({ + sessionId: "session-other-lane", + laneId: "lane-other", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-other-lane.log", + toolType: "codex", + resumeCommand: "codex --no-alt-screen resume thread-existing", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-existing", + launch: { permissionMode: "config-toml" }, + }, + }); + + await expect(service.create({ + sessionId: "session-other-lane", + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen resume thread-existing", + })).rejects.toThrow(/belongs to lane/i); + }); + + it("preserves the previous session outcome when a reattached resume spawn fails", async () => { + const { service, sessionService, loadPty } = createHarness(); + loadPty.mockReturnValue({ + spawn: vi.fn(() => { + throw new Error("spawn failed"); + }), + }); + sessionService.create({ + sessionId: "session-existing", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-existing.log", + toolType: "codex", + resumeCommand: "codex --no-alt-screen resume thread-existing", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-existing", + launch: { permissionMode: "config-toml" }, + }, + }); + sessionService.end({ + sessionId: "session-existing", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + await expect(service.create({ + sessionId: "session-existing", + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen resume thread-existing", + })).rejects.toThrow(/spawn failed/i); + + expect(sessionService.reattach).not.toHaveBeenCalled(); + expect(sessionService.end).toHaveBeenCalledTimes(1); + expect(sessionService.get("session-existing")).toEqual(expect.objectContaining({ + status: "completed", + exitCode: 0, + endedAt: "2026-04-09T12:30:00.000Z", + })); + }); + it("normalizes toolType to a known value", async () => { const { service, sessionService } = createHarness(); await service.create({ @@ -590,6 +762,28 @@ describe("ptyService", () => { expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests"); }); + it("backfills a missing tracked CLI resume target from the flushed transcript tail on exit", async () => { + mocks.extractResumeCommandFromOutput.mockReturnValue("codex resume thread-backfilled" as any); + const { service, mockPty, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex-orchestrated", + startupCommand: "codex --no-alt-screen", + }); + const transcriptPath = sessionService.create.mock.calls[0]?.[0]?.transcriptPath; + + mockPty._emitter.emit("exit", { exitCode: 0 }); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sessionService.readTranscriptTail).toHaveBeenCalledWith(transcriptPath, 220_000); + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(created.sessionId, "codex resume thread-backfilled"); + }); + it("does not overwrite a manually renamed CLI session title", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 807533ca3..01580f207 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -188,6 +188,10 @@ function buildInitialResumeMetadata(args: { return null; } +function isTrackedCliToolType(toolType: TerminalToolType | null): toolType is "claude" | "codex" | "claude-orchestrated" | "codex-orchestrated" { + return toolType === "claude" || toolType === "codex" || toolType === "claude-orchestrated" || toolType === "codex-orchestrated"; +} + const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024; const TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] transcript limit reached (8MB). Further output omitted.\n"; @@ -583,6 +587,74 @@ export function createPtyService({ }); }; + const endTranscriptStream = (stream: fs.WriteStream | null): Promise => { + if (!stream) return Promise.resolve(); + if (stream.writableFinished || stream.destroyed) return Promise.resolve(); + return new Promise((resolve) => { + let settled = false; + const complete = () => { + if (settled) return; + settled = true; + stream.removeListener("finish", complete); + stream.removeListener("error", complete); + resolve(); + }; + stream.once("finish", complete); + stream.once("error", complete); + try { + stream.end(() => complete()); + } catch { + complete(); + } + }); + }; + + const scheduleTranscriptDependentWork = ( + entry: Pick, + reason: "close" | "dispose" | "orphan-dispose", + ): void => { + void endTranscriptStream(entry.transcriptStream) + .finally(() => { + backfillResumeTargetFromTranscriptBestEffort(entry.sessionId, entry.toolTypeHint, reason); + summarizeSessionBestEffort(entry.sessionId, { + laneWorktreePath: entry.laneWorktreePath, + boundCwd: entry.boundCwd, + }); + }); + }; + + const backfillResumeTargetFromTranscriptBestEffort = ( + sessionId: string, + preferredToolType: TerminalToolType | null, + reason: "close" | "dispose" | "orphan-dispose", + ): void => { + Promise.resolve() + .then(async () => { + const session = sessionService.get(sessionId); + if (!session?.tracked) return; + const effectiveToolType = preferredToolType ?? session.toolType ?? null; + if (!isTrackedCliToolType(effectiveToolType)) return; + if (session.resumeMetadata?.targetId?.trim()) return; + + const transcript = await sessionService.readTranscriptTail(session.transcriptPath, 220_000); + const detected = extractResumeCommandFromOutput(transcript, effectiveToolType); + if (!detected) { + logger.warn("pty.resume_target_missing", { sessionId, toolType: effectiveToolType, reason }); + return; + } + sessionService.setResumeCommand(sessionId, detected); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason }); + }) + .catch((err) => { + logger.warn("pty.resume_target_backfill_failed", { + sessionId, + toolType: preferredToolType, + reason, + err: String(err), + }); + }); + }; + const closeEntry = (ptyId: string, exitCode: number | null) => { const entry = ptys.get(ptyId); if (!entry) return; @@ -593,18 +665,13 @@ export function createPtyService({ entry.aiTitleTimer = null; } clearToolAutoCloseTimer(ptyId); - - try { - entry.transcriptStream?.end(); - } catch { - // ignore - } cleanupEntryPaths(entry); flushPreview(entry); const endedAt = new Date().toISOString(); const status = statusFromExit(exitCode); sessionService.end({ sessionId: entry.sessionId, endedAt, exitCode, status }); + scheduleTranscriptDependentWork(entry, "close"); clearIdleTimer(entry.sessionId); const finalRuntimeState = runtimeFromStatus(status); setRuntimeState(entry.sessionId, finalRuntimeState, { touch: false }); @@ -620,10 +687,6 @@ export function createPtyService({ } catch { // ignore callback failures } - summarizeSessionBestEffort(entry.sessionId, { - laneWorktreePath: entry.laneWorktreePath, - boundCwd: entry.boundCwd, - }); // Best-effort head SHA at end; never block exit. Promise.resolve() @@ -741,18 +804,37 @@ export function createPtyService({ const { laneWorktreePath: worktreePath, cwd } = launchContext; const { cols, rows } = clampDims(args.cols, args.rows); + const requestedSessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + const existingSession = requestedSessionId.length + ? sessionService.get(requestedSessionId) + : null; + if (requestedSessionId.length && !existingSession) { + throw new Error(`Terminal session '${requestedSessionId}' was not found.`); + } + if (existingSession && existingSession.laneId !== laneId) { + throw new Error(`Terminal session '${requestedSessionId}' belongs to lane '${existingSession.laneId}', not '${laneId}'.`); + } + if (existingSession && !existingSession.tracked) { + throw new Error(`Terminal session '${requestedSessionId}' is not tracked and cannot be resumed.`); + } + if (existingSession && Array.from(ptys.values()).some((entry) => entry.sessionId === existingSession.id && !entry.disposed)) { + throw new Error(`Terminal session '${requestedSessionId}' is already attached to a live PTY.`); + } + const ptyId = randomUUID(); - const sessionId = randomUUID(); + const sessionId = existingSession?.id ?? randomUUID(); const startedAt = new Date().toISOString(); - const tracked = args.tracked !== false; - const toolTypeHint = normalizeToolType(args.toolType); + const tracked = existingSession?.tracked ?? (args.tracked !== false); + const toolTypeHint = normalizeToolType(args.toolType ?? existingSession?.toolType ?? null); const requestedStartupCommand = typeof args.startupCommand === "string" ? args.startupCommand.trim() : ""; - const initialResumeCommand = defaultResumeCommandForTool(toolTypeHint); - const initialResumeMetadata = buildInitialResumeMetadata({ + const initialResumeCommand = existingSession?.resumeCommand ?? defaultResumeCommandForTool(toolTypeHint); + const initialResumeMetadata = existingSession?.resumeMetadata ?? buildInitialResumeMetadata({ toolType: toolTypeHint, startupCommand: requestedStartupCommand, }); - const transcriptPath = safeTranscriptPathFor(sessionId); + const transcriptPath = tracked + ? (existingSession?.transcriptPath?.trim() || safeTranscriptPathFor(sessionId)) + : ""; const enrichedLaunch = enrichStartupCommandForAdeMcp({ projectRoot, workspaceRoot: cwd, @@ -774,27 +856,29 @@ export function createPtyService({ transcriptStream = fs.createWriteStream(transcriptPath, { flags: "a" }); } - sessionService.create({ - sessionId, - laneId, - ptyId, - tracked, - title, - startedAt, - transcriptPath: tracked ? transcriptPath : "", - toolType: toolTypeHint, - resumeCommand: initialResumeCommand, - resumeMetadata: initialResumeMetadata, - }); - setRuntimeState(sessionId, "running"); - - // Best-effort head SHA at start; do not block terminal creation. - Promise.resolve() - .then(async () => { - const sha = await computeHeadShaBestEffort(cwd || worktreePath); - if (sha) sessionService.setHeadShaStart(sessionId, sha); - }) - .catch(() => {}); + if (!existingSession) { + sessionService.create({ + sessionId, + laneId, + ptyId, + tracked, + title, + startedAt, + transcriptPath: tracked ? transcriptPath : "", + toolType: toolTypeHint, + resumeCommand: initialResumeCommand, + resumeMetadata: initialResumeMetadata, + }); + setRuntimeState(sessionId, "running"); + + // Best-effort head SHA at start; do not block terminal creation. + Promise.resolve() + .then(async () => { + const sha = await computeHeadShaBestEffort(cwd || worktreePath); + if (sha) sessionService.setHeadShaStart(sessionId, sha); + }) + .catch(() => {}); + } const shellCandidates = resolveShellCandidates(); let pty: IPty; @@ -857,10 +941,11 @@ export function createPtyService({ } } try { - transcriptStream?.end(); + await endTranscriptStream(transcriptStream); } catch { // ignore } + if (existingSession) throw err; sessionService.end({ sessionId, endedAt: new Date().toISOString(), exitCode: null, status: "failed" }); clearIdleTimer(sessionId); setRuntimeState(sessionId, "exited", { touch: false }); @@ -873,6 +958,30 @@ export function createPtyService({ throw err; } + if (existingSession) { + sessionService.reattach({ sessionId, ptyId, startedAt }); + setRuntimeState(sessionId, "running"); + Promise.resolve() + .then(async () => { + const sha = await computeHeadShaBestEffort(cwd || worktreePath); + if (sha) sessionService.setHeadShaStart(sessionId, sha); + }) + .catch(() => {}); + } + + if ( + existingSession + && isTrackedCliToolType(toolTypeHint) + && !existingSession.resumeMetadata?.targetId?.trim() + ) { + logger.warn("pty.resume_target_missing", { + sessionId, + ptyId, + toolType: toolTypeHint, + reason: "resume-launch", + }); + } + const entry: PtyEntry = { pty, laneId, @@ -1109,6 +1218,7 @@ export function createPtyService({ // so stale sessions do not get stuck in a "running" state forever. const endedAt = new Date().toISOString(); sessionService.end({ sessionId, endedAt, exitCode: null, status: "disposed" }); + backfillResumeTargetFromTranscriptBestEffort(sessionId, session.toolType ?? null, "orphan-dispose"); clearIdleTimer(sessionId); setRuntimeState(sessionId, "killed", { touch: false }); runtimeStates.delete(sessionId); @@ -1142,11 +1252,6 @@ export function createPtyService({ entry.aiTitleTimer = null; } clearToolAutoCloseTimer(ptyId); - try { - entry.transcriptStream?.end(); - } catch { - // ignore - } cleanupEntryPaths(entry); try { entry.pty.kill(); @@ -1155,6 +1260,7 @@ export function createPtyService({ } const endedAt = new Date().toISOString(); sessionService.end({ sessionId: entry.sessionId, endedAt, exitCode: null, status: "disposed" }); + scheduleTranscriptDependentWork(entry, "dispose"); clearIdleTimer(entry.sessionId); setRuntimeState(entry.sessionId, "killed", { touch: false }); runtimeStates.delete(entry.sessionId); @@ -1169,10 +1275,6 @@ export function createPtyService({ } catch { // ignore callback failures } - summarizeSessionBestEffort(entry.sessionId, { - laneWorktreePath: entry.laneWorktreePath, - boundCwd: entry.boundCwd, - }); broadcastExit({ ptyId, sessionId: entry.sessionId, exitCode: null }); ptys.delete(ptyId); diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 9cf44b3b7..ecdf04eef 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -97,7 +97,7 @@ describe("sessionService resume metadata", () => { permissionMode: "default", launch: { permissionMode: "default" }, }); - expect(created?.resumeCommand).toBe("claude --permission-mode default"); + expect(created?.resumeCommand).toBe("claude --permission-mode default --resume"); service.setResumeCommand("session-1", "claude --resume abc123"); const resumed = service.get("session-1"); @@ -144,7 +144,7 @@ describe("sessionService resume metadata", () => { const created = service.get("session-2"); expect(created?.resumeCommand).toBe( - "codex --no-alt-screen -c approval_policy=on-failure -c sandbox_mode=workspace-write", + "codex --no-alt-screen -c approval_policy=on-failure -c sandbox_mode=workspace-write resume", ); service.setResumeCommand("session-2", "codex resume thread-1"); @@ -167,4 +167,101 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + + it("round-trips Codex full-auto resume commands without dropping the thread id", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-2b", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-2b.log", + toolType: "codex", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-seed", + launch: { + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + }, + }); + + service.setResumeCommand("session-2b", "codex --no-alt-screen --full-auto resume thread-full-auto"); + const resumed = service.get("session-2b"); + expect(resumed?.resumeMetadata).toEqual({ + provider: "codex", + targetKind: "thread", + targetId: "thread-full-auto", + permissionMode: "full-auto", + launch: { + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + }); + expect(resumed?.resumeCommand).toBe("codex --no-alt-screen --full-auto resume thread-full-auto"); + + activeDisposers.push(async () => db.close()); + }); + + it("reattaches an existing tracked session to a new PTY without changing its identity", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-3", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-3.log", + toolType: "codex", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-3", + launch: { permissionMode: "edit" }, + }, + }); + service.end({ + sessionId: "session-3", + endedAt: "2026-03-17T00:20:00.000Z", + exitCode: 0, + status: "completed", + }); + + const reattached = service.reattach({ + sessionId: "session-3", + ptyId: "pty-3b", + startedAt: "2026-03-17T00:30:00.000Z", + }); + expect(reattached).toEqual(expect.objectContaining({ + id: "session-3", + ptyId: "pty-3b", + status: "running", + endedAt: null, + exitCode: null, + startedAt: "2026-03-17T00:30:00.000Z", + title: "Codex CLI", + summary: null, + })); + + activeDisposers.push(async () => db.close()); + }); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index b55698a9d..77617dba1 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -443,6 +443,26 @@ export function createSessionService({ db }: { db: AdeDb }) { ); }, + reattach(args: { sessionId: string; ptyId: string | null; startedAt: string }): TerminalSessionSummary | null { + const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + if (!sessionId) return null; + db.run( + ` + update terminal_sessions + set pty_id = ?, + started_at = ?, + status = 'running', + ended_at = null, + exit_code = null, + summary = null, + head_sha_end = null + where id = ? + `, + [args.ptyId, args.startedAt, sessionId], + ); + return this.get(sessionId); + }, + setHeadShaStart(sessionId: string, sha: string): void { db.run("update terminal_sessions set head_sha_start = ? where id = ?", [sha, sessionId]); }, diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index 3ead389e3..1707366c5 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -82,7 +82,7 @@ describe("terminalSessionSignals", () => { targetKind: "thread", targetId: null, launch: { permissionMode: "full-auto" }, - })).toBe("codex --no-alt-screen --full-auto"); + })).toBe("codex --no-alt-screen --full-auto resume"); }); it("extracts resume targets from Claude and Codex picker commands", () => { @@ -98,5 +98,13 @@ describe("terminalSessionSignals", () => { provider: "codex", targetId: "thread_abc123", }); + expect(parseTrackedCliResumeCommand("codex --no-alt-screen --full-auto resume thread_abc123", "codex")).toEqual({ + provider: "codex", + targetId: "thread_abc123", + }); + expect(parseTrackedCliResumeCommand("codex --no-alt-screen --full-auto resume", "codex")).toEqual({ + provider: "codex", + targetId: null, + }); }); }); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index aa2aef4d8..bbdb8647e 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -140,12 +140,12 @@ export function parseTrackedCliResumeCommand( if (!provider) return null; if (provider === "claude") { - const match = normalized.match(/^claude(?:(?:\s+--[^\s]+)(?:\s+[^\s]+)?)*\s+(?:--resume|-r|resume)\s+([^\s]+)(?:\s|$)/i); + const match = normalized.match(/^claude(?:(?:\s+--[^\s]+)(?:\s+[^\s]+)?)*\s+(?:--resume|-r|resume)(?:\s+([^\s]+))?(?:\s|$)/i); if (!match) return { provider, targetId: null }; return { provider, targetId: match[1] ?? null }; } - const match = normalized.match(/^codex(?:\s+--no-alt-screen)?(?:\s+-c\s+[^\s]+)*(?:\s+resume)\s+([^\s]+)(?:\s|$)/i); + const match = normalized.match(/^codex(?:(?:\s+--no-alt-screen)|(?:\s+--full-auto)|(?:\s+-c\s+[^\s]+))*\s+resume(?:\s+([^\s]+))?(?:\s|$)/i); if (!match) return { provider, targetId: null }; return { provider, targetId: match[1] ?? null }; } @@ -158,12 +158,14 @@ export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata | if (provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(permissionMode)]; - if (targetId.length) parts.push("--resume", targetId); + parts.push("--resume"); + if (targetId.length) parts.push(targetId); return parts.join(" "); } const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(permissionMode)]; - if (targetId.length) parts.push("resume", targetId); + parts.push("resume"); + if (targetId.length) parts.push(targetId); return parts.join(" "); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index cf7bc89fa..3999880cd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -186,6 +186,13 @@ function installAdeMocks(options?: { prs: { getForLane: vi.fn().mockResolvedValue(null), }, + pty: { + onExit: vi.fn().mockImplementation(() => () => undefined), + dispose: vi.fn().mockResolvedValue(undefined), + resize: vi.fn().mockResolvedValue(undefined), + write: vi.fn().mockResolvedValue(undefined), + onData: vi.fn().mockImplementation(() => () => undefined), + }, } as any; return { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f8f703d6e..bb6c006be 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2832,7 +2832,6 @@ export function AgentChatPane({ open={terminalDrawerOpen} onToggle={() => setTerminalDrawerOpen((v) => !v)} laneId={laneId} - sessionId={selectedSessionId ?? undefined} /> diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index 6d48856d7..f994750d2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -1,72 +1,39 @@ import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { Terminal as TerminalIcon, Plus, X } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; -import type { PtyDataEvent, PtyExitEvent } from "../../../shared/types"; - -import "@xterm/xterm/css/xterm.css"; - -/* ── Types ── */ +import type { PtyExitEvent } from "../../../shared/types"; +import { TerminalView } from "../terminals/TerminalView"; type ChatTerminalDrawerProps = { open: boolean; onToggle: () => void; laneId: string; - sessionId?: string; }; type TabEntry = { id: string; ptyId: string; + sessionId: string; label: string; exited: boolean; }; -/* ── Lazy xterm loader ── */ - -type XtermModules = { - Terminal: typeof import("@xterm/xterm").Terminal; - FitAddon: typeof import("@xterm/addon-fit").FitAddon; -}; - -let xtermCache: Promise | null = null; - -function loadXterm(): Promise { - if (!xtermCache) { - xtermCache = Promise.all([ - import("@xterm/xterm"), - import("@xterm/addon-fit"), - ]).then(([xtermMod, fitMod]) => ({ - Terminal: xtermMod.Terminal, - FitAddon: fitMod.FitAddon, - })); - } - return xtermCache; -} - -/* ── Constants ── */ - -const TERM_THEME = { - background: "#0A090E", - foreground: "#E8E8ED", - cursor: "#A78BFA", - cursorAccent: "#0A090E", - selectionBackground: "rgba(167, 139, 250, 0.22)", -}; - let nextTabIndex = 1; -/* ── Main drawer ── */ - export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ open, onToggle, laneId, - sessionId, }: ChatTerminalDrawerProps) { const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); const [drawerHeight, setDrawerHeight] = useState(300); + const [creatingTab, setCreatingTab] = useState(false); const dragRef = useRef<{ startY: number; startHeight: number } | null>(null); + const hadTabsRef = useRef(false); + const tabsRef = useRef([]); + + tabsRef.current = tabs; const handleDragStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -75,8 +42,8 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ const handleDragMove = (ev: MouseEvent) => { if (!dragRef.current) return; const delta = dragRef.current.startY - ev.clientY; - const newHeight = Math.max(150, Math.min(600, dragRef.current.startHeight + delta)); - setDrawerHeight(newHeight); + const nextHeight = Math.max(150, Math.min(600, dragRef.current.startHeight + delta)); + setDrawerHeight(nextHeight); }; const handleDragEnd = () => { @@ -89,312 +56,137 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ document.addEventListener("mouseup", handleDragEnd); }, [drawerHeight]); - /* Refs per-tab: terminal instance, fit addon, container, pty unsubs */ - const terminalsRef = useRef< - Map< - string, - { - term: InstanceType; - fit: InstanceType; - container: HTMLDivElement; - unsubData: (() => void) | null; - unsubExit: (() => void) | null; - disposed: boolean; - } - > - >(new Map()); - - const containerRef = useRef(null); - const hasCreatedInitial = useRef(false); - - /* ── Create a new terminal tab ── */ const createTab = useCallback(async () => { - const mods = await loadXterm(); - - const tabId = `chat-term-${Date.now()}-${nextTabIndex++}`; - const label = `Terminal ${tabs.length + 1}`; - - /* Create PTY */ - const ptyResult = await window.ade.pty.create({ - laneId, - cols: 80, - rows: 24, - title: label, - tracked: false, - }); - - const entry: TabEntry = { - id: tabId, - ptyId: ptyResult.ptyId, - label, - exited: false, - }; - - /* Create xterm instance */ - const term = new mods.Terminal({ - fontFamily: "var(--font-mono)", - fontSize: 11, - lineHeight: 1.3, - cursorBlink: true, - allowTransparency: true, - theme: TERM_THEME, - scrollback: 5000, - }); - - const fit = new mods.FitAddon(); - term.loadAddon(fit); - - /* Create host container */ - const host = document.createElement("div"); - host.style.width = "100%"; - host.style.height = "100%"; - host.dataset.termTabId = tabId; - - /* PTY data -> terminal */ - const unsubData = window.ade.pty.onData((ev: PtyDataEvent) => { - if (ev.ptyId !== ptyResult.ptyId) return; - try { - term.write(ev.data); - } catch { - /* ignore writes after disposal */ - } - }); - - /* PTY exit */ - const unsubExit = window.ade.pty.onExit((ev: PtyExitEvent) => { - if (ev.ptyId !== ptyResult.ptyId) return; - setTabs((prev) => - prev.map((t) => (t.id === tabId ? { ...t, exited: true } : t)), - ); - }); - - /* Terminal input -> PTY */ - term.onData((data) => { - window.ade.pty.write({ ptyId: ptyResult.ptyId, data }).catch(() => {}); - }); - - terminalsRef.current.set(tabId, { - term, - fit, - container: host, - unsubData, - unsubExit, - disposed: false, - }); - - setTabs((prev) => [...prev, entry]); - setActiveTabId(tabId); - - /* Defer open + fit until the host is mounted */ - requestAnimationFrame(() => { - if (!host.isConnected) return; - term.open(host); - try { - fit.fit(); - } catch { - /* ignore initial fit failures */ - } - const dims = { cols: term.cols, rows: term.rows }; - if (dims.cols > 0 && dims.rows > 0) { - window.ade.pty - .resize({ ptyId: ptyResult.ptyId, cols: dims.cols, rows: dims.rows }) - .catch(() => {}); - } - }); - }, [laneId, tabs.length]); - - /* ── Lazy-create first terminal on open ── */ - useEffect(() => { - if (!open || hasCreatedInitial.current) return; - hasCreatedInitial.current = true; - createTab(); - }, [open, createTab]); - - /* ── Close a tab ── */ - const closeTab = useCallback( - (tabId: string) => { - const entry = tabs.find((t) => t.id === tabId); - const runtime = terminalsRef.current.get(tabId); - - if (entry && !runtime?.disposed) { - window.ade.pty - .dispose({ ptyId: entry.ptyId, sessionId }) - .catch(() => {}); - } - - if (runtime) { - runtime.unsubData?.(); - runtime.unsubExit?.(); - try { - runtime.term.dispose(); - } catch { - /* ignore */ - } - runtime.disposed = true; - terminalsRef.current.delete(tabId); - } - - setTabs((prev) => { - const next = prev.filter((t) => t.id !== tabId); - if (activeTabId === tabId) { - setActiveTabId(next.length > 0 ? next[next.length - 1].id : null); - } - if (next.length === 0) { - onToggle(); - } - return next; + if (creatingTab) return; + setCreatingTab(true); + try { + const tabIndex = nextTabIndex++; + const label = `Terminal ${tabIndex}`; + const tabId = `chat-term-${Date.now()}-${tabIndex}`; + const created = await window.ade.pty.create({ + laneId, + cols: 80, + rows: 24, + title: label, + tracked: false, + toolType: "shell", }); - }, - [tabs, activeTabId, sessionId, onToggle], - ); - /* ── Mount / unmount active terminal container ── */ - useEffect(() => { - if (!activeTabId || !containerRef.current) return; - const runtime = terminalsRef.current.get(activeTabId); - if (!runtime || runtime.disposed) return; - - const wrapper = containerRef.current; - - /* Detach all other terminal hosts */ - for (const [id, rt] of terminalsRef.current) { - if (id !== activeTabId && rt.container.parentElement === wrapper) { - wrapper.removeChild(rt.container); - } + const nextEntry: TabEntry = { + id: tabId, + ptyId: created.ptyId, + sessionId: created.sessionId, + label, + exited: false, + }; + + setTabs((prev) => [...prev, nextEntry]); + setActiveTabId(tabId); + } finally { + setCreatingTab(false); } + }, [creatingTab, laneId]); - /* Attach active host */ - if (runtime.container.parentElement !== wrapper) { - wrapper.appendChild(runtime.container); - } + useEffect(() => { + if (!open || creatingTab || tabs.length > 0) return; + void createTab(); + }, [createTab, creatingTab, open, tabs.length]); - /* Open terminal into DOM if not yet opened */ - if (!runtime.container.querySelector(".xterm")) { - runtime.term.open(runtime.container); + useEffect(() => { + if (tabs.length > 0) { + hadTabsRef.current = true; + return; } + if (!open || creatingTab || !hadTabsRef.current) return; + hadTabsRef.current = false; + onToggle(); + }, [creatingTab, onToggle, open, tabs.length]); - /* Fit to container */ - requestAnimationFrame(() => { - try { - runtime.fit.fit(); - } catch { - /* ignore */ - } - }); - }, [activeTabId]); - - /* ── ResizeObserver to auto-fit ── */ useEffect(() => { - if (!open || !containerRef.current) return; - - const observer = new ResizeObserver(() => { - if (!activeTabId) return; - const runtime = terminalsRef.current.get(activeTabId); - if (!runtime || runtime.disposed) return; - try { - runtime.fit.fit(); - } catch { - /* ignore */ - } - - const tab = tabs.find((t) => t.id === activeTabId); - if (tab) { - const dims = { cols: runtime.term.cols, rows: runtime.term.rows }; - if (dims.cols > 0 && dims.rows > 0) { - window.ade.pty - .resize({ ptyId: tab.ptyId, cols: dims.cols, rows: dims.rows }) - .catch(() => {}); - } - } + const unsubscribe = window.ade.pty.onExit((ev: PtyExitEvent) => { + setTabs((prev) => prev.map((tab) => ( + tab.ptyId === ev.ptyId + ? { ...tab, exited: true } + : tab + ))); }); + return unsubscribe; + }, []); - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, [open, activeTabId, tabs]); - - /* ── Re-fit terminals when drawer height changes ── */ - useEffect(() => { - if (!open) return; - for (const [, entry] of terminalsRef.current) { - if (!entry.disposed) { - try { entry.fit.fit(); } catch { /* ignore */ } - } + const closeTab = useCallback((tabId: string) => { + const entry = tabsRef.current.find((tab) => tab.id === tabId); + if (entry) { + window.ade.pty.dispose({ ptyId: entry.ptyId, sessionId: entry.sessionId }).catch(() => {}); } - }, [drawerHeight, open]); - /* ── Cleanup all PTYs on unmount ── */ + setTabs((prev) => { + const next = prev.filter((tab) => tab.id !== tabId); + setActiveTabId((current) => { + if (current !== tabId) return current; + return next.length > 0 ? next[next.length - 1].id : null; + }); + return next; + }); + }, []); + useEffect(() => { return () => { - for (const [, runtime] of terminalsRef.current) { - if (runtime.disposed) continue; - runtime.unsubData?.(); - runtime.unsubExit?.(); - try { - runtime.term.dispose(); - } catch { - /* ignore */ - } - runtime.disposed = true; - } - /* Dispose PTYs */ - for (const tab of tabs) { - window.ade.pty - .dispose({ ptyId: tab.ptyId, sessionId }) - .catch(() => {}); + for (const tab of tabsRef.current) { + window.ade.pty.dispose({ ptyId: tab.ptyId, sessionId: tab.sessionId }).catch(() => {}); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!open) return null; + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs.at(-1) ?? null; + return ( -
- {/* Resize handle */} +
- {/* Tab bar */} -
-
+ +
+
{tabs.map((tab) => (
- {/* Add tab button */} - {/* Spacer */}
- {/* Terminal viewport */} -
+
+ {activeTab ? ( + + ) : ( +
+ Create a terminal to start working in this chat. +
+ )} +
); }); -/* ── Toggle button (for parent to embed in chat header) ── */ - type ChatTerminalToggleProps = { open: boolean; onToggle: () => void; diff --git a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx index 90e4bca34..49a8be6fe 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx @@ -4,9 +4,21 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { FileChangeEvent, FileTreeNode } from "../../../shared/types"; import { FilesPage } from "./FilesPage"; import { useAppStore } from "../../state/appStore"; +type MockEditorInstance = { + setModel: (next: any) => void; + getValue: () => string; + setValue: (next: string) => void; + updateOptions: ReturnType; + onDidChangeModelContent: (cb: () => void) => { dispose: ReturnType }; + dispose: ReturnType; +}; + +let latestMockEditor: MockEditorInstance | null = null; + vi.mock("../ui/PaneTilingLayout", () => ({ PaneTilingLayout: ({ panes }: { panes: Record }) => (
@@ -48,7 +60,7 @@ vi.mock("monaco-editor", () => { let model: ReturnType | null = null; let onChange: (() => void) | null = null; element.setAttribute("data-testid", "mock-monaco-editor"); - return { + latestMockEditor = { setModel(next: ReturnType | null) { model = next; element.textContent = next?.value ?? ""; @@ -68,6 +80,7 @@ vi.mock("monaco-editor", () => { }, dispose: vi.fn(), }; + return latestMockEditor; }), createModel: vi.fn(createModel), setTheme: vi.fn(), @@ -75,40 +88,76 @@ vi.mock("monaco-editor", () => { }; }); -const baseVisibleTree = [ +const visibleTree: FileTreeNode[] = [ { name: "src", path: "src", - type: "directory" as const, + type: "directory", children: [ { name: "index.ts", path: "src/index.ts", - type: "file" as const, + type: "file", }, ], }, ]; -const hiddenAwareTree = [ - ...baseVisibleTree, +const ignoredTree: FileTreeNode[] = [ + ...visibleTree, { name: ".ade", path: ".ade", - type: "directory" as const, + type: "directory", children: [ { name: "context", path: ".ade/context", - type: "directory" as const, + type: "directory", }, ], }, ]; +let currentTree: FileTreeNode[] = []; +let fileContents: Record = {}; +let changeListener: ((event: FileChangeEvent) => void) | null = null; +let projectRoot = ""; +let projectCounter = 0; + +function cloneTree(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes.map((node) => ({ + ...node, + children: node.children ? cloneTree(node.children) : node.children, + })); +} + +function findNode(nodes: FileTreeNode[], targetPath: string): FileTreeNode | null { + for (const node of nodes) { + if (node.path === targetPath) return node; + if (node.children?.length) { + const found = findNode(node.children, targetPath); + if (found) return found; + } + } + return null; +} + +function listTreeForRequest(parentPath?: string, includeIgnored?: boolean): FileTreeNode[] { + const source = includeIgnored ? currentTree : visibleTree; + if (!parentPath) return cloneTree(source); + return cloneTree(findNode(source, parentPath)?.children ?? []); +} + +function emitFileChange(event: FileChangeEvent) { + act(() => { + changeListener?.(event); + }); +} + function resetStore() { useAppStore.setState({ - project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + project: { rootPath: projectRoot, name: "ADE" } as any, projectHydrated: true, showWelcome: false, selectedLaneId: null, @@ -138,12 +187,29 @@ function renderFilesPage(initialState?: Record) { ); } +async function waitForEditorText(text: string) { + await waitFor(() => { + expect(screen.getByTestId("mock-monaco-editor").textContent).toContain(text); + }); +} + describe("FilesPage", () => { const originalAde = globalThis.window.ade; const originalConfirm = globalThis.window.confirm; beforeEach(() => { + projectCounter += 1; + projectRoot = `/Users/arul/ADE-${projectCounter}`; resetStore(); + latestMockEditor = null; + changeListener = null; + currentTree = cloneTree(ignoredTree); + fileContents = { + "src/index.ts": "export const value = 1;\n", + "src/main.ts": "export const value = 2;\n", + ".ade/context/PRD.ade.md": "# PRD.ade\n\n## What this is\nRenderer-safe content", + ".ade/context/ARCHITECTURE.ade.md": "# ARCHITECTURE.ade\n\n## System shape\nRenderer-safe content", + }; window.localStorage.clear(); globalThis.window.confirm = vi.fn(() => true); @@ -155,26 +221,44 @@ describe("FilesPage", () => { kind: "primary", laneId: null, name: "ADE", - rootPath: "/Users/arul/ADE", + rootPath: projectRoot, isReadOnlyByDefault: false, }, ]), - listTree: vi.fn(async ({ includeIgnored }: { includeIgnored?: boolean }) => (includeIgnored ? hiddenAwareTree : baseVisibleTree)), + listTree: vi.fn(async ({ parentPath, includeIgnored }: { parentPath?: string; includeIgnored?: boolean }) => + listTreeForRequest(parentPath, includeIgnored) + ), watchChanges: vi.fn(async () => undefined), stopWatching: vi.fn(async () => undefined), - onChange: vi.fn(() => () => {}), - readFile: vi.fn(async ({ path }: { path: string }) => ({ - content: - path === ".ade/context/ARCHITECTURE.ade.md" - ? "# ARCHITECTURE.ade\n\n## System shape\nRenderer-safe content" - : "# PRD.ade\n\n## What this is\nRenderer-safe content", - encoding: "utf-8", - size: 128, - languageId: "markdown", - isBinary: false, - })), - quickOpen: vi.fn(async () => []), - searchText: vi.fn(async () => []), + onChange: vi.fn((cb: (event: FileChangeEvent) => void) => { + changeListener = cb; + return () => { + if (changeListener === cb) changeListener = null; + }; + }), + readFile: vi.fn(async ({ path }: { path: string }) => { + const content = fileContents[path]; + if (content == null) { + throw new Error(`ENOENT: ${path}`); + } + return { + content, + encoding: "utf-8", + size: content.length, + languageId: path.endsWith(".ts") ? "typescript" : "markdown", + isBinary: false, + }; + }), + quickOpen: vi.fn(async ({ includeIgnored, query }: { includeIgnored?: boolean; query: string }) => ( + includeIgnored && query.toLowerCase().includes("prd") + ? [{ path: ".ade/context/PRD.ade.md", score: 100 }] + : [] + )), + searchText: vi.fn(async ({ includeIgnored, query }: { includeIgnored?: boolean; query: string }) => ( + includeIgnored && query.toLowerCase().includes("renderer") + ? [{ path: ".ade/context/PRD.ade.md", line: 3, column: 1, preview: "Renderer-safe content" }] + : [] + )), writeText: vi.fn(async () => undefined), rename: vi.fn(async () => undefined), delete: vi.fn(async () => undefined), @@ -196,6 +280,8 @@ describe("FilesPage", () => { afterEach(() => { cleanup(); + latestMockEditor = null; + changeListener = null; window.localStorage.clear(); globalThis.window.confirm = originalConfirm; if (originalAde === undefined) { @@ -205,46 +291,201 @@ describe("FilesPage", () => { } }); - it("opens PRD context docs from navigation state without a blank editor tab", async () => { + it("shows ignored paths by default and opens PRD context docs without a toggle", async () => { renderFilesPage({ openFilePath: ".ade/context/PRD.ade.md", preferPrimaryWorkspace: true, }); - await waitFor(() => { - expect(screen.getByTestId("mock-monaco-editor").textContent).toContain("# PRD.ade"); - }); + await waitForEditorText("# PRD.ade"); expect(screen.queryByText(/OPEN A FILE TO START EDITING/i)).toBeNull(); expect(await screen.findByTitle(".ade")).toBeTruthy(); + expect(screen.queryByTitle("Hide dotfiles")).toBeNull(); + expect(screen.queryByTitle("Show dotfiles")).toBeNull(); + expect((window.ade.files.listTree as any).mock.calls[0]?.[0]).toMatchObject({ includeIgnored: true }); + expect((window.ade.files.watchChanges as any).mock.calls[0]?.[0]).toMatchObject({ + workspaceId: "primary", + includeIgnored: true, + }); }); - it("keeps an opened context doc visible while hidden files are toggled", async () => { + it("passes includeIgnored through quick open and search affordances", async () => { renderFilesPage({ openFilePath: ".ade/context/PRD.ade.md", preferPrimaryWorkspace: true, }); + await waitForEditorText("# PRD.ade"); + + fireEvent.change(screen.getByPlaceholderText("SEARCH FILES"), { + target: { value: "renderer" }, + }); + await waitFor(() => { - expect(screen.getByTestId("mock-monaco-editor").textContent).toContain("# PRD.ade"); + expect((window.ade.files.searchText as any).mock.calls.at(-1)?.[0]).toMatchObject({ + workspaceId: "primary", + query: "renderer", + includeIgnored: true, + }); }); + expect(await screen.findByText(".ade/context/PRD.ade.md:3:1")).toBeTruthy(); - await act(async () => { - fireEvent.click(screen.getByTitle("Hide dotfiles")); + fireEvent.click(screen.getByText(/QUICK OPEN/i)); + fireEvent.change(screen.getByPlaceholderText(/Type to search files/i), { + target: { value: "prd" }, }); await waitFor(() => { - expect(screen.getByTestId("mock-monaco-editor").textContent).toContain("# PRD.ade"); + expect((window.ade.files.quickOpen as any).mock.calls.at(-1)?.[0]).toMatchObject({ + workspaceId: "primary", + query: "prd", + includeIgnored: true, + }); }); - expect(screen.queryByText(/OPEN A FILE TO START EDITING/i)).toBeNull(); - expect(screen.queryByTitle(".ade")).toBeNull(); + expect(await screen.findByText(".ade/context/PRD.ade.md")).toBeTruthy(); + }); + + it("remaps clean open tabs when files are renamed", async () => { + renderFilesPage({ + openFilePath: "src/index.ts", + preferPrimaryWorkspace: true, + }); + + await waitForEditorText("value = 1"); + fireEvent.click(await screen.findByTitle("src")); + expect(await screen.findByTitle("src/index.ts")).toBeTruthy(); + + currentTree = [ + { + name: "src", + path: "src", + type: "directory", + children: [ + { + name: "main.ts", + path: "src/main.ts", + type: "file", + }, + ], + }, + { + name: ".ade", + path: ".ade", + type: "directory", + children: [ + { + name: "context", + path: ".ade/context", + type: "directory", + }, + ], + }, + ]; + + emitFileChange({ + workspaceId: "primary", + type: "renamed", + oldPath: "src/index.ts", + path: "src/main.ts", + ts: new Date().toISOString(), + }); + + await waitForEditorText("value = 2"); + expect(await screen.findByTitle("src/main.ts")).toBeTruthy(); + expect(screen.queryByTitle("src/index.ts")).toBeNull(); + expect((window.ade.files.readFile as any).mock.calls.some(([arg]: [{ path: string }]) => arg.path === "src/main.ts")).toBe(true); + }); + + it("closes deleted tabs without crashing the page", async () => { + renderFilesPage({ + openFilePath: "src/index.ts", + preferPrimaryWorkspace: true, + }); + + await waitForEditorText("value = 1"); + fireEvent.click(await screen.findByTitle("src")); + expect(await screen.findByTitle("src/index.ts")).toBeTruthy(); + + currentTree = [ + { + name: "src", + path: "src", + type: "directory", + children: [], + }, + { + name: ".ade", + path: ".ade", + type: "directory", + children: [ + { + name: "context", + path: ".ade/context", + type: "directory", + }, + ], + }, + ]; + delete fileContents["src/index.ts"]; - await act(async () => { - fireEvent.click(screen.getByTitle("Show dotfiles")); + emitFileChange({ + workspaceId: "primary", + type: "deleted", + path: "src/index.ts", + ts: new Date().toISOString(), }); await waitFor(() => { - expect(screen.getByTestId("mock-monaco-editor").textContent).toContain("# PRD.ade"); + expect(screen.getByText(/OPEN A FILE TO START EDITING/i)).toBeTruthy(); }); - expect(screen.getByTitle(".ade")).toBeTruthy(); + await waitFor(() => { + expect(screen.queryByTitle("src/index.ts")).toBeNull(); + }); + expect(screen.getByTestId("mock-pane-layout")).toBeTruthy(); + }); + + it("refreshes clean tabs from disk but preserves dirty tabs", async () => { + renderFilesPage({ + openFilePath: "src/index.ts", + preferPrimaryWorkspace: true, + }); + + await waitForEditorText("value = 1"); + + fileContents["src/index.ts"] = "export const value = 2;\n"; + emitFileChange({ + workspaceId: "primary", + type: "modified", + path: "src/index.ts", + ts: new Date().toISOString(), + }); + + await waitForEditorText("value = 2"); + + expect(latestMockEditor).toBeTruthy(); + act(() => { + latestMockEditor?.setValue("export const value = 99;\n"); + }); + await waitForEditorText("value = 99"); + + fileContents["src/index.ts"] = "export const value = 3;\n"; + vi.useFakeTimers(); + try { + emitFileChange({ + workspaceId: "primary", + type: "modified", + path: "src/index.ts", + ts: new Date().toISOString(), + }); + + await act(async () => { + vi.advanceTimersByTime(180); + await Promise.resolve(); + }); + + expect(screen.getByTestId("mock-monaco-editor").textContent).toContain("value = 99"); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 14e4a0de6..4775ec4ec 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -18,8 +18,6 @@ import { FloppyDisk as Save, MagnifyingGlass as Search, Sparkle as Sparkles, - Eye, - EyeSlash, Terminal as TerminalSquare, FileXls as FileSpreadsheet, X, @@ -96,12 +94,12 @@ type FilesPageSessionState = { }; const filesPageSessionByScope = new Map(); +const MAX_QUEUED_TREE_PARENT_REFRESHES = 24; function filesSessionKey(projectRoot: string, laneId: string | null): string { return `${projectRoot}::${laneId ?? "__primary__"}`; } const FILES_EDITOR_THEME_KEY = "ade.files.editorTheme"; -const FILES_SHOW_HIDDEN_KEY = "ade.files.showHidden"; function readStoredEditorTheme(): EditorThemeMode { try { @@ -121,22 +119,6 @@ function persistEditorTheme(theme: EditorThemeMode): void { } } -function readStoredShowHidden(): boolean { - try { - return window.localStorage.getItem(FILES_SHOW_HIDDEN_KEY) === "true"; - } catch { - return false; - } -} - -function persistShowHidden(show: boolean): void { - try { - window.localStorage.setItem(FILES_SHOW_HIDDEN_KEY, show ? "true" : "false"); - } catch { - // ignore - } -} - let monacoInit: Promise | null = null; async function loadMonaco(): Promise { @@ -234,11 +216,25 @@ function parentDirOfPath(filePath: string): string { return normalized.slice(0, idx); } -function hasHiddenPathSegment(filePath: string): boolean { - return filePath - .replace(/\\/g, "/") - .split("/") - .some((segment) => segment.startsWith(".") && segment !== "." && segment !== ".."); +function normalizePath(filePath: string): string { + return filePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); +} + +function isPathEqualOrDescendant(filePath: string, rootPath: string): boolean { + const normalizedPath = normalizePath(filePath); + const normalizedRoot = normalizePath(rootPath); + if (!normalizedRoot) return normalizedPath.length === 0; + return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); +} + +function remapPathForRename(filePath: string, oldPath: string, newPath: string): string { + const normalizedPath = normalizePath(filePath); + const normalizedOld = normalizePath(oldPath); + const normalizedNew = normalizePath(newPath); + if (!normalizedOld || !normalizedNew) return normalizedPath; + if (normalizedPath === normalizedOld) return normalizedNew; + if (!normalizedPath.startsWith(`${normalizedOld}/`)) return normalizedPath; + return `${normalizedNew}${normalizedPath.slice(normalizedOld.length)}`; } const FILE_ICON_COLORS = { @@ -362,16 +358,12 @@ export function FilesPage() { queuedFull: false, queuedParents: new Set() }); - const watcherRefreshTimerRef = useRef(null); - const refreshTreeRef = useRef<((parentPath?: string) => Promise) | null>(null); const refreshTreeNowRef = useRef<((parentPath?: string) => Promise) | null>(null); - const scheduleTreeRefreshRef = useRef<((parentPath?: string, delayMs?: number) => void) | null>(null); const [openTabs, setOpenTabs] = useState(() => initialSession?.openTabs.map((tab) => ({ ...tab })) ?? []); const [activeTabPath, setActiveTabPath] = useState(initialSession?.activeTabPath ?? null); const [mode, setMode] = useState(initialSession?.mode ?? "edit"); const [editorTheme, setEditorTheme] = useState(initialSession?.editorTheme ?? readStoredEditorTheme()); - const [showHidden, setShowHidden] = useState(readStoredShowHidden); const [quickOpen, setQuickOpen] = useState(""); const [quickOpenResults, setQuickOpenResults] = useState([]); @@ -397,6 +389,7 @@ export function FilesPage() { const modelKeyRef = useRef(null); const editorApplyingRef = useRef(false); const activeTabPathRef = useRef(null); + const openTabsRef = useRef([]); const searchInputRef = useRef(null); const openInMenuRef = useRef(null); @@ -583,6 +576,10 @@ export function FilesPage() { activeTabPathRef.current = activeTabPath; }, [activeTabPath]); + useEffect(() => { + openTabsRef.current = openTabs; + }, [openTabs]); + useEffect(() => { const st = (location.state as FilesPageNavState | null) ?? null; const openFilePath = st?.openFilePath?.trim(); @@ -602,7 +599,7 @@ export function FilesPage() { workspaceId, parentPath, depth: parentPath ? 1 : 2, - includeIgnored: showHidden + includeIgnored: true }); if (!parentPath) { setTree(nodes); @@ -619,7 +616,7 @@ export function FilesPage() { } catch (err) { setError(err instanceof Error ? err.message : String(err)); } - }, [workspaceId, showHidden]); + }, [workspaceId]); refreshTreeNowRef.current = refreshTreeNow; @@ -640,8 +637,6 @@ export function FilesPage() { try { let nextParent: string | undefined = normalizedParent; while (true) { - // Use the ref so queued refreshes always pick up the latest - // showHidden / workspaceId values instead of a stale closure. await refreshTreeNowRef.current!(nextParent); if (state.queuedFull) { state.queuedFull = false; @@ -662,42 +657,20 @@ export function FilesPage() { } }, [refreshTreeNow, workspaceId]); - const scheduleTreeRefresh = useCallback((parentPath?: string, delayMs = 140) => { - const normalizedParent = parentPath?.trim() ? parentPath : undefined; - if (normalizedParent) { - const state = treeRefreshStateRef.current; - if (!state.queuedFull) { - state.queuedParents.add(normalizedParent); - } - } - if (watcherRefreshTimerRef.current != null) return; - watcherRefreshTimerRef.current = window.setTimeout(() => { - watcherRefreshTimerRef.current = null; - void refreshTree(normalizedParent).catch(() => {}); - }, delayMs); - }, [refreshTree]); - - useEffect(() => { - refreshTreeRef.current = refreshTree; - }, [refreshTree]); - - useEffect(() => { - scheduleTreeRefreshRef.current = scheduleTreeRefresh; - }, [scheduleTreeRefresh]); - const openFile = useCallback(async (filePath: string, options: { forceReload?: boolean; preserveMode?: boolean } = {}) => { if (!workspaceId) return; + const normalizedPath = normalizePath(filePath); try { - const loaded = await window.ade.files.readFile({ workspaceId, path: filePath }); + const loaded = await window.ade.files.readFile({ workspaceId, path: normalizedPath }); if (loaded.isBinary) { setError("Binary files are read-only and cannot be edited in this view."); } setOpenTabs((prev) => { - const existing = prev.find((tab) => tab.path === filePath); + const existing = prev.find((tab) => tab.path === normalizedPath); if (existing && !options.forceReload) return prev; if (existing && options.forceReload) { return prev.map((tab) => ( - tab.path === filePath + tab.path === normalizedPath ? { ...tab, content: loaded.content, @@ -711,7 +684,7 @@ export function FilesPage() { return [ ...prev, { - path: filePath, + path: normalizedPath, content: loaded.content, savedContent: loaded.content, languageId: loaded.languageId, @@ -722,13 +695,51 @@ export function FilesPage() { if (!options.preserveMode) { setMode("edit"); } - setActiveTabPath(filePath); - setSelectedNodePath(filePath); + setActiveTabPath(normalizedPath); + setSelectedNodePath(normalizedPath); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, [workspaceId]); + const syncCleanTabFromDisk = useCallback(async (filePathRaw: string) => { + if (!workspaceId) return; + const filePath = normalizePath(filePathRaw); + if (!filePath) return; + const hasCleanOpenTab = openTabsRef.current.some((tab) => tab.path === filePath && tab.content === tab.savedContent); + if (!hasCleanOpenTab) return; + + try { + const loaded = await window.ade.files.readFile({ workspaceId, path: filePath }); + setOpenTabs((prev) => { + let changed = false; + const next = prev.map((tab) => { + if (tab.path !== filePath) return tab; + if (tab.content !== tab.savedContent) return tab; + if ( + tab.content === loaded.content && + tab.savedContent === loaded.content && + tab.languageId === loaded.languageId && + tab.isBinary === loaded.isBinary + ) { + return tab; + } + changed = true; + return { + ...tab, + content: loaded.content, + savedContent: loaded.content, + languageId: loaded.languageId, + isBinary: loaded.isBinary + }; + }); + return changed ? next : prev; + }); + } catch { + // A deleted or renamed path can race this sync; ignore quietly. + } + }, [workspaceId]); + useEffect(() => { const pending = pendingOpenRef.current; if (!pending) return; @@ -751,13 +762,10 @@ export function FilesPage() { } if (!workspaceId) return; - if (!showHidden && hasHiddenPathSegment(pending.filePath)) { - setShowHidden(true); - } openFile(pending.filePath).catch(() => {}); pendingOpenRef.current = null; navigate(location.pathname, { replace: true, state: null }); - }, [workspaces, workspaceId, switchWorkspace, openFile, navigate, location.pathname, showHidden]); + }, [workspaces, workspaceId, switchWorkspace, openFile, navigate, location.pathname]); const closeTab = useCallback((filePath: string) => { setOpenTabs((prev) => { @@ -921,36 +929,119 @@ export function FilesPage() { treeRefreshStateRef.current.inFlight = false; treeRefreshStateRef.current.queuedFull = false; treeRefreshStateRef.current.queuedParents.clear(); - if (watcherRefreshTimerRef.current != null) { - window.clearTimeout(watcherRefreshTimerRef.current); - watcherRefreshTimerRef.current = null; - } - refreshTreeRef.current?.().catch(() => {}); - window.ade.files.watchChanges({ workspaceId }).catch(() => {}); + void refreshTree(); + + let refreshTimer: number | null = null; + let queuedFullRefresh = false; + const queuedParents = new Set(); + const pendingTabSyncPaths = new Set(); + + const queueTreeRefresh = (parentPath?: string) => { + const normalizedParent = normalizePath(parentPath ?? ""); + if (!normalizedParent) { + queuedFullRefresh = true; + queuedParents.clear(); + } else if (!queuedFullRefresh) { + queuedParents.add(normalizedParent); + if (queuedParents.size > MAX_QUEUED_TREE_PARENT_REFRESHES) { + queuedFullRefresh = true; + queuedParents.clear(); + } + } + }; + + const scheduleFlush = () => { + if (refreshTimer != null) window.clearTimeout(refreshTimer); + refreshTimer = window.setTimeout(() => { + refreshTimer = null; + const parentsToRefresh = queuedFullRefresh ? [] : Array.from(queuedParents); + queuedFullRefresh = false; + queuedParents.clear(); + + if (parentsToRefresh.length === 0) { + void refreshTree(); + } else { + for (const parentPath of parentsToRefresh) { + void refreshTree(parentPath); + } + } + + const pathsToSync = Array.from(pendingTabSyncPaths); + pendingTabSyncPaths.clear(); + for (const path of pathsToSync) { + void syncCleanTabFromDisk(path); + } + }, 120); + }; + + window.ade.files.watchChanges({ workspaceId, includeIgnored: true }).catch(() => {}); const unsub = window.ade.files.onChange((ev) => { if (ev.workspaceId !== workspaceId) return; - if (ev.type === "renamed") { - scheduleTreeRefreshRef.current?.(parentPathOf(ev.oldPath ?? "")); - scheduleTreeRefreshRef.current?.(parentPathOf(ev.path)); + + const nextPath = normalizePath(ev.path); + const oldPath = normalizePath(ev.oldPath ?? ""); + + if (ev.type === "renamed" && oldPath && nextPath) { + setOpenTabs((prev) => { + let changed = false; + const next = prev.map((tab) => { + const mappedPath = remapPathForRename(tab.path, oldPath, nextPath); + if (mappedPath === tab.path) return tab; + changed = true; + if (tab.content === tab.savedContent) pendingTabSyncPaths.add(mappedPath); + return { ...tab, path: mappedPath }; + }); + return changed ? next : prev; + }); + setActiveTabPath((current) => (current ? remapPathForRename(current, oldPath, nextPath) : current)); + setSelectedNodePath((current) => (current ? remapPathForRename(current, oldPath, nextPath) : current)); + setExpanded((prev) => { + let changed = false; + const next = new Set(); + for (const path of prev) { + const mappedPath = remapPathForRename(path, oldPath, nextPath); + next.add(mappedPath); + if (mappedPath !== path) changed = true; + } + return changed ? next : prev; + }); + queueTreeRefresh(parentPathOf(oldPath)); + queueTreeRefresh(parentPathOf(nextPath)); + scheduleFlush(); return; } - scheduleTreeRefreshRef.current?.(parentPathOf(ev.path)); + + if (ev.type === "deleted" && nextPath) { + setOpenTabs((prev) => { + const next = prev.filter((tab) => !isPathEqualOrDescendant(tab.path, nextPath)); + if (next.length !== prev.length) { + const activePath = activeTabPathRef.current; + if (activePath && !next.some((tab) => tab.path === activePath)) { + setActiveTabPath(next[next.length - 1]?.path ?? null); + } + } + return next.length === prev.length ? prev : next; + }); + setSelectedNodePath((current) => (current && isPathEqualOrDescendant(current, nextPath) ? null : current)); + setExpanded((prev) => { + const next = new Set(Array.from(prev).filter((path) => !isPathEqualOrDescendant(path, nextPath))); + return next.size === prev.size ? prev : next; + }); + } else if (nextPath) { + pendingTabSyncPaths.add(nextPath); + } + + queueTreeRefresh(parentPathOf(nextPath)); + scheduleFlush(); }); return () => { unsub(); - if (watcherRefreshTimerRef.current != null) { - window.clearTimeout(watcherRefreshTimerRef.current); - watcherRefreshTimerRef.current = null; - } - window.ade.files.stopWatching({ workspaceId }).catch(() => {}); + if (refreshTimer != null) window.clearTimeout(refreshTimer); + window.ade.files.stopWatching({ workspaceId, includeIgnored: true }).catch(() => {}); }; - }, [workspaceId]); - - useEffect(() => { - if (workspaceId) refreshTree().catch(() => {}); - }, [showHidden]); // eslint-disable-line react-hooks/exhaustive-deps + }, [workspaceId, refreshTree, syncCleanTabFromDisk]); useEffect(() => { const onBeforeUnload = (event: BeforeUnloadEvent) => { @@ -1029,7 +1120,7 @@ export function FilesPage() { return; } if (!showQuickOpen) return; - window.ade.files.quickOpen({ workspaceId, query: quickOpen, limit: 80 }) + window.ade.files.quickOpen({ workspaceId, query: quickOpen, limit: 80, includeIgnored: true }) .then(setQuickOpenResults) .catch(() => setQuickOpenResults([])); }, [quickOpen, workspaceId, showQuickOpen]); @@ -1040,7 +1131,7 @@ export function FilesPage() { return; } const timer = setTimeout(() => { - window.ade.files.searchText({ workspaceId, query: searchQuery, limit: 200 }) + window.ade.files.searchText({ workspaceId, query: searchQuery, limit: 200, includeIgnored: true }) .then(setSearchResults) .catch(() => setSearchResults([])); }, 150); @@ -1379,26 +1470,7 @@ export function FilesPage() { ) : null}
-
- +
+
+
TERMINAL
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ These preferences apply across work terminals, lane shells, resolver terminals, and the chat drawer. +
+
+
+
; const showClaudeCacheTimer = shouldShowClaudeCacheTtl({ provider: session.toolType === "claude-chat" ? "claude" : null, @@ -56,13 +68,15 @@ export const SessionCard = React.memo(function SessionCard({
- {/* Content */} + {/* Content β€” 3 rows */}
- {/* Title row */} + {/* Row 1: Status dot + Title + Relative time */}
{primaryText} + + {relativeTimeCompact(session.endedAt ?? session.startedAt)} +
- {/* Meta row */} + {/* Row 2: Summary/preview line (conditional) */} + {previewLine ? ( +
+ + {previewLine} + +
+ ) : null} + + {/* Row 3: Tool type + Lane + Cache badge + Delta chips + Exit code */}
+ + {shortToolTypeLabel(session.toolType)} + + · {laneMarker} - + {lane?.name ?? session.laneName} @@ -139,11 +169,7 @@ export const SessionCard = React.memo(function SessionCard({
-
{children}
+ + {!collapsed && count > 0 ? ( +
{children}
+ ) : null}
); } @@ -95,6 +98,8 @@ export const SessionListPane = React.memo(function SessionListPane({ setSessionListOrganization, workCollapsedLaneIds, toggleWorkLaneCollapsed, + workCollapsedSectionIds, + toggleWorkSectionCollapsed, sessionsGroupedByLane, }: { lanes: LaneSummary[]; @@ -121,6 +126,8 @@ export const SessionListPane = React.memo(function SessionListPane({ setSessionListOrganization: (v: WorkSessionListOrganization) => void; workCollapsedLaneIds: string[]; toggleWorkLaneCollapsed: (laneId: string) => void; + workCollapsedSectionIds: string[]; + toggleWorkSectionCollapsed: (sectionId: string) => void; sessionsGroupedByLane: Map | null; }) { const navigate = useNavigate(); @@ -164,15 +171,36 @@ export const SessionListPane = React.memo(function SessionListPane({ const groupedByStatusList = (
- + } + label="Running" + count={runningFiltered.length} + collapsed={workCollapsedSectionIds.includes("status:running")} + onToggleCollapsed={() => toggleWorkSectionCollapsed("status:running")} + > {renderCards(runningFiltered)} - - + + } + label="Awaiting" + count={awaitingInputFiltered.length} + collapsed={workCollapsedSectionIds.includes("status:awaiting")} + onToggleCollapsed={() => toggleWorkSectionCollapsed("status:awaiting")} + > {renderCards(awaitingInputFiltered)} - - + + } + label="Ended" + count={endedFiltered.length} + collapsed={workCollapsedSectionIds.includes("status:ended")} + onToggleCollapsed={() => toggleWorkSectionCollapsed("status:ended")} + > {renderCards(endedFiltered)} - +
); @@ -181,45 +209,24 @@ export const SessionListPane = React.memo(function SessionListPane({ {orderedLanes.map((lane) => { const list = sessionsGroupedByLane?.get(lane.id) ?? []; const collapsed = workCollapsedLaneIds.includes(lane.id); - const { running, awaiting, ended } = bucketSessions(list); const total = list.length; + const laneIcon = ( + + {lane.icon ? iconGlyph(lane.icon) : } + + ); return ( -
- - {!collapsed && total > 0 ? ( -
- - {renderCards(running)} - - - {renderCards(awaiting)} - - - {renderCards(ended)} - -
- ) : null} - {!collapsed && total === 0 ? ( -
- No sessions -
- ) : null} -
+ toggleWorkLaneCollapsed(lane.id)} + > + {renderCards(list)} + ); })}
@@ -227,30 +234,36 @@ export const SessionListPane = React.memo(function SessionListPane({ const byTimeList = (
- {timeBuckets.today.length > 0 ? ( -
-
- Today -
-
{renderCards(timeBuckets.today)}
-
- ) : null} - {timeBuckets.yesterday.length > 0 ? ( -
-
- Yesterday -
-
{renderCards(timeBuckets.yesterday)}
-
- ) : null} - {timeBuckets.older.length > 0 ? ( -
-
- Older -
-
{renderCards(timeBuckets.older)}
-
- ) : null} + toggleWorkSectionCollapsed("time:today")} + > + {renderCards(timeBuckets.today)} + + toggleWorkSectionCollapsed("time:yesterday")} + > + {renderCards(timeBuckets.yesterday)} + + toggleWorkSectionCollapsed("time:older")} + > + {renderCards(timeBuckets.older)} +
); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 1cdbb32f0..7323180be 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -9,6 +9,12 @@ const mockState = vi.hoisted(() => ({ nextFitDims: { cols: 120, rows: 40 }, shouldThrowWebglAddon: false, lastContextLossHandler: null as (() => void) | null, + theme: "dark" as const, + terminalPreferences: { + fontSize: 12.5, + lineHeight: 1.25, + scrollback: 10_000, + }, })); const resizeObservers: MockResizeObserver[] = []; @@ -32,7 +38,22 @@ class MockIntersectionObserver { } vi.mock("../../state/appStore", () => ({ - useAppStore: vi.fn((selector: (state: { theme: "dark" }) => unknown) => selector({ theme: "dark" })), + useAppStore: vi.fn((selector: (state: { + theme: "dark"; + terminalPreferences: { + fontSize: number; + lineHeight: number; + scrollback: number; + }; + }) => unknown) => selector({ + theme: mockState.theme, + terminalPreferences: mockState.terminalPreferences, + })), + DEFAULT_TERMINAL_PREFERENCES: { + fontSize: 12.5, + lineHeight: 1.25, + scrollback: 10_000, + }, })); vi.mock("@xterm/xterm", () => ({ @@ -230,6 +251,12 @@ describe("TerminalView", () => { mockState.nextFitDims = { cols: 120, rows: 40 }; mockState.shouldThrowWebglAddon = false; mockState.lastContextLossHandler = null; + mockState.theme = "dark"; + mockState.terminalPreferences = { + fontSize: 12.5, + lineHeight: 1.25, + scrollback: 10_000, + }; }); afterEach(() => { @@ -325,14 +352,42 @@ describe("TerminalView", () => { const previousFallbacks = getTerminalRuntimeSnapshot("session-dom")?.health.rendererFallbacks ?? 0; render(); - await flushAllTimers(); - // Extra flush: initRendererChain is fire-and-forget and its dynamic import - // may not settle within a single timer flush cycle in CI environments. - await act(async () => {}); - await flushAllTimers(); + // initRendererChain is fire-and-forget with a dynamic import inside. + // Multiple flush cycles are needed for the microtask chain to fully settle: + // 1) timer flush kicks off the render + initRendererChain + // 2) microtask flush lets the dynamic import resolve + // 3) second timer flush lets the post-import code run + for (let i = 0; i < 4; i++) { + await act(async () => {}); + await flushAllTimers(); + } const runtime = getTerminalRuntimeSnapshot("session-dom"); expect(runtime?.renderer).toBe("dom"); expect(runtime?.health.rendererFallbacks).toBeGreaterThan(previousFallbacks); }); + + it("applies updated terminal preferences to an existing runtime", async () => { + const view = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + options: Record; + } | undefined; + expect(terminal?.options.fontSize).toBe(12.5); + expect(terminal?.options.lineHeight).toBe(1.25); + expect(terminal?.options.scrollback).toBe(10_000); + + mockState.terminalPreferences = { + fontSize: 14, + lineHeight: 1.3, + scrollback: 20_000, + }; + view.rerender(); + await flushAllTimers(); + + expect(terminal?.options.fontSize).toBe(14); + expect(terminal?.options.lineHeight).toBe(1.3); + expect(terminal?.options.scrollback).toBe(20_000); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index c239af680..acb828eb0 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -3,8 +3,12 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { cn } from "../ui/cn"; -import { MONO_FONT } from "../lanes/laneDesignTokens"; -import { useAppStore, type ThemeId } from "../../state/appStore"; +import { + DEFAULT_TERMINAL_PREFERENCES, + useAppStore, + type TerminalPreferences, + type ThemeId, +} from "../../state/appStore"; type XtermTheme = NonNullable[0]>["theme"]; type TerminalRendererMode = "webgl" | "dom"; @@ -23,6 +27,8 @@ type RuntimeSnapshot = { health: TerminalHealthCounters; }; +type TerminalRenderPreferences = Pick; + type RuntimeListener = (snapshot: RuntimeSnapshot) => void; type CachedRuntime = { @@ -77,6 +83,16 @@ const MIN_VALID_ROWS = 6; const MIN_HOST_WIDTH_PX = 120; const MIN_HOST_HEIGHT_PX = 48; const INVALID_FIT_RETRY_MS = 90; +const TERMINAL_FONT_STACK = [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "\"Cascadia Mono\"", + "\"JetBrains Mono\"", + "\"Geist Mono\"", + "monospace", +].join(", "); const runtimeCache = new Map(); let parkedRoot: HTMLDivElement | null = null; @@ -193,6 +209,24 @@ function clearTextureAtlas(runtime: CachedRuntime) { } } +function applyRuntimeVisualOptions( + runtime: CachedRuntime, + args: { + theme: XtermTheme; + preferences: TerminalRenderPreferences; + }, +) { + try { + runtime.term.options.theme = args.theme ? { ...args.theme } : undefined; + runtime.term.options.fontFamily = TERMINAL_FONT_STACK; + runtime.term.options.fontSize = args.preferences.fontSize; + runtime.term.options.lineHeight = args.preferences.lineHeight; + runtime.term.options.scrollback = args.preferences.scrollback; + } catch { + // ignore updates after disposal + } +} + function restoreTerminalDims(runtime: CachedRuntime, dims: TerminalDims) { try { runtime.term.resize(dims.cols, dims.rows); @@ -616,7 +650,12 @@ async function initRendererChain(runtime: CachedRuntime) { await setRenderer(runtime, "dom"); } -function createRuntime(args: { ptyId: string; sessionId: string; theme: XtermTheme }): CachedRuntime { +function createRuntime(args: { + ptyId: string; + sessionId: string; + theme: XtermTheme; + preferences: TerminalRenderPreferences; +}): CachedRuntime { const host = document.createElement("div"); host.className = "h-full w-full m-0 p-0 border-0 overflow-hidden"; @@ -626,10 +665,10 @@ function createRuntime(args: { ptyId: string; sessionId: string; theme: XtermThe cursorBlink: true, cursorInactiveStyle: "none", documentOverride: document, - scrollback: 6000, - fontFamily: MONO_FONT, - fontSize: 13, - lineHeight: 1.2, + scrollback: args.preferences.scrollback, + fontFamily: TERMINAL_FONT_STACK, + fontSize: args.preferences.fontSize, + lineHeight: args.preferences.lineHeight, theme: args.theme }); @@ -811,11 +850,20 @@ function createRuntime(args: { ptyId: string; sessionId: string; theme: XtermThe return runtime; } -function ensureRuntime(args: { ptyId: string; sessionId: string; theme: XtermTheme }): CachedRuntime { +function ensureRuntime(args: { + ptyId: string; + sessionId: string; + theme: XtermTheme; + preferences: TerminalRenderPreferences; +}): CachedRuntime { const existing = runtimeCache.get(args.sessionId); if (existing && !existing.disposed) { if (existing.ptyId === args.ptyId) { clearDisposeTimer(existing); + applyRuntimeVisualOptions(existing, { + theme: args.theme, + preferences: args.preferences, + }); return existing; } teardownRuntime(existing); @@ -854,21 +902,33 @@ export function TerminalView({ isVisible?: boolean; }) { const appTheme = useAppStore((s) => s.theme); + const terminalPreferences = useAppStore((s) => s.terminalPreferences); const wrapperRef = useRef(null); const containerRef = useRef(null); const runtimeRef = useRef(null); const [exited, setExited] = useState(null); const termTheme = useMemo(() => terminalThemes[isDarkTheme(appTheme) ? "dark" : "light"], [appTheme]); - const mountConfigRef = useRef({ isActive, isVisible, theme: termTheme }); - mountConfigRef.current = { isActive, isVisible, theme: termTheme }; + const resolvedPreferences = useMemo(() => ({ + fontSize: terminalPreferences?.fontSize ?? DEFAULT_TERMINAL_PREFERENCES.fontSize, + lineHeight: terminalPreferences?.lineHeight ?? DEFAULT_TERMINAL_PREFERENCES.lineHeight, + scrollback: terminalPreferences?.scrollback ?? DEFAULT_TERMINAL_PREFERENCES.scrollback, + }), [terminalPreferences]); + const currentMountConfig = { isActive, isVisible, theme: termTheme, preferences: resolvedPreferences }; + const mountConfigRef = useRef(currentMountConfig); + mountConfigRef.current = currentMountConfig; useEffect(() => { const el = containerRef.current; if (!el) return; const mountConfig = mountConfigRef.current; - const runtime = ensureRuntime({ ptyId, sessionId, theme: mountConfig.theme }); + const runtime = ensureRuntime({ + ptyId, + sessionId, + theme: mountConfig.theme, + preferences: mountConfig.preferences, + }); runtimeRef.current = runtime; runtime.refs += 1; clearDisposeTimer(runtime); @@ -1074,14 +1134,15 @@ export function TerminalView({ const runtime = runtimeRef.current ?? runtimeCache.get(sessionId); if (!runtime || runtime.disposed) return; const id = requestAnimationFrame(() => { - try { - runtime.term.options.theme = termTheme ? { ...termTheme } : undefined; - } catch { - // ignore theme updates when disposed - } + applyRuntimeVisualOptions(runtime, { + theme: termTheme, + preferences: resolvedPreferences, + }); + clearTextureAtlas(runtime); + scheduleFit(runtime, true); }); return () => cancelAnimationFrame(id); - }, [sessionId, termTheme]); + }, [resolvedPreferences, sessionId, termTheme]); // When this terminal becomes the active tab, force fit + focus + scroll useEffect(() => { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 42fafc375..fe472424c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -185,6 +185,8 @@ export function TerminalsPage() { setSessionListOrganization={work.setSessionListOrganization} workCollapsedLaneIds={work.workCollapsedLaneIds} toggleWorkLaneCollapsed={work.toggleWorkLaneCollapsed} + workCollapsedSectionIds={work.workCollapsedSectionIds} + toggleWorkSectionCollapsed={work.toggleWorkSectionCollapsed} sessionsGroupedByLane={work.sessionsGroupedByLane} /> ), diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index bc669182a..36c5811c2 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -323,9 +323,9 @@ export function WorkViewArea({ style={{ borderBottom: "1px solid var(--work-pane-border)", background: "transparent", - height: 28, - minHeight: 28, - maxHeight: 28, + height: 32, + minHeight: 32, + maxHeight: 32, }} > @@ -343,7 +343,7 @@ export function WorkViewArea({ className="group/tab inline-flex shrink-0 items-center gap-1.5 transition-colors" style={{ padding: "0 8px", - height: 28, + height: 32, fontSize: 11, fontWeight: isActive ? 500 : 400, background: "transparent", @@ -352,7 +352,7 @@ export function WorkViewArea({ border: "none", borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent", borderRadius: "0", - opacity: isActive ? 1 : 0.5, + opacity: isActive ? 1 : 0.65, }} onClick={() => onSelectItem(session.id)} onContextMenu={(e) => handleContextMenu(session, e)} @@ -477,7 +477,7 @@ export function WorkViewArea({ return (
{!group.collapsed ? ( -
+
{group.sessions.map((session) => { const isActive = activeSession?.id === session.id; const dot = sessionStatusDot(session); @@ -513,18 +515,17 @@ export function WorkViewArea({ - +
+ {([ + { mode: "tabs" as const, icon: , label: "Tabs", title: "Tab View" }, + { mode: "grid" as const, icon: , label: "Grid", title: "Grid View" }, + ]).map(({ mode, icon, label, title }) => { + const active = viewMode === mode; + return ( + + ); + })}
); } diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index c4d0896b6..a11e4dff8 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -140,6 +140,22 @@ describe("tracked CLI resume helpers", () => { })).toBe("codex --no-alt-screen -c approval_policy=on-failure -c sandbox_mode=workspace-write resume thread-99"); }); + it("falls back to the provider resume picker when the concrete target is missing", () => { + expect(buildTrackedCliResumeCommand({ + provider: "claude", + targetKind: "session", + targetId: null, + launch: { permissionMode: "default" }, + })).toBe("claude --permission-mode default --resume"); + + expect(buildTrackedCliResumeCommand({ + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { permissionMode: "full-auto" }, + })).toBe("codex --no-alt-screen --full-auto resume"); + }); + it("prefers structured metadata over the legacy resume command string", () => { const session = { resumeCommand: "codex resume picker", diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.ts index 19d9e7965..37589f34c 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.ts @@ -68,12 +68,14 @@ export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata): const targetId = metadata.targetId?.trim() ?? ""; if (metadata.provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(metadata.launch.permissionMode)]; - if (targetId) parts.push("--resume", targetId); + parts.push("--resume"); + if (targetId) parts.push(targetId); return parts.join(" "); } const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(metadata.launch.permissionMode)]; - if (targetId) parts.push("resume", targetId); + parts.push("resume"); + if (targetId) parts.push(targetId); return parts.join(" "); } diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 1337aa796..5e78be495 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -9,6 +9,20 @@ import { act, renderHook } from "@testing-library/react"; const focusSessionSpy = vi.fn(); const selectLaneSpy = vi.fn(); const setWorkViewStateSpy = vi.fn(); +const navigateSpy = vi.fn(); +let fakeAppStoreState: Record; + +function resetFakeAppStoreState() { + fakeAppStoreState = { + project: { rootPath: "/fake/project" }, + lanes: [{ id: "lane-1", name: "Lane 1" }], + focusSession: focusSessionSpy, + focusedSessionId: null, + selectLane: selectLaneSpy, + workViewByProject: {}, + setWorkViewState: setWorkViewStateSpy, + }; +} // --------------------------------------------------------------------------- // Module-level mocks (hoisted by vitest) @@ -58,22 +72,13 @@ vi.mock("../../lib/sessions", () => ({ })); vi.mock("react-router-dom", () => ({ - useNavigate: vi.fn(() => vi.fn()), + useNavigate: vi.fn(() => navigateSpy), useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]), })); vi.mock("../../state/appStore", () => ({ useAppStore: vi.fn((selector: (state: Record) => unknown) => { - const fakeState: Record = { - project: { rootPath: "/fake/project" }, - lanes: [{ id: "lane-1", name: "Lane 1" }], - focusSession: focusSessionSpy, - focusedSessionId: null, - selectLane: selectLaneSpy, - workViewByProject: {}, - setWorkViewState: setWorkViewStateSpy, - }; - return selector(fakeState); + return selector(fakeAppStoreState); }), })); @@ -108,6 +113,7 @@ function installWindowAde() { describe("useWorkSessions β€” refresh-before-focus ordering", () => { beforeEach(() => { vi.clearAllMocks(); + resetFakeAppStoreState(); installWindowAde(); listSessionsCachedMock.mockResolvedValue([]); }); @@ -226,6 +232,164 @@ describe("useWorkSessions β€” refresh-before-focus ordering", () => { draftKind: "chat", }); }); + + it("resumeSession keeps the Work view active and reuses the existing tracked session id", async () => { + const session = { + id: "session-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + goal: "Resume codex", + toolType: "codex" as const, + title: "Codex", + status: "completed" as const, + startedAt: "2026-04-01T12:00:00.000Z", + endedAt: "2026-04-01T12:30:00.000Z", + exitCode: 0, + transcriptPath: "/tmp/session-1.log", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "exited" as const, + resumeCommand: "codex --no-alt-screen --full-auto resume thread-1", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "thread-1", + launch: { permissionMode: "full-auto" as const }, + }, + }; + listSessionsCachedMock.mockResolvedValue([session]); + + const workState = { + openItemIds: [] as string[], + activeItemId: null as string | null, + selectedItemId: null as string | null, + viewMode: "tabs" as const, + draftKind: "chat" as const, + laneFilter: "all", + statusFilter: "all" as const, + search: "", + sessionListOrganization: "by-lane" as const, + workCollapsedLaneIds: [] as string[], + workCollapsedTabGroupIds: [] as string[], + workFocusSessionsHidden: false, + }; + setWorkViewStateSpy.mockImplementation((_projectRoot: string, next: any) => { + const resolved = typeof next === "function" ? next(workState) : { ...workState, ...next }; + Object.assign(workState, resolved); + }); + (window as any).ade.pty.create.mockResolvedValue({ sessionId: "session-1", ptyId: "pty-2" }); + + const { result } = renderHook(() => useWorkSessions()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + await act(async () => { + await result.current.resumeSession(session); + }); + + expect((window as any).ade.pty.create).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-1", + startupCommand: "codex --no-alt-screen --full-auto resume thread-1", + })); + expect(focusSessionSpy).toHaveBeenCalledWith("session-1"); + expect(workState.activeItemId).toBe("session-1"); + expect(workState.selectedItemId).toBe("session-1"); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it("refreshes against the newly active project before pruning that project's saved tabs", async () => { + const sessionA = { + id: "session-a", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "shell" as const, + title: "Session A", + status: "running" as const, + startedAt: "2026-04-01T12:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running" as const, + resumeCommand: null, + }; + const sessionB = { + ...sessionA, + id: "session-b", + title: "Session B", + }; + const persistedProjectBState = { + openItemIds: ["session-b"], + activeItemId: "session-b", + selectedItemId: "session-b", + viewMode: "grid" as const, + draftKind: "chat" as const, + laneFilter: "all", + statusFilter: "all" as const, + search: "", + sessionListOrganization: "by-lane" as const, + workCollapsedLaneIds: [], + workCollapsedTabGroupIds: [], + workFocusSessionsHidden: false, + }; + + listSessionsCachedMock + .mockResolvedValueOnce([sessionA]) + .mockResolvedValueOnce([sessionB]); + + const { result, rerender } = renderHook(() => useWorkSessions()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(result.current.sessions.map((session) => session.id)).toEqual(["session-a"]); + + fakeAppStoreState = { + ...fakeAppStoreState, + project: { rootPath: "/project/b" }, + workViewByProject: { + "/project/b": persistedProjectBState, + }, + }; + + act(() => { + rerender(); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(result.current.sessions.map((session) => session.id)).toEqual(["session-b"]); + expect(listSessionsCachedMock).toHaveBeenCalledTimes(2); + + const projectBStates = setWorkViewStateSpy.mock.calls + .filter(([projectRoot]) => projectRoot === "/project/b") + .map(([, next]) => ( + typeof next === "function" + ? next(persistedProjectBState) + : { ...persistedProjectBState, ...next } + )); + + expect(projectBStates).not.toContainEqual( + expect.objectContaining({ openItemIds: [] }), + ); + }); }); describe("useWorkSessions β€” grouping defaults and derived tab order", () => { diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 30d4c7fe1..54f10a29a 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -31,6 +31,7 @@ const DEFAULT_PROJECT_WORK_STATE: WorkProjectViewState = { search: "", sessionListOrganization: "by-lane", workCollapsedLaneIds: [], + workCollapsedSectionIds: [], workCollapsedTabGroupIds: [], workFocusSessionsHidden: false, }; @@ -278,6 +279,11 @@ export function useWorkSessions() { const backgroundRefreshTimerRef = useRef(null); const appliedQuerySessionIdRef = useRef(null); const hasLoadedOnceRef = useRef(false); + const projectRootRef = useRef(projectRoot); + + useEffect(() => { + projectRootRef.current = projectRoot; + }, [projectRoot]); const projectViewState = useMemo(() => { if (!projectRoot) return DEFAULT_PROJECT_WORK_STATE; @@ -308,6 +314,7 @@ export function useWorkSessions() { projectViewState.sessionListOrganization ?? "by-lane"; const workCollapsedLaneIds = projectViewState.workCollapsedLaneIds ?? []; const workCollapsedTabGroupIds = projectViewState.workCollapsedTabGroupIds ?? []; + const workCollapsedSectionIds = projectViewState.workCollapsedSectionIds ?? []; const workFocusSessionsHidden = projectViewState.workFocusSessionsHidden ?? false; const sessionsById = useMemo(() => { const map = new Map(); @@ -331,9 +338,7 @@ export function useWorkSessions() { [lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds], ); - const visibleSessions = useMemo(() => { - return openSessions; - }, [openSessions]); + const visibleSessions = openSessions; const tabVisibleSessionIds = tabGroupModel.sessionIds; @@ -378,32 +383,29 @@ export function useWorkSessions() { [setProjectViewState], ); - const toggleWorkLaneCollapsed = useCallback( - (laneId: string) => { - setProjectViewState((prev) => { - const cur = prev.workCollapsedLaneIds ?? []; - const has = cur.includes(laneId); - return { - ...prev, - workCollapsedLaneIds: has ? cur.filter((id) => id !== laneId) : [...cur, laneId], - }; - }); - }, + const makeCollapsedToggle = useCallback( + (key: "workCollapsedLaneIds" | "workCollapsedTabGroupIds" | "workCollapsedSectionIds") => + (itemId: string) => { + setProjectViewState((prev) => { + const cur = prev[key] ?? []; + const has = cur.includes(itemId); + return { ...prev, [key]: has ? cur.filter((id) => id !== itemId) : [...cur, itemId] }; + }); + }, [setProjectViewState], ); - const toggleWorkTabGroupCollapsed = useCallback( - (groupId: string) => { - setProjectViewState((prev) => { - const cur = prev.workCollapsedTabGroupIds ?? []; - const has = cur.includes(groupId); - return { - ...prev, - workCollapsedTabGroupIds: has ? cur.filter((id) => id !== groupId) : [...cur, groupId], - }; - }); - }, - [setProjectViewState], + const toggleWorkLaneCollapsed = useMemo( + () => makeCollapsedToggle("workCollapsedLaneIds"), + [makeCollapsedToggle], + ); + const toggleWorkTabGroupCollapsed = useMemo( + () => makeCollapsedToggle("workCollapsedTabGroupIds"), + [makeCollapsedToggle], + ); + const toggleWorkSectionCollapsed = useMemo( + () => makeCollapsedToggle("workCollapsedSectionIds"), + [makeCollapsedToggle], ); const setWorkFocusSessionsHidden = useCallback( @@ -504,6 +506,12 @@ export function useWorkSessions() { ); const refresh = useCallback(async (options: { showLoading?: boolean; force?: boolean } = {}) => { + const requestedProjectRoot = projectRootRef.current; + if (!requestedProjectRoot) { + setSessions([]); + hasLoadedOnceRef.current = false; + return; + } const showLoading = options.showLoading ?? true; if (refreshInFlightRef.current) { if (refreshQueuedRef.current) { @@ -533,6 +541,9 @@ export function useWorkSessions() { options.force ? { force: true } : undefined, ) ).filter((session) => !isRunOwnedSession(session)); + if (projectRootRef.current !== requestedProjectRoot) { + return; + } setSessions(rows); hasLoadedOnceRef.current = true; } finally { @@ -571,8 +582,16 @@ export function useWorkSessions() { }, [refresh]); useEffect(() => { - refresh({ showLoading: true }).catch(() => {}); - }, [refresh]); + invalidateSessionListCache(); + setSessions([]); + setLoading(false); + refreshQueuedRef.current = null; + hasLoadedOnceRef.current = false; + hasRunningSessionsRef.current = false; + appliedQuerySessionIdRef.current = null; + if (!projectRoot) return; + refresh({ showLoading: true, force: true }).catch(() => {}); + }, [projectRoot, refresh]); useEffect(() => { hasRunningSessionsRef.current = sessions.some((s) => s.status === "running"); @@ -856,7 +875,8 @@ export function useWorkSessions() { setResumingSessionId(session.id); try { const toolType = (session.toolType ?? inferToolFromResumeCommand(command) ?? null) as TerminalToolType | null; - const started = await window.ade.pty.create({ + const resumed = await window.ade.pty.create({ + sessionId: session.id, laneId: session.laneId, cols: 100, rows: 30, @@ -867,15 +887,16 @@ export function useWorkSessions() { ? withCodexNoAltScreen(command) : command, }); + invalidateSessionListCache(); + await refresh({ showLoading: false, force: true }); selectLane(session.laneId); - focusSession(started.sessionId); - setActiveItemId(started.sessionId); - navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(started.sessionId)}`); + focusSession(resumed.sessionId); + setActiveItemId(resumed.sessionId); } finally { setResumingSessionId(null); } }, - [focusSession, navigate, refresh, resumingSessionId, selectLane, setActiveItemId], + [focusSession, refresh, resumingSessionId, selectLane, setActiveItemId], ); const closeChatSession = useCallback( @@ -975,6 +996,8 @@ export function useWorkSessions() { toggleWorkLaneCollapsed, workCollapsedTabGroupIds, toggleWorkTabGroupCollapsed, + workCollapsedSectionIds, + toggleWorkSectionCollapsed, sessionsGroupedByLane, tabGroups: tabGroupModel.groups, tabVisibleSessionIds, diff --git a/apps/desktop/src/renderer/lib/format.test.ts b/apps/desktop/src/renderer/lib/format.test.ts new file mode 100644 index 000000000..1864649b2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import { relativeTimeCompact } from "./format"; + +describe("relativeTimeCompact", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-09T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns empty string for null/undefined input", () => { + expect(relativeTimeCompact(null)).toBe(""); + expect(relativeTimeCompact(undefined)).toBe(""); + }); + + it("returns empty string for invalid ISO string", () => { + expect(relativeTimeCompact("not-a-date")).toBe(""); + }); + + it('returns "now" for timestamps less than 1 minute ago', () => { + expect(relativeTimeCompact("2026-04-09T12:00:00.000Z")).toBe("now"); + expect(relativeTimeCompact("2026-04-09T11:59:30.000Z")).toBe("now"); + }); + + it("returns minutes for timestamps 1–59 minutes ago", () => { + expect(relativeTimeCompact("2026-04-09T11:59:00.000Z")).toBe("1m"); + expect(relativeTimeCompact("2026-04-09T11:30:00.000Z")).toBe("30m"); + expect(relativeTimeCompact("2026-04-09T11:01:00.000Z")).toBe("59m"); + }); + + it("returns hours for timestamps 1–23 hours ago", () => { + expect(relativeTimeCompact("2026-04-09T11:00:00.000Z")).toBe("1h"); + expect(relativeTimeCompact("2026-04-09T00:00:00.000Z")).toBe("12h"); + expect(relativeTimeCompact("2026-04-08T13:00:00.000Z")).toBe("23h"); + }); + + it("returns days for timestamps 24+ hours ago", () => { + expect(relativeTimeCompact("2026-04-08T12:00:00.000Z")).toBe("1d"); + expect(relativeTimeCompact("2026-04-02T12:00:00.000Z")).toBe("7d"); + }); + + it("treats future timestamps as 'now' (delta clamped to 0)", () => { + expect(relativeTimeCompact("2026-04-09T13:00:00.000Z")).toBe("now"); + }); +}); diff --git a/apps/desktop/src/renderer/lib/format.ts b/apps/desktop/src/renderer/lib/format.ts index bb852d64d..1bc8faee6 100644 --- a/apps/desktop/src/renderer/lib/format.ts +++ b/apps/desktop/src/renderer/lib/format.ts @@ -1,5 +1,20 @@ /** Shared formatting utilities for the renderer. */ +/** Returns a compact relative time label (e.g. "now", "2m", "1h", "3d") for sidebar cards. */ +export function relativeTimeCompact(iso: string | null | undefined): string { + if (!iso) return ""; + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return ""; + const delta = Math.max(0, Date.now() - ts); + const mins = Math.floor(delta / 60_000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; +} + /** Returns a human-readable relative time for an ISO timestamp. */ export function relativeWhen(iso: string): string { const ts = Date.parse(iso); diff --git a/apps/desktop/src/renderer/lib/sessionListCache.test.ts b/apps/desktop/src/renderer/lib/sessionListCache.test.ts index 050401fe3..d1657a8c5 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.test.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAppStore } from "../state/appStore"; import { invalidateSessionListCache, listSessionsCached } from "./sessionListCache"; const listMock = vi.fn(); @@ -32,6 +33,9 @@ describe("sessionListCache", () => { beforeEach(() => { invalidateSessionListCache(); listMock.mockReset(); + useAppStore.setState({ + project: { rootPath: "/project/a" } as any, + }); Object.defineProperty(globalThis, "window", { configurable: true, value: { @@ -67,4 +71,25 @@ describe("sessionListCache", () => { expect(full).toHaveLength(10); expect(listMock).toHaveBeenCalledTimes(2); }); + + it("keeps cache entries isolated per active project", async () => { + listMock + .mockResolvedValueOnce(makeRows(3)) + .mockResolvedValueOnce(makeRows(4)); + + const projectARows = await listSessionsCached({ limit: 3 }); + useAppStore.setState({ + project: { rootPath: "/project/b" } as any, + }); + const projectBRows = await listSessionsCached({ limit: 5 }); + useAppStore.setState({ + project: { rootPath: "/project/a" } as any, + }); + const projectARowsAgain = await listSessionsCached({ limit: 3 }); + + expect(projectARows).toHaveLength(3); + expect(projectBRows).toHaveLength(4); + expect(projectARowsAgain).toHaveLength(3); + expect(listMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/desktop/src/renderer/lib/sessionListCache.ts b/apps/desktop/src/renderer/lib/sessionListCache.ts index 7d4a8a96d..cfed0c277 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.ts @@ -1,4 +1,5 @@ import type { ListSessionsArgs, TerminalSessionSummary } from "../../shared/types"; +import { useAppStore } from "../state/appStore"; type CacheEntry = { value: TerminalSessionSummary[] | null; @@ -23,6 +24,7 @@ function normalizeArgs(args?: ListSessionsArgs): ListSessionsArgs { function cacheKey(args?: ListSessionsArgs): string { const normalized = normalizeArgs(args); return JSON.stringify({ + projectRoot: useAppStore.getState().project?.rootPath?.trim() || null, laneId: normalized.laneId ?? null, status: normalized.status ?? null, }); diff --git a/apps/desktop/src/renderer/lib/sessions.test.ts b/apps/desktop/src/renderer/lib/sessions.test.ts new file mode 100644 index 000000000..f72221753 --- /dev/null +++ b/apps/desktop/src/renderer/lib/sessions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { shortToolTypeLabel } from "./sessions"; + +describe("shortToolTypeLabel", () => { + it('returns "Shell" for null, undefined, or "shell"', () => { + expect(shortToolTypeLabel(null)).toBe("Shell"); + expect(shortToolTypeLabel(undefined)).toBe("Shell"); + expect(shortToolTypeLabel("shell")).toBe("Shell"); + }); + + it('returns "Run" for run-shell', () => { + expect(shortToolTypeLabel("run-shell")).toBe("Run"); + }); + + it('returns "Claude" for any claude-prefixed tool type', () => { + expect(shortToolTypeLabel("claude")).toBe("Claude"); + expect(shortToolTypeLabel("claude-chat")).toBe("Claude"); + expect(shortToolTypeLabel("claude-orchestrated")).toBe("Claude"); + }); + + it('returns "Codex" for any codex-prefixed tool type', () => { + expect(shortToolTypeLabel("codex")).toBe("Codex"); + expect(shortToolTypeLabel("codex-chat")).toBe("Codex"); + expect(shortToolTypeLabel("codex-orchestrated")).toBe("Codex"); + }); + + it('returns "OpenCode" for any opencode-prefixed tool type', () => { + expect(shortToolTypeLabel("opencode")).toBe("OpenCode"); + expect(shortToolTypeLabel("opencode-chat")).toBe("OpenCode"); + expect(shortToolTypeLabel("opencode-orchestrated")).toBe("OpenCode"); + }); + + it("returns exact labels for known single-name tools", () => { + expect(shortToolTypeLabel("cursor")).toBe("Cursor"); + expect(shortToolTypeLabel("aider")).toBe("Aider"); + expect(shortToolTypeLabel("continue")).toBe("Continue"); + }); + + it("replaces hyphens with spaces for unknown tool types", () => { + expect(shortToolTypeLabel("my-custom-tool")).toBe("my custom tool"); + }); +}); diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts index f536708a3..da66cb04e 100644 --- a/apps/desktop/src/renderer/lib/sessions.ts +++ b/apps/desktop/src/renderer/lib/sessions.ts @@ -76,6 +76,19 @@ export function buildOptimisticChatSessionSummary(args: { }; } +/** Short tool type label for compact card display (e.g. "Claude", "Shell", "Codex"). */ +export function shortToolTypeLabel(toolType: string | null | undefined): string { + if (!toolType || toolType === "shell") return "Shell"; + if (toolType === "run-shell") return "Run"; + if (toolType.startsWith("claude")) return "Claude"; + if (toolType.startsWith("codex")) return "Codex"; + if (toolType.startsWith("opencode")) return "OpenCode"; + if (toolType === "cursor") return "Cursor"; + if (toolType === "aider") return "Aider"; + if (toolType === "continue") return "Continue"; + return toolType.replace(/-/g, " "); +} + export function formatToolTypeLabel(toolType: string | null | undefined): string { if (toolType === "claude-orchestrated") return "Claude worker runtime"; if (toolType === "codex-orchestrated") return "Codex worker runtime"; diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 8096a0758..78f7c0187 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -36,7 +36,7 @@ const mockLocalStorage = { // Import after window is set up import type { WorkProjectViewState } from "./appStore"; -import { useAppStore, THEME_IDS } from "./appStore"; +import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES } from "./appStore"; // --------------------------------------------------------------------------- // Helpers @@ -55,6 +55,8 @@ function resetStore() { selectedLaneId: null, runLaneId: null, focusedSessionId: null, + theme: "dark", + terminalPreferences: { ...DEFAULT_TERMINAL_PREFERENCES }, laneInspectorTabs: {}, workViewByProject: {}, laneWorkViewByScope: {}, @@ -92,6 +94,44 @@ describe("appStore", () => { }); }); + describe("setTerminalPreferences", () => { + it("updates terminal preferences in state and persists them to localStorage", () => { + useAppStore.getState().setTerminalPreferences({ + fontSize: 14, + lineHeight: 1.3, + scrollback: 20_000, + }); + + expect(useAppStore.getState().terminalPreferences).toEqual({ + fontSize: 14, + lineHeight: 1.3, + scrollback: 20_000, + }); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "ade.terminalPreferences.v1", + JSON.stringify({ + fontSize: 14, + lineHeight: 1.3, + scrollback: 20_000, + }), + ); + }); + + it("clamps invalid terminal preferences to safe bounds", () => { + useAppStore.getState().setTerminalPreferences({ + fontSize: 99, + lineHeight: 0.2, + scrollback: 10, + }); + + expect(useAppStore.getState().terminalPreferences).toEqual({ + fontSize: 18, + lineHeight: 1, + scrollback: 2000, + }); + }); + }); + // ───────────────────────────────────────────────────────────── // Simple setters // ───────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 85ff12c0f..f5f8bf116 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -5,6 +5,16 @@ import { extractError } from "../lib/format"; export type ThemeId = "dark" | "light"; export const THEME_IDS: ThemeId[] = ["dark", "light"]; +export type TerminalPreferences = { + fontSize: number; + lineHeight: number; + scrollback: number; +}; +export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = { + fontSize: 12.5, + lineHeight: 1.25, + scrollback: 10_000, +}; export type TerminalAttentionIndicator = "none" | "running-active" | "running-needs-attention"; export type WorkViewMode = "tabs" | "grid"; export type WorkStatusFilter = "all" | "running" | "awaiting-input" | "ended"; @@ -29,6 +39,8 @@ export type WorkProjectViewState = { workCollapsedLaneIds: string[]; /** Tab group ids collapsed in the Work tab strip. */ workCollapsedTabGroupIds: string[]; + /** Section ids collapsed in status/time sidebar groupings (e.g. "status:running", "time:today"). */ + workCollapsedSectionIds: string[]; /** When true, sessions sidebar is hidden for a full-width content area (persisted per project). */ workFocusSessionsHidden: boolean; }; @@ -53,6 +65,9 @@ const EMPTY_TERMINAL_ATTENTION: TerminalAttentionSnapshot = { byLaneId: {} }; +const WORK_VIEW_STORAGE_KEY = "ade.workViewState.v1"; +const TERMINAL_PREFERENCES_STORAGE_KEY = "ade.terminalPreferences.v1"; + function createDefaultWorkProjectViewState(): WorkProjectViewState { return { openItemIds: [], @@ -66,10 +81,102 @@ function createDefaultWorkProjectViewState(): WorkProjectViewState { sessionListOrganization: "by-lane", workCollapsedLaneIds: [], workCollapsedTabGroupIds: [], + workCollapsedSectionIds: [], workFocusSessionsHidden: false, }; } +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function normalizeOptionalString(value: unknown): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return normalized.length > 0 ? normalized : null; +} + +function normalizeWorkProjectViewState(value: unknown): WorkProjectViewState { + const candidate = value && typeof value === "object" + ? value as Partial + : {}; + return { + openItemIds: normalizeStringArray(candidate.openItemIds), + activeItemId: normalizeOptionalString(candidate.activeItemId), + selectedItemId: normalizeOptionalString(candidate.selectedItemId), + viewMode: candidate.viewMode === "grid" ? "grid" : "tabs", + draftKind: + candidate.draftKind === "cli" || candidate.draftKind === "shell" + ? candidate.draftKind + : "chat", + laneFilter: normalizeOptionalString(candidate.laneFilter) ?? "all", + statusFilter: + candidate.statusFilter === "running" + || candidate.statusFilter === "awaiting-input" + || candidate.statusFilter === "ended" + ? candidate.statusFilter + : "all", + search: typeof candidate.search === "string" ? candidate.search : "", + sessionListOrganization: + candidate.sessionListOrganization === "all-lanes-by-status" + || candidate.sessionListOrganization === "by-time" + ? candidate.sessionListOrganization + : "by-lane", + workCollapsedLaneIds: normalizeStringArray(candidate.workCollapsedLaneIds), + workCollapsedTabGroupIds: normalizeStringArray(candidate.workCollapsedTabGroupIds), + workCollapsedSectionIds: normalizeStringArray(candidate.workCollapsedSectionIds), + workFocusSessionsHidden: candidate.workFocusSessionsHidden === true, + }; +} + +function readPersistedWorkViewState(): { + workViewByProject: Record; + laneWorkViewByScope: Record; +} { + try { + const raw = window.localStorage.getItem(WORK_VIEW_STORAGE_KEY); + if (!raw) { + return { workViewByProject: {}, laneWorkViewByScope: {} }; + } + const parsed = JSON.parse(raw) as { + workViewByProject?: Record; + laneWorkViewByScope?: Record; + }; + const workViewByProject: Record = {}; + const laneWorkViewByScope: Record = {}; + for (const [projectRoot, viewState] of Object.entries(parsed.workViewByProject ?? {})) { + const key = normalizeProjectKey(projectRoot); + if (!key) continue; + workViewByProject[key] = normalizeWorkProjectViewState(viewState); + } + for (const [scopeKey, viewState] of Object.entries(parsed.laneWorkViewByScope ?? {})) { + const dividerIndex = scopeKey.indexOf("::"); + if (dividerIndex <= 0 || dividerIndex >= scopeKey.length - 2) continue; + const projectRoot = normalizeProjectKey(scopeKey.slice(0, dividerIndex)); + const laneId = scopeKey.slice(dividerIndex + 2).trim(); + if (!projectRoot || !laneId) continue; + laneWorkViewByScope[`${projectRoot}::${laneId}`] = normalizeWorkProjectViewState(viewState); + } + return { workViewByProject, laneWorkViewByScope }; + } catch { + return { workViewByProject: {}, laneWorkViewByScope: {} }; + } +} + +function persistWorkViewState(args: { + workViewByProject: Record; + laneWorkViewByScope: Record; +}): void { + try { + window.localStorage.setItem(WORK_VIEW_STORAGE_KEY, JSON.stringify(args)); + } catch { + // ignore + } +} + function normalizeProjectKey(projectRoot: string | null | undefined): string { return typeof projectRoot === "string" ? projectRoot.trim() : ""; } @@ -94,6 +201,8 @@ function readInitialTheme(): ThemeId { return "dark"; } +const initialPersistedWorkViews = readPersistedWorkViewState(); + function persistTheme(theme: ThemeId) { try { window.localStorage.setItem("ade.theme", theme); @@ -102,6 +211,53 @@ function persistTheme(theme: ThemeId) { } } +function clampTerminalFontSize(value: unknown): number { + const next = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(next)) return DEFAULT_TERMINAL_PREFERENCES.fontSize; + return Math.max(10, Math.min(18, Math.round(next * 2) / 2)); +} + +function clampTerminalLineHeight(value: unknown): number { + const next = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(next)) return DEFAULT_TERMINAL_PREFERENCES.lineHeight; + return Math.max(1, Math.min(1.6, Math.round(next * 100) / 100)); +} + +function clampTerminalScrollback(value: unknown): number { + const next = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(next)) return DEFAULT_TERMINAL_PREFERENCES.scrollback; + return Math.max(2000, Math.min(100_000, Math.round(next / 1000) * 1000)); +} + +function normalizeTerminalPreferences(value: unknown): TerminalPreferences { + const candidate = value && typeof value === "object" + ? value as Partial + : {}; + return { + fontSize: clampTerminalFontSize(candidate.fontSize), + lineHeight: clampTerminalLineHeight(candidate.lineHeight), + scrollback: clampTerminalScrollback(candidate.scrollback), + }; +} + +function readInitialTerminalPreferences(): TerminalPreferences { + try { + const raw = window.localStorage.getItem(TERMINAL_PREFERENCES_STORAGE_KEY); + if (!raw) return { ...DEFAULT_TERMINAL_PREFERENCES }; + return normalizeTerminalPreferences(JSON.parse(raw)); + } catch { + return { ...DEFAULT_TERMINAL_PREFERENCES }; + } +} + +function persistTerminalPreferences(preferences: TerminalPreferences) { + try { + window.localStorage.setItem(TERMINAL_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)); + } catch { + // ignore + } +} + type AppState = { project: ProjectInfo | null; projectHydrated: boolean; @@ -122,6 +278,7 @@ type AppState = { runLaneId: string | null; focusedSessionId: string | null; theme: ThemeId; + terminalPreferences: TerminalPreferences; providerMode: ProviderMode; availableModels: ModelDescriptor[]; laneInspectorTabs: Record; @@ -141,6 +298,11 @@ type AppState = { selectRunLane: (laneId: string | null) => void; focusSession: (sessionId: string | null) => void; setTheme: (theme: ThemeId) => void; + setTerminalPreferences: ( + next: + | Partial + | ((prev: TerminalPreferences) => TerminalPreferences) + ) => void; setTerminalAttention: (snapshot: TerminalAttentionSnapshot) => void; getWorkViewState: (projectRoot: string | null | undefined) => WorkProjectViewState; setWorkViewState: ( @@ -245,13 +407,14 @@ export const useAppStore = create((set, get) => ({ runLaneId: null, focusedSessionId: null, theme: readInitialTheme(), + terminalPreferences: readInitialTerminalPreferences(), providerMode: "guest", availableModels: [...MODEL_REGISTRY].filter((m) => !m.deprecated), laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, - workViewByProject: {}, - laneWorkViewByScope: {}, + workViewByProject: initialPersistedWorkViews.workViewByProject, + laneWorkViewByScope: initialPersistedWorkViews.laneWorkViewByScope, setProject: (project) => set({ project }), setProjectHydrated: (projectHydrated) => set({ projectHydrated }), @@ -277,6 +440,16 @@ export const useAppStore = create((set, get) => ({ persistTheme(theme); set({ theme }); }, + setTerminalPreferences: (next) => + set((prev) => { + const updated = normalizeTerminalPreferences( + typeof next === "function" + ? next(prev.terminalPreferences) + : { ...prev.terminalPreferences, ...next } + ); + persistTerminalPreferences(updated); + return { terminalPreferences: updated }; + }), setTerminalAttention: (terminalAttention) => set({ terminalAttention }), openNewTab: () => set({ isNewTabOpen: true, showWelcome: true }), cancelNewTab: () => { @@ -300,11 +473,16 @@ export const useAppStore = create((set, get) => ({ ...current, ...next, }; + const nextWorkViews = { + ...prev.workViewByProject, + [key]: updated, + }; + persistWorkViewState({ + workViewByProject: nextWorkViews, + laneWorkViewByScope: prev.laneWorkViewByScope, + }); return { - workViewByProject: { - ...prev.workViewByProject, - [key]: updated, - }, + workViewByProject: nextWorkViews, }; }); }, @@ -325,11 +503,16 @@ export const useAppStore = create((set, get) => ({ ...current, ...next, }; + const nextLaneWorkViews = { + ...prev.laneWorkViewByScope, + [key]: updated, + }; + persistWorkViewState({ + workViewByProject: prev.workViewByProject, + laneWorkViewByScope: nextLaneWorkViews, + }); return { - laneWorkViewByScope: { - ...prev.laneWorkViewByScope, - [key]: updated, - }, + laneWorkViewByScope: nextLaneWorkViews, }; }); }, @@ -388,6 +571,10 @@ export const useAppStore = create((set, get) => ({ const nextSnapshots: LaneListSnapshot[] = laneSnapshots ?? prev.laneSnapshots.filter((snapshot) => allowed.has(snapshot.lane.id)); + persistWorkViewState({ + workViewByProject: prev.workViewByProject, + laneWorkViewByScope: nextLaneWorkViews, + }); return { laneSnapshots: nextSnapshots, lanes, @@ -552,6 +739,10 @@ export const useAppStore = create((set, get) => ({ const projectKey = scopeKey.split("::")[0]; if (projectKey === activeRoot || recentRoots.has(projectKey)) nextLaneWorkViews[scopeKey] = value; } + persistWorkViewState({ + workViewByProject: nextWorkViews, + laneWorkViewByScope: nextLaneWorkViews, + }); return { projectTransition: null, workViewByProject: nextWorkViews, diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts index 77be95ad9..770c4e219 100644 --- a/apps/desktop/src/shared/types/files.ts +++ b/apps/desktop/src/shared/types/files.ts @@ -77,6 +77,7 @@ export type FilesDeleteArgs = { export type FilesWatchArgs = { workspaceId: string; + includeIgnored?: boolean; }; export type FileChangeEvent = { @@ -91,6 +92,7 @@ export type FilesQuickOpenArgs = { workspaceId: string; query: string; limit?: number; + includeIgnored?: boolean; }; export type FilesQuickOpenItem = { @@ -102,6 +104,7 @@ export type FilesSearchTextArgs = { workspaceId: string; query: string; limit?: number; + includeIgnored?: boolean; }; export type FilesSearchTextMatch = { diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 1479d0f7f..2eeb0a99a 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -86,6 +86,7 @@ export type TerminalSessionDetail = TerminalSessionSummary & { }; export type PtyCreateArgs = { + sessionId?: string; laneId: string; cwd?: string; cols: number; diff --git a/apps/mcp-server/src/mcpServer.test.ts b/apps/mcp-server/src/mcpServer.test.ts index 01d06b8e0..99751c439 100644 --- a/apps/mcp-server/src/mcpServer.test.ts +++ b/apps/mcp-server/src/mcpServer.test.ts @@ -1071,6 +1071,39 @@ describe("mcpServer", () => { ); }); + it("hides ADE spawn and mission-worker tools from standalone chat callers", async () => { + await withEnv({ ADE_DEFAULT_ROLE: "agent", ADE_CHAT_SESSION_ID: "chat-1" }, async () => { + const { runtime } = createRuntime(); + const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "chat-1", role: "agent" }); + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "tools/list" })) as any; + const names = (result.tools ?? []).map((tool: any) => tool.name); + + expect(names).toEqual( + expect.arrayContaining([ + "ask_user", + "memory_search", + "memory_add", + "create_lane", + "run_tests", + ]) + ); + expect(names).not.toEqual( + expect.arrayContaining([ + "spawn_agent", + "delegate_to_subagent", + "delegate_parallel", + "report_status", + "report_result", + "get_worker_output", + "read_mission_status", + "list_workers", + ]) + ); + }); + }); + it("lists CTO operator and Linear sync tools for cto callers", async () => { const { runtime } = createRuntime(); const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); @@ -1381,6 +1414,23 @@ describe("mcpServer", () => { }); }); + it("rejects standalone chat calls to ADE spawn_agent", async () => { + await withEnv({ ADE_DEFAULT_ROLE: "agent", ADE_CHAT_SESSION_ID: "chat-1" }, async () => { + const { runtime } = createRuntime(); + const handler = createMcpRequestHandler({ runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "chat-1", role: "agent" }); + + const response = await callTool(handler, "spawn_agent", { + laneId: "lane-1", + prompt: "Handle a child task.", + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("Unsupported tool: spawn_agent"); + }); + }); + it("lets agent callers delegate nested work only beneath their own worker", async () => { await withEnv({ ADE_RUN_ID: "run-1" }, async () => { const fixture = createRuntime(); diff --git a/apps/mcp-server/src/mcpServer.ts b/apps/mcp-server/src/mcpServer.ts index 1ecaf4368..e526395cf 100644 --- a/apps/mcp-server/src/mcpServer.ts +++ b/apps/mcp-server/src/mcpServer.ts @@ -1538,6 +1538,11 @@ const AGENT_VISIBLE_COORDINATOR_TOOL_SPECS = COORDINATOR_TOOL_SPECS.filter((tool AGENT_VISIBLE_COORDINATOR_TOOL_NAMES.has(tool.name) ); +const STANDALONE_CHAT_HIDDEN_TOOL_NAMES = new Set([ + "spawn_agent", + ...AGENT_VISIBLE_COORDINATOR_TOOL_NAMES, +]); + const CTO_OPERATOR_TOOL_NAMES = new Set(CTO_OPERATOR_TOOL_SPECS.map((tool) => tool.name)); const CTO_LINEAR_SYNC_TOOL_NAMES = new Set(CTO_LINEAR_SYNC_TOOL_SPECS.map((tool) => tool.name)); @@ -2509,8 +2514,21 @@ function toExternalMcpIdentity(callerCtx: CallerContext): { }; } +function isStandaloneChatCaller(callerCtx: CallerContext): boolean { + return Boolean(callerCtx.chatSessionId) + && !callerCtx.missionId + && !callerCtx.runId + && !callerCtx.stepId + && !callerCtx.attemptId; +} + +function isToolHiddenForStandaloneChat(name: string, callerCtx: CallerContext): boolean { + return isStandaloneChatCaller(callerCtx) && STANDALONE_CHAT_HIDDEN_TOOL_NAMES.has(name); +} + function canCallerAccessCoordinatorTool(name: string, callerCtx: CallerContext): boolean { if (!COORDINATOR_TOOL_NAMES.has(name)) return true; + if (isToolHiddenForStandaloneChat(name, callerCtx)) return false; if (callerCtx.role === "orchestrator") return true; if (callerCtx.role === "agent" && AGENT_VISIBLE_COORDINATOR_TOOL_NAMES.has(name)) return true; if ( @@ -2544,19 +2562,23 @@ async function listToolSpecsForSession(runtime: AdeMcpRuntime, session: SessionS const visibleBaseTools = shouldHideLocalComputerUse ? TOOL_SPECS.filter((tool) => !LOCAL_COMPUTER_USE_TOOL_NAMES.has(tool.name)) : TOOL_SPECS; - if (callerCtx.role === "external" || !callerCtx.role) { - return [...visibleBaseTools, ...externalToolSpecs]; - } - if (callerCtx.role === "agent") { - return [...visibleBaseTools, ...AGENT_VISIBLE_COORDINATOR_TOOL_SPECS, ...externalToolSpecs]; - } - if (callerCtx.role === "cto") { - return [...visibleBaseTools, ...CTO_OPERATOR_TOOL_SPECS, ...CTO_LINEAR_SYNC_TOOL_SPECS, ...externalToolSpecs]; - } const visibleCoordinatorTools = shouldHideLocalComputerUse ? COORDINATOR_TOOL_SPECS.filter((tool) => !LOCAL_COMPUTER_USE_TOOL_NAMES.has(tool.name)) : COORDINATOR_TOOL_SPECS; - return [...visibleBaseTools, ...visibleCoordinatorTools, ...externalToolSpecs]; + const allVisibleTools = (() => { + if (callerCtx.role === "external" || !callerCtx.role) { + return [...visibleBaseTools, ...externalToolSpecs]; + } + if (callerCtx.role === "agent") { + return [...visibleBaseTools, ...AGENT_VISIBLE_COORDINATOR_TOOL_SPECS, ...externalToolSpecs]; + } + if (callerCtx.role === "cto") { + return [...visibleBaseTools, ...CTO_OPERATOR_TOOL_SPECS, ...CTO_LINEAR_SYNC_TOOL_SPECS, ...externalToolSpecs]; + } + return [...visibleBaseTools, ...visibleCoordinatorTools, ...externalToolSpecs]; + })(); + + return allVisibleTools.filter((tool) => !isToolHiddenForStandaloneChat(tool.name, callerCtx)); } function parseInitializeIdentity(runtime: AdeMcpRuntime, params: unknown): SessionIdentity { @@ -3566,6 +3588,9 @@ async function runTool(args: { }): Promise { const { runtime, session, name, toolArgs } = args; const callerCtx = await resolveEffectiveCallerContext(runtime, session); + if (isToolHiddenForStandaloneChat(name, callerCtx)) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); + } const runLocalCommand = ( command: string, commandArgs: string[], diff --git a/docs/ORCHESTRATOR_OVERHAUL.md b/docs/ORCHESTRATOR_OVERHAUL.md index 079d62a60..a8dcc7725 100644 --- a/docs/ORCHESTRATOR_OVERHAUL.md +++ b/docs/ORCHESTRATOR_OVERHAUL.md @@ -311,5 +311,6 @@ Applied after Phase 7 close to tighten orchestrator invariants discovered during - Retry budget with exponential backoff prevents infinite retry loops; exhausted retries open recovery interventions. ### MCP Tool Visibility -- MCP tool listing now correctly scopes tools to the coordinator's configured tool set rather than exposing the full internal tool surface. +- MCP tool listing correctly scopes tools to the coordinator's configured tool set rather than exposing the full internal tool surface. - Planning-mode tool profiles restrict write operations during the planning phase. +- Standalone chat sessions (identified by having a `chatSessionId` but no `missionId`, `runId`, `stepId`, or `attemptId`) have `spawn_agent` and all coordinator tools hidden from both tool listing and tool execution. This prevents interactive chat users from accessing orchestration primitives that are only meaningful in mission contexts. diff --git a/docs/architecture/AI_INTEGRATION.md b/docs/architecture/AI_INTEGRATION.md index 9c01f7321..e6a76eead 100644 --- a/docs/architecture/AI_INTEGRATION.md +++ b/docs/architecture/AI_INTEGRATION.md @@ -446,7 +446,8 @@ Every MCP tool invocation passes through a policy engine before execution: - **Read-only tools** (`read_context`, `check_conflicts`, `get_lane_status`, `list_lanes`, `run_tests`): Allowed by default for all authenticated sessions. - **Mutation tools** (`create_lane`, `merge_lane`, `commit_changes`): Require explicit grant from the orchestrator's claim system. An agent must hold an active claim on the relevant lane/scope before a mutation tool will execute. - **Intervention tools** (`ask_user`): Always allowed but rate-limited to prevent intervention flooding. -- **Agent tools** (`spawn_agent`): Restricted to the orchestrator session; individual agents cannot spawn sub-agents without orchestrator approval. +- **Agent tools** (`spawn_agent`): Restricted to the orchestrator session; individual agents cannot spawn sub-agents without orchestrator approval. Additionally hidden from standalone chat sessions (those with a `chatSessionId` but no mission/run/step/attempt context). +- **Standalone chat filtering**: Standalone chat callers have `spawn_agent` and all coordinator tools hidden from both `tools/list` responses and `tools/call` execution. This prevents interactive chat users from invoking orchestration primitives that require mission infrastructure. #### Call Audit Logging diff --git a/docs/features/AGENTS.md b/docs/features/AGENTS.md index c1bed66c9..a05e64c56 100644 --- a/docs/features/AGENTS.md +++ b/docs/features/AGENTS.md @@ -62,6 +62,8 @@ Agent tools are organized into three tiers, each scoped to the appropriate agent This tiering ensures agents have the tools appropriate to their role without exposing orchestrator-level control to regular chat sessions, or workflow actions to headless workers. +Standalone chat sessions (those connected via MCP with a `chatSessionId` but no mission/run/step/attempt context) have an additional restriction: `spawn_agent` and all coordinator tools are hidden from both tool listing and tool execution. This prevents interactive chat users from invoking orchestration primitives that only function within mission infrastructure. + --- ## Configuration Contract diff --git a/docs/features/CHAT.md b/docs/features/CHAT.md index 7a6a3b898..bade5107f 100644 --- a/docs/features/CHAT.md +++ b/docs/features/CHAT.md @@ -348,6 +348,13 @@ When a user switches model families mid-session (e.g., from Claude to Codex), th `persistent_identity` profile, giving it a distinct visual treatment. - **Mission threads** -- Mission-scoped views adapt chat events through `missionThreadEventAdapter` so they render in the mission feed format. +- **Chat terminal drawer** -- The `AgentChatPane` includes a collapsible + terminal drawer (`ChatTerminalDrawer`) at the bottom of the chat + surface. Each drawer tab creates an untracked shell PTY in the current + lane. The drawer reuses the shared `TerminalView` component (with + global terminal preferences) rather than managing raw xterm instances + directly. Tabs track PTY exit state and auto-close the drawer when the + last tab is removed. ### Chat Header diff --git a/docs/features/FILES_AND_EDITOR.md b/docs/features/FILES_AND_EDITOR.md index 02e8a5bfd..e2328b94c 100644 --- a/docs/features/FILES_AND_EDITOR.md +++ b/docs/features/FILES_AND_EDITOR.md @@ -132,7 +132,7 @@ The file explorer is a tree view of files and directories in the selected worksp **Tree behavior**: - Folders are expandable/collapsible with click or arrow keys. - Files and folders are sorted: directories first, then files, both alphabetical. -- Respects `.gitignore` rules β€” ignored files and directories are hidden by default (with a toggle to show them). +- Shows all files including those in `.gitignore` β€” the tree always operates with `includeIgnored: true` so hidden files (dotfiles, `.ade/`, `node_modules/`) are visible without a toggle. - Supports lazy loading for large directories (only fetch children when a folder is expanded). **Visual indicators**: @@ -231,7 +231,7 @@ The Files tab includes several safeguards to prevent accidental data loss: | Service | Status | Responsibility | |---------|--------|---------------| -| `fileService` | Exists | Atomic file writes (write to temp + rename). File tree listing with `.gitignore` support. File watching for external changes. Quick open and cross-file search. | +| `fileService` | Exists | Atomic file writes (write to temp + rename). File tree listing with `.gitignore` support and `includeIgnored` mode. File watching for external changes with ref-counted watcher subscriptions. Quick open and cross-file search with dual-mode indexing. | | `diffService` | Exists | Diff computation for staged vs. unstaged, commit comparisons. Used by diff mode. | ### IPC Channels @@ -239,18 +239,18 @@ The Files tab includes several safeguards to prevent accidental data loss: | Channel | Signature | Status | Description | |---------|-----------|--------|-------------| | `ade.files.listWorkspaces` | `() => WorkspaceInfo[]` | Exists | List available workspaces (primary, lane worktrees, attached) | -| `ade.files.listTree` | `(args: { rootPath: string, depth?: number }) => FileTreeNode[]` | Exists | List directory contents as a tree structure. Respects `.gitignore`. Supports depth limiting for lazy loading. | +| `ade.files.listTree` | `(args: { workspaceId: string, parentPath?: string, depth?: number, includeIgnored?: boolean }) => FileTreeNode[]` | Exists | List directory contents as a tree structure. Respects `.gitignore` unless `includeIgnored` is true. Supports depth limiting for lazy loading and scoped parent-path refreshes. | | `ade.files.readFile` | `(args: { filePath: string, encoding?: string }) => FileContent` | Exists | Read file contents. Returns content, encoding, size, and language ID for syntax highlighting. | | `ade.files.writeTextAtomic` | `(args: { filePath: string, content: string }) => void` | Exists | Atomically write text content to a file. | | `ade.files.writeText` | `(args: { filePath: string, content: string }) => void` | Exists | Write text content to a file (non-atomic). | -| `ade.files.watchChanges` | `(args: { rootPath: string }) => void` | Exists | Start watching a directory for changes. Emits events via `ade.files.change` channel. | -| `ade.files.stopWatching` | `(args: { rootPath: string }) => void` | Exists | Stop watching a directory. | +| `ade.files.watchChanges` | `(args: { workspaceId: string, includeIgnored?: boolean }) => void` | Exists | Start watching a workspace for changes. The `includeIgnored` flag controls whether `.ade/` and `node_modules/` paths are included in watcher events and search indexes. Emits events via `ade.files.change` channel. | +| `ade.files.stopWatching` | `(args: { workspaceId: string, includeIgnored?: boolean }) => void` | Exists | Stop watching a workspace. The `includeIgnored` flag must match the value used when starting the watcher. | | `ade.files.createFile` | `(args: { filePath: string, content?: string }) => void` | Exists | Create a new file. | | `ade.files.createDirectory` | `(args: { dirPath: string }) => void` | Exists | Create a new directory. | | `ade.files.rename` | `(args: { oldPath: string, newPath: string }) => void` | Exists | Rename a file or directory. | | `ade.files.delete` | `(args: { path: string }) => void` | Exists | Delete a file or directory. | -| `ade.files.quickOpen` | `(args: { rootPath: string, query: string }) => FileMatch[]` | Exists | Fuzzy file search for quick open (Cmd+P). | -| `ade.files.searchText` | `(args: { rootPath: string, query: string, options?: SearchOptions }) => SearchResult[]` | Exists | Cross-file text search (Cmd+Shift+F). | +| `ade.files.quickOpen` | `(args: { workspaceId: string, query: string, limit?: number, includeIgnored?: boolean }) => FilesQuickOpenItem[]` | Exists | Fuzzy file search for quick open (Cmd+P). The `includeIgnored` flag controls whether ignored paths are included in results. | +| `ade.files.searchText` | `(args: { workspaceId: string, query: string, limit?: number, includeIgnored?: boolean }) => FilesSearchTextMatch[]` | Exists | Cross-file text search (Cmd+Shift+F). The `includeIgnored` flag controls whether ignored paths are searched. | **File change events** (streamed via `ade.files.change`): - `created`: A new file or directory was created. @@ -284,13 +284,20 @@ interface FileContent { File watching is a performance-sensitive feature that must handle large repositories without excessive resource consumption. **Approach**: -1. Use `chokidar` (or Node.js `fs.watch` with polyfills) for cross-platform file watching. -2. Watch the workspace root recursively, but respect `.gitignore` to exclude irrelevant paths (e.g., `node_modules/`, `dist/`). -3. Debounce events (50ms window) to batch rapid changes (e.g., a build tool writing many files). -4. Only send events to the renderer for files that are either: - - Visible in the expanded file tree, or - - Open in an editor tab. -5. Dispose watchers when the workspace is switched or the Files tab is deactivated. +1. Use `chokidar` for cross-platform recursive file watching. +2. The watcher service supports an `includeIgnored` mode that controls which paths are excluded. In default mode, `.git/`, `node_modules/`, and `.ade/` directories are ignored. In `includeIgnored` mode, only `.git/` is ignored, allowing dotfiles, `node_modules/`, and `.ade/` paths to appear. +3. Debounce events (140ms window) per file key to batch rapid changes (e.g., a build tool writing many files). +4. The watcher service uses reference counting per subscription (`defaultRefCount` and `includeIgnoredRefCount`). Multiple callers sharing a workspace and sender ID share a single chokidar instance. When the last reference is released, the watcher is closed. If the include-ignored state changes due to reference removal, the watcher is restarted with the new ignored-path configuration. +5. Dispose watchers when the workspace is switched, the Files tab is deactivated, or the sender window is closed (`stopAllForSender`). + +**File search index**: +The `fileSearchIndexService` maintains separate indexes per workspace per `includeIgnored` mode, keyed as `workspaceId::default` and `workspaceId::all`. Incremental updates from the watcher propagate to all matching indexes for the workspace. Index invalidation clears both modes for the workspace. + +**External change sync for open tabs**: +When the file watcher detects a modified file that is currently open as a clean (unsaved) tab, the renderer automatically re-reads the file from disk and updates the tab content. This keeps open editors synchronized with external changes (e.g., from terminal commands or other tools) without user intervention. Dirty (unsaved) tabs are never overwritten by external changes. + +**Tree refresh queue**: +File watcher events queue parent-directory refreshes with a cap (`MAX_QUEUED_TREE_PARENT_REFRESHES = 24`). When the queue exceeds this cap, a full tree refresh is triggered instead of individual parent refreshes, preventing excessive incremental refreshes during bulk file operations. **Memory considerations**: - Large repositories may have tens of thousands of files. The file tree service uses lazy loading β€” only fetching children when a directory is expanded β€” to avoid loading the entire tree into memory. diff --git a/docs/features/ONBOARDING_AND_SETTINGS.md b/docs/features/ONBOARDING_AND_SETTINGS.md index 3fdc0314a..783de70ec 100644 --- a/docs/features/ONBOARDING_AND_SETTINGS.md +++ b/docs/features/ONBOARDING_AND_SETTINGS.md @@ -74,7 +74,7 @@ The setup flow now favors completion over forced integration ceremony. Settings owns durable configuration and infrastructure concerns, organized into tabs: -- **General** β€” AI mode, task routing, provider-specific permission policy, config reload, local/shared config boundaries +- **General** β€” AI mode, task routing, provider-specific permission policy, config reload, local/shared config boundaries, terminal preferences (font size, line height, scrollback) - **Context & Docs** β€” context doc management, skill files, deterministic context health, and context doc generation preferences (`ContextDocPrefs`: provider, model, reasoning effort, refresh event triggers). Context doc status updates are pushed from the main process via the `onStatusChanged` callback (preload bridge), replacing the previous polling approach. Each doc shows its health state (`missing`, `incomplete`, `fallback`, `stale`, `ready`) with inline status text and color derived from `describeContextDocHealth`, and its output source (`ai`, `deterministic`, `previous_good`). This is the canonical surface for file-backed skills and legacy command files; ADE indexes them internally for retrieval and dedupe, but they are managed here rather than in the generic Memory browser. Generation controls are inline in the Context section rather than a separate modal. - **Memory** β€” consolidated memory management with two sub-tabs: - *Overview* β€” memory health, scope summaries, promotion status, embedding progress and health monitoring (service state, queue depth, error rates) diff --git a/docs/features/TERMINALS_AND_SESSIONS.md b/docs/features/TERMINALS_AND_SESSIONS.md index 957d740b9..b57c9ab29 100644 --- a/docs/features/TERMINALS_AND_SESSIONS.md +++ b/docs/features/TERMINALS_AND_SESSIONS.md @@ -43,11 +43,11 @@ The grid math module (`packedSessionGridMath.ts`) provides: Each `SessionSurface` receives a `layoutVariant` prop (`"standard"` or `"grid-tile"`) and a `terminalVisible` flag so that terminals in non-visible tiles can skip fit operations. Chat tiles use minimum dimensions of 440x340px; terminal tiles use 320x220px. -The Work tab also supports a single-session focused view and a tab-bar mode with a "New Chat" button in the tab strip for quick session creation. +The Work tab also supports a single-session focused view and a tab-bar mode with a "New Chat" button in the tab strip for quick session creation. The tab bar and grid view share a segmented view-mode toggle (`ViewModeToggle`) with labeled "Tabs" and "Grid" buttons in a pill-shaped container. Grid groups use rounded styling with an active-tab highlight and per-group count badges. ### Shared session-list cache -The renderer now deduplicates repeated `ade.sessions.list` calls through a small shared cache layer. This cache is used by multiple surfaces that previously issued overlapping requests independently. +The renderer deduplicates repeated `ade.sessions.list` calls through a small shared cache layer. The cache key includes the current project root (from `useAppStore`), lane ID, and status filter, so switching projects invalidates stale entries. This cache is used by multiple surfaces that previously issued overlapping requests independently. Current users include: @@ -127,10 +127,20 @@ The lifecycle model remains the same: UI observers subscribe selectively and reuse cached list results where possible. -### Session resume metadata +### Work view state persistence + +The Work tab's per-project view state (open items, active/selected item, view mode, draft kind, filters, organization mode, collapsed lane/section/tab-group IDs, focus-hidden flag) is persisted to `localStorage` under `ade.workViewState.v1`. Lane-scoped view state is stored under the same key with a `projectRoot::laneId` composite key. State is read on app startup and written on every mutation, so the user's sidebar organization, collapsed groups, and active session survive page reloads and app restarts. + +### Session resume and reattach PTY sessions track structured resume metadata via `TerminalResumeMetadata`, which includes the provider (`claude` or `codex`), target kind (`session` or `thread`), target ID, and launch configuration (permission modes for each provider). This metadata enables the "resume" action in the session context menu and the `resumeCommand` field to reconstruct the appropriate CLI invocation for continuing a chat session. +When a user resumes a session, the renderer passes the existing `sessionId` to `pty.create`. The PTY service validates that the session exists, belongs to the requested lane, is tracked, and is not already attached to a live PTY. Instead of creating a new session row, the service calls `sessionService.reattach()` to reset the existing session's status to `running`, clear its end state, and bind it to the new PTY. The transcript file is reopened in append mode so the resumed session's output continues in the same transcript. This keeps the session's identity, lane association, and history intact across resume cycles. + +If the resume target ID is missing from the session's metadata at close time, the PTY service performs a best-effort backfill by scanning the transcript tail for provider-specific session/thread identifiers. The backfill runs after the transcript stream is flushed and finalized, ensuring it reads complete output. + +The resume command is always constructed with the `--resume` (Claude) or `resume` (Codex) flag, even when no target ID is available yet. This allows the CLI to prompt for session selection interactively when the target is unknown. + ### Stale session reconciliation On startup, the session service reconciles stale running sessions via `reconcileStaleRunningSessions()`. This marks orphaned sessions (those still in `running` status from a previous app lifecycle) as `disposed`. The method now accepts an `excludeToolTypes` parameter to skip specific tool types during reconciliation β€” for example, chat sessions may be excluded so they can be resumed rather than force-closed. The exclusion uses normalized tool types and generates a dynamic SQL `NOT IN` clause. @@ -185,6 +195,32 @@ The `TerminalView` component accepts `isActive` and `isVisible` props that contr When a terminal is resized or re-parented (e.g. moved between grid tiles), the fit addon computes new column/row dimensions from the host container. If the computed dimensions are invalid (below `MIN_VALID_COLS = 20` / `MIN_VALID_ROWS = 6`, or the host is smaller than `MIN_HOST_WIDTH_PX = 120` / `MIN_HOST_HEIGHT_PX = 48`), the previous valid dimensions are restored, the fit is retried after a short delay (`INVALID_FIT_RETRY_MS = 90ms`), and the terminal content is refreshed to prevent visual artifacts. Successful retries after an initial invalid fit are counted as `fitRecoveries` in the `TerminalHealthCounters`. The `measureHost` helper uses the maximum of `getBoundingClientRect`, `clientWidth`/`clientHeight`, and `offsetWidth`/`offsetHeight` to handle edge cases where one measurement API returns zero during layout transitions. A `fitWarningLogged` flag prevents log spam when a host remains too small across multiple retry cycles. +## Terminal preferences + +Terminal font size, line height, and scrollback depth are configurable via Settings > General > Terminal. Preferences are persisted to `localStorage` under `ade.terminalPreferences.v1` and applied globally across all terminal surfaces: work terminals, lane shells, resolver terminals, and the chat drawer. + +| Setting | Range | Default | +|---------|-------|---------| +| Font size | 10 -- 18 px (0.5 increments) | 12.5 | +| Line height | 1.0 -- 1.6 | 1.25 | +| Scrollback | 2,000 -- 100,000 lines | 10,000 | + +`TerminalView` reads preferences from `useAppStore` and applies them at runtime creation and on preference change. When preferences change, the runtime updates font family, font size, line height, and scrollback on the xterm instance, clears the texture atlas (to force glyph re-rasterization for WebGL), and re-triggers a fit cycle. The terminal font stack prioritizes platform-native monospace fonts (`ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Cascadia Mono`, `JetBrains Mono`, `Geist Mono`, `monospace`). + +## Session card design + +Each session card in the sidebar renders three rows: + +1. **Status dot + title + relative time** -- The status dot indicates session state (see below). The title uses `primarySessionLabel()`. A compact relative timestamp (`relativeTimeCompact` -- "now", "2m", "1h", "3d") is right-aligned. +2. **Preview line** (conditional) -- Shows the session summary, last output preview (sanitized via `sanitizeTerminalInlineText`), or goal, whichever is available and different from the title. +3. **Tool type + lane + badges** -- A short tool type label (`shortToolTypeLabel` -- "Claude", "Shell", "Codex", etc.), lane marker with name, cache timer badge (Claude sessions), delta chips, and exit code badge. + +The selected card has a left accent border and elevated background. Hover actions (info button, resume button) appear on mouse-over. + +## Session list organization + +The sidebar session list supports three organization modes: **by lane**, **by status** (running / awaiting / ended), and **by time** (today / yesterday / older). Each group uses a collapsible sticky header (`StickyGroupHeader`) with a caret toggle, icon, label, and count badge. Collapsed state is persisted per section ID in `workCollapsedSectionIds` (status/time groups) and `workCollapsedLaneIds` (lane groups) within the work view state. + ## Terminal status indicators Terminal session status dots use distinct visual treatments per state: From 2aa8fed77b8e476fbe9c83e792f3574bda273abf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:00:22 -0400 Subject: [PATCH 2/3] fix: EPIPE handling, MCP security hardening, a11y, debounced persist, test isolation - fileService: handle async EPIPE on child.stdin, git init in test temp dir - fileSearchIndexService: always skip node_modules regardless of includeIgnored - ChatTerminalDrawer: proper ))}
diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 64399991f..e88b19624 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -166,10 +166,10 @@ export const SessionCard = React.memo(function SessionCard({ {/* Hover actions */} -
+