From d71b502bfd81cc61aa3daf2ccecf03a9e82ae824 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:51:01 -0400 Subject: [PATCH 1/3] Add secret-scan CI, memory-files, and chat updates Add gitleaks secret scanning and a dedicated typecheck-web job to CI (plus .gitleaks.toml). Add Shiki to the desktop package and update package-lock.json. Integrate a Project Memory Files service into the desktop main process (createProjectMemoryFilesService): wire it into startup, debounced sync flow, memory briefing, and worker services, and call memoryFilesService.sync() at startup. Rename/align permission handling in a few places by switching usages to unifiedPermissionMode and remove the deprecated permissionMode parameter from CTO operator tools. Large test additions and updates for agent chat covering Codex runtime continuity, Claude SDK session behavior, memory bootstrap/injection, explicit memory capture, session resume/persistence, and other chat-related behaviors. Miscellaneous small IPC/type changes and test scaffolding to support these features. --- .github/workflows/ci.yml | 30 + .gitleaks.toml | 12 + apps/desktop/package-lock.json | 148 ++ apps/desktop/package.json | 1 + apps/desktop/src/main/main.ts | 27 +- .../src/main/services/ai/providerOptions.ts | 3 - .../services/ai/tools/ctoOperatorTools.ts | 4 +- .../services/automations/automationService.ts | 2 +- .../services/chat/agentChatService.test.ts | 610 +++++- .../main/services/chat/agentChatService.ts | 1225 +++++++---- .../services/chat/sessionRecovery.test.ts | 292 +++ .../src/main/services/chat/sessionRecovery.ts | 116 + .../src/main/services/ipc/registerIpc.ts | 86 +- .../services/lanes/autoRebaseService.test.ts | 699 ++++++ .../main/services/lanes/autoRebaseService.ts | 15 +- .../services/lanes/laneEnvironmentService.ts | 29 + .../src/main/services/lanes/laneService.ts | 3 +- .../services/lanes/oauthRedirectService.ts | 15 +- .../services/lanes/portAllocationService.ts | 66 +- .../services/lanes/rebaseSuggestionService.ts | 11 +- .../lanes/runtimeDiagnosticsService.ts | 22 +- .../memory/humanWorkDigestService.test.ts | 115 + .../services/memory/humanWorkDigestService.ts | 27 +- .../memory/memoryBriefingService.test.ts | 119 + .../services/memory/memoryBriefingService.ts | 26 +- .../memory/memoryFilesService.test.ts | 148 ++ .../services/memory/memoryFilesService.ts | 359 +++ .../unifiedOrchestratorAdapter.test.ts | 107 + .../unifiedOrchestratorAdapter.ts | 29 +- .../main/services/prs/prIssueResolver.test.ts | 2 +- .../src/main/services/prs/prIssueResolver.ts | 2 +- .../src/main/services/prs/prRebaseResolver.ts | 2 +- apps/desktop/src/preload/global.d.ts | 1 - apps/desktop/src/preload/preload.ts | 2 - apps/desktop/src/renderer/browserMock.ts | 1 - .../chat/AgentChatComposer.test.tsx | 288 ++- .../components/chat/AgentChatComposer.tsx | 316 ++- .../chat/AgentChatMessageList.test.tsx | 62 +- .../components/chat/AgentChatMessageList.tsx | 702 +++--- .../chat/AgentChatPane.submit.test.tsx | 1 - .../components/chat/AgentChatPane.test.ts | 9 - .../components/chat/AgentChatPane.tsx | 1932 ++++------------- .../components/chat/ChatComposerShell.tsx | 8 +- .../components/chat/ChatContextMeter.tsx | 91 + .../components/chat/ChatSurfaceShell.tsx | 8 +- .../components/chat/ChatTurnDivider.tsx | 90 + .../components/chat/CodeHighlighter.tsx | 245 +++ .../components/chat/chatSurfaceTheme.test.ts | 271 +++ .../components/chat/chatSurfaceTheme.ts | 13 + .../components/chat/chatTranscriptRows.ts | 59 + .../chat/hooks/useAgentChatComposerState.ts | 426 ++++ .../chat/hooks/useAgentChatEvents.ts | 126 ++ .../chat/hooks/useAgentChatSessions.ts | 324 +++ .../components/chat/hooks/useChatDraft.ts | 71 + .../chat/hooks/useChatDraftStore.ts | 60 + .../chat/hooks/useDeriveRuntimeState.ts | 86 + .../src/renderer/components/cto/CtoPage.tsx | 7 +- .../components/lanes/AttachLaneDialog.tsx | 5 +- .../components/lanes/CommitTimeline.tsx | 14 +- .../components/lanes/LaneContextMenu.tsx | 25 +- .../components/lanes/LaneDiffPane.tsx | 46 +- .../components/lanes/LaneEnvInitProgress.tsx | 31 +- .../components/lanes/LaneInspectorPane.tsx | 13 +- .../lanes/LaneOverlayConfigPanel.tsx | 9 +- .../components/lanes/LaneRebaseBanner.tsx | 38 +- .../src/renderer/components/lanes/LaneRow.tsx | 9 +- .../components/lanes/LaneStackPane.tsx | 6 +- .../components/lanes/LaneTerminalsPanel.tsx | 10 +- .../components/lanes/LaneWorkPane.tsx | 10 +- .../renderer/components/lanes/LanesPage.tsx | 21 +- .../components/lanes/ManageLaneDialog.tsx | 11 +- .../components/lanes/MonacoDiffView.tsx | 90 +- .../components/lanes/laneDesignTokens.ts | 48 +- .../components/lanes/laneDialogTokens.ts | 2 +- .../prs/shared/lanePrWarnings.test.ts | 143 ++ .../components/run/AddCommandDialog.tsx | 200 +- .../renderer/components/run/CommandCard.tsx | 10 +- .../components/run/LaneRuntimeBar.tsx | 66 +- .../components/run/ProcessMonitor.tsx | 39 +- .../components/run/RunNetworkPanel.tsx | 70 +- .../src/renderer/components/run/RunPage.tsx | 404 ++-- .../renderer/components/run/RunSidebar.tsx | 18 +- .../settings/ProxyAndPreviewSection.tsx | 64 +- .../shared/UnifiedModelSelector.tsx | 355 +-- apps/desktop/src/renderer/index.css | 16 +- .../src/renderer/lib/integrationLanes.test.ts | 109 + .../src/renderer/lib/integrationLanes.ts | 5 +- .../src/renderer/lib/modelOptions.test.ts | 369 ++++ apps/desktop/src/renderer/lib/modelOptions.ts | 32 +- apps/desktop/src/renderer/lib/sessions.ts | 2 +- apps/desktop/src/renderer/state/appStore.ts | 6 + apps/desktop/src/shared/ipc.ts | 1 - apps/desktop/src/shared/modelRegistry.test.ts | 73 +- apps/desktop/src/shared/modelRegistry.ts | 214 +- apps/desktop/src/shared/types/agents.ts | 3 +- apps/desktop/src/shared/types/chat.ts | 38 +- apps/desktop/src/shared/types/config.ts | 2 + apps/desktop/src/shared/types/cto.ts | 3 +- apps/mcp-server/src/mcpServer.ts | 1 - apps/web/package.json | 2 +- docs/architecture/AI_INTEGRATION.md | 29 +- docs/architecture/MEMORY.md | 12 +- docs/features/CHAT.md | 46 +- docs/features/LANES.md | 14 +- docs/features/MEMORY.md | 11 +- docs/features/PULL_REQUESTS.md | 2 +- 106 files changed, 9080 insertions(+), 3218 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 apps/desktop/src/main/services/chat/sessionRecovery.test.ts create mode 100644 apps/desktop/src/main/services/chat/sessionRecovery.ts create mode 100644 apps/desktop/src/main/services/lanes/autoRebaseService.test.ts create mode 100644 apps/desktop/src/main/services/memory/humanWorkDigestService.test.ts create mode 100644 apps/desktop/src/main/services/memory/memoryBriefingService.test.ts create mode 100644 apps/desktop/src/main/services/memory/memoryFilesService.test.ts create mode 100644 apps/desktop/src/main/services/memory/memoryFilesService.ts create mode 100644 apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx create mode 100644 apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx create mode 100644 apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/hooks/useAgentChatComposerState.ts create mode 100644 apps/desktop/src/renderer/components/chat/hooks/useAgentChatEvents.ts create mode 100644 apps/desktop/src/renderer/components/chat/hooks/useAgentChatSessions.ts create mode 100644 apps/desktop/src/renderer/components/chat/hooks/useChatDraft.ts create mode 100644 apps/desktop/src/renderer/components/chat/hooks/useChatDraftStore.ts create mode 100644 apps/desktop/src/renderer/components/chat/hooks/useDeriveRuntimeState.ts create mode 100644 apps/desktop/src/renderer/lib/modelOptions.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3865c6d8..b04128c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,17 @@ jobs: cd apps/web && npm ci & wait + # ── Secret scanning (no deps needed) ─────────────────────────────────── + secret-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # ── Stage 2: Parallel checks ────────────────────────────────────────── typecheck-desktop: needs: install @@ -72,6 +83,23 @@ jobs: key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/mcp-server && npm run typecheck + typecheck-web: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/web && npm run typecheck + lint-desktop: needs: install runs-on: ubuntu-latest @@ -167,8 +195,10 @@ jobs: ci-pass: if: always() needs: + - secret-scan - typecheck-desktop - typecheck-mcp + - typecheck-web - lint-desktop - test-desktop - test-mcp diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..fcf083258 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,12 @@ +[extend] +# Use the default gitleaks ruleset +useDefault = true + +[allowlist] +paths = [ + '''\.env\.example$''', + '''node_modules/''', + '''dist/''', + '''release/''', + '''\.git/''', +] diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index bb7065f1c..7e80b755e 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -48,6 +48,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "shiki": "^3.23.0", "sql.js": "^1.13.0", "tailwind-merge": "^3.4.0", "ws": "^8.19.0", @@ -3874,6 +3875,73 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -9113,6 +9181,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -12004,6 +12095,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/onnxruntime-common": { "version": "1.24.3", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", @@ -13162,6 +13270,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -13761,6 +13893,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d04d1ff1e..8b952056e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -78,6 +78,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "shiki": "^3.23.0", "sql.js": "^1.13.0", "tailwind-merge": "^3.4.0", "ws": "^8.19.0", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 4e194d1ca..a2a35fb8f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -63,6 +63,7 @@ import { createEmbeddingService } from "./services/memory/embeddingService"; import { createEmbeddingWorkerService } from "./services/memory/embeddingWorkerService"; import { createHybridSearchService } from "./services/memory/hybridSearchService"; import { createUnifiedMemoryService } from "./services/memory/unifiedMemoryService"; +import { createProjectMemoryFilesService } from "./services/memory/memoryFilesService"; import { createMemoryLifecycleService } from "./services/memory/memoryLifecycleService"; import { createMemoryBriefingService } from "./services/memory/memoryBriefingService"; import { createMissionMemoryLifecycleService } from "./services/memory/missionMemoryLifecycleService"; @@ -410,7 +411,7 @@ async function createWindow(logger?: Logger): Promise { await win.loadURL(`data:text/html;charset=UTF-8,${fallbackHtml}`); } - if (process.env.VITE_DEV_SERVER_URL) { + if (process.env.VITE_DEV_SERVER_URL && !process.env.NO_DEVTOOLS) { win.webContents.openDevTools({ mode: "detach" }); } @@ -965,10 +966,22 @@ app.whenReady().then(async () => { logger, }); let ctoStateServiceRef: ReturnType | null = null; + let memoryFilesServiceRef: ReturnType | null = null; let syncMemoryDocsTimer: ReturnType | null = null; const debouncedSyncMemoryDocs = () => { if (syncMemoryDocsTimer) clearTimeout(syncMemoryDocsTimer); - syncMemoryDocsTimer = setTimeout(() => { ctoStateServiceRef?.syncDerivedMemoryDocs(); }, 2_000); + syncMemoryDocsTimer = setTimeout(() => { + try { + ctoStateServiceRef?.syncDerivedMemoryDocs(); + } catch { + // Ignore best-effort generated doc sync errors. + } + try { + memoryFilesServiceRef?.sync(); + } catch { + // Ignore best-effort generated memory file sync errors. + } + }, 2_000); }; const memoryService = createUnifiedMemoryService(db, { hybridSearchService, @@ -982,6 +995,12 @@ app.whenReady().then(async () => { } }, }); + const memoryFilesService = createProjectMemoryFilesService({ + projectRoot, + projectId, + memoryService, + }); + memoryFilesServiceRef = memoryFilesService; const compactionFlushService = createCompactionFlushService(undefined, { logger }); aiIntegrationService.setCompactionFlushService(compactionFlushService); const batchConsolidationService = createBatchConsolidationService({ @@ -1015,6 +1034,7 @@ app.whenReady().then(async () => { embeddingWorkerServiceRef = embeddingWorkerService; const memoryBriefingService = createMemoryBriefingService({ memoryService, + memoryFilesService, projectRoot, humanWorkDigestService: { getRecentCommitSummaries: async (count?: number) => { @@ -1058,7 +1078,6 @@ app.whenReady().then(async () => { projectId, projectRoot, logger, - memoryService, }); const skillRegistryService = createSkillRegistryService({ db, @@ -1088,6 +1107,7 @@ app.whenReady().then(async () => { memoryService, }); ctoStateServiceRef = ctoStateService; + memoryFilesService.sync(); const workerAgentService = createWorkerAgentService({ db, @@ -1213,6 +1233,7 @@ app.whenReady().then(async () => { transcriptsDir: adePaths.transcriptsDir, projectId, memoryService, + memoryFilesService, fileService, workerAgentService, workerHeartbeatService, diff --git a/apps/desktop/src/main/services/ai/providerOptions.ts b/apps/desktop/src/main/services/ai/providerOptions.ts index a3b927d57..0c7a5ed2e 100644 --- a/apps/desktop/src/main/services/ai/providerOptions.ts +++ b/apps/desktop/src/main/services/ai/providerOptions.ts @@ -49,8 +49,6 @@ export function buildProviderOptions( }, }; - case "groq": - case "together": case "xai": return { [descriptor.family]: { reasoningEffort: tier }, @@ -67,7 +65,6 @@ export function buildProviderOptions( case "ollama": case "mistral": - case "meta": // No reasoning config needed. return {}; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index cc6d46ac4..49ff07a0e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -442,12 +442,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + execute: async ({ laneId, modelId, reasoningEffort, title, initialPrompt, openInUi }) => { try { const selectedModelId = modelId?.trim() || deps.defaultModelId || null; const resolved = deriveChatProvider({ modelId: selectedModelId }); @@ -457,7 +456,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { vi.mock("node:child_process", () => ({ spawn: vi.fn(() => { const proc: any = { - stdin: { write: vi.fn(), end: vi.fn() }, + stdin: { write: vi.fn(), end: vi.fn(), writable: true }, stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, on: vi.fn(), @@ -166,7 +169,7 @@ import { detectAllAuth } from "../ai/authDetector"; import * as providerResolver from "../ai/providerResolver"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; -import type { ComputerUseBackendStatus, AgentChatProvider } from "../../../shared/types"; +import type { ComputerUseBackendStatus } from "../../../shared/types"; // --------------------------------------------------------------------------- // Helpers @@ -245,6 +248,12 @@ function createMockSessionService() { if (args.resumeCommand !== undefined) row.resumeCommand = args.resumeCommand; } }), + setResumeCommand: vi.fn((sessionId: string, resumeCommand: string | null) => { + const row = sessions.get(sessionId); + if (row) { + row.resumeCommand = resumeCommand; + } + }), setHeadShaStart: vi.fn(), setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), @@ -252,6 +261,57 @@ function createMockSessionService() { } as any; } +async function flushPromises(iterations = 4) { + for (let index = 0; index < iterations; index += 1) { + await Promise.resolve(); + } +} + +function getLatestSpawnProc() { + const proc = vi.mocked(spawn).mock.results.at(-1)?.value as any; + expect(proc).toBeTruthy(); + return proc; +} + +function getLatestReader() { + const reader = vi.mocked(readline.createInterface).mock.results.at(-1)?.value as any; + expect(reader).toBeTruthy(); + return reader; +} + +function getReaderLineHandler(reader: any): (line: string) => void { + const lineCall = reader.on.mock.calls.find(([event]: [string]) => event === "line"); + expect(lineCall).toBeTruthy(); + return lineCall[1]; +} + +function writtenPayloads(proc: any): Array> { + return proc.stdin.write.mock.calls.map(([payload]: [string]) => JSON.parse(String(payload).trim())); +} + +async function waitForWrittenMethod(proc: any, method: string) { + return waitForWrittenMethodCount(proc, method, 1); +} + +async function waitForWrittenMethodCount(proc: any, method: string, count: number) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const payloads = writtenPayloads(proc).filter((entry) => entry.method === method); + if (payloads.length >= count) return payloads[count - 1]; + await flushPromises(); + } + throw new Error(`Timed out waiting for request '${method}'. Saw methods: ${writtenPayloads(proc).map((entry) => entry.method).join(", ")}`); +} + +async function completeCodexInitialize(proc: any, lineHandler: (line: string) => void) { + const initialize = await waitForWrittenMethod(proc, "initialize"); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: initialize.id, + result: {}, + })); + await flushPromises(); +} + function createMockProjectConfigService() { return { get: vi.fn(() => ({ @@ -307,6 +367,8 @@ beforeEach(() => { mockState.uuidCounter = 0; vi.mocked(streamText).mockReset(); vi.mocked(generateText).mockReset(); + vi.mocked(unstable_v2_createSession).mockReset(); + vi.mocked(unstable_v2_resumeSession).mockReset(); vi.mocked(detectAllAuth).mockResolvedValue([]); vi.mocked(providerResolver.resolveModel).mockResolvedValue({} as any); vi.mocked(parseAgentChatTranscript).mockReturnValue([]); @@ -436,7 +498,6 @@ describe("createAgentChatService", () => { expect(service.disposeAll).toBeTypeOf("function"); expect(service.updateSession).toBeTypeOf("function"); expect(service.warmupModel).toBeTypeOf("function"); - expect(service.changePermissionMode).toBeTypeOf("function"); expect(service.listSubagents).toBeTypeOf("function"); expect(service.getSessionCapabilities).toBeTypeOf("function"); expect(service.cleanupStaleAttachments).toBeTypeOf("function"); @@ -479,6 +540,18 @@ describe("createAgentChatService", () => { expect(session.status).toBe("idle"); }); + it("stores the real runtime model name for Codex GPT-5.4 sessions", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + expect(session.modelId).toBe("openai/gpt-5.4-codex"); + expect(session.model).toBe("gpt-5.4"); + }); + it("sets sessionProfile to workflow by default", async () => { const { service } = createService(); const session = await service.createSession({ @@ -788,6 +861,54 @@ describe("createAgentChatService", () => { ); }); + it("skips memory search for trivial test pings", async () => { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; + })(), + } as any); + + const memoryService = { + search: vi.fn(async () => []), + } as any; + const onEvent = vi.fn(); + const { service } = createService({ + memoryService, + onEvent, + computerUseArtifactBrokerService: { + getBackendStatus: vi.fn(() => ({ + backends: [], + localFallback: { + available: false, + detail: "disabled", + supportedKinds: [], + }, + })), + } as any, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "this is a test", + }); + + expect(memoryService.search).not.toHaveBeenCalled(); + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "memory", + }), + }), + ); + }); + it("checks memory and emits a memory notice for coding turns", async () => { vi.mocked(streamText).mockReturnValue({ fullStream: (async function* () { @@ -844,7 +965,221 @@ describe("createAgentChatService", () => { event: expect.objectContaining({ type: "system_notice", noticeKind: "memory", - message: expect.stringContaining("Checked memory"), + message: expect.stringContaining("Memory:"), + }), + }), + ); + }); + + it("loads bootstrap memory for non-trivial arbitrary turns even without targeted search", async () => { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 2, outputTokens: 2 } }; + })(), + } as any); + + const memoryService = { + search: vi.fn(async () => []), + } as any; + const memoryFilesService = { + buildPromptContext: vi.fn(() => ({ + text: "ADE auto memory bootstrap (generated from promoted project memory):\n- Decision: keep SQLite as the canonical store.", + bootstrapLoaded: true, + topicFilesLoaded: [], + })), + } as any; + const onEvent = vi.fn(); + const { service } = createService({ + memoryService, + memoryFilesService, + onEvent, + computerUseArtifactBrokerService: { + getBackendStatus: vi.fn(() => ({ + backends: [], + localFallback: { + available: false, + detail: "disabled", + supportedKinds: [], + }, + })), + } as any, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Take a look at the release flow and tell me what stands out.", + }); + + expect(memoryFilesService.buildPromptContext).toHaveBeenCalled(); + expect(memoryService.search).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "memory", + message: expect.stringContaining("loaded bootstrap"), + }), + }), + ); + }); + + it("injects generated auto memory bootstrap into coding turns", async () => { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 2, outputTokens: 2 } }; + })(), + } as any); + + const memoryService = { + search: vi.fn(async () => []), + } as any; + const memoryFilesService = { + buildPromptContext: vi.fn(() => ({ + text: "ADE auto memory bootstrap (generated from promoted project memory):\n- Convention: keep SQLite as the memory source of truth.", + bootstrapLoaded: true, + topicFilesLoaded: ["conventions.md"], + })), + } as any; + const onEvent = vi.fn(); + const { service } = createService({ + memoryService, + memoryFilesService, + onEvent, + computerUseArtifactBrokerService: { + getBackendStatus: vi.fn(() => ({ + backends: [], + localFallback: { + available: false, + detail: "disabled", + supportedKinds: [], + }, + })), + } as any, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Fix the failing memory tests in the desktop app.", + }); + + expect(memoryFilesService.buildPromptContext).toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "memory", + detail: expect.objectContaining({ + sections: expect.arrayContaining([ + expect.objectContaining({ + title: "Auto memory files", + items: expect.arrayContaining([ + expect.stringContaining(".ade/memory/MEMORY.md"), + expect.stringContaining("conventions.md"), + ]), + }), + ]), + }), + }), + }), + ); + }); + + it("captures explicit user instructions into memory", async () => { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 2, outputTokens: 1 } }; + })(), + } as any); + + const savedMemory = { + id: "memory-saved-1", + projectId: "test-project", + scope: "project", + scopeOwnerId: null, + tier: 2, + category: "convention", + content: "Convention: we always use pnpm, not npm, in this repo.", + importance: "high", + sourceSessionId: "test-uuid-1", + sourcePackKey: null, + createdAt: "2026-03-25T10:00:00.000Z", + updatedAt: "2026-03-25T10:00:00.000Z", + lastAccessedAt: "2026-03-25T10:00:00.000Z", + accessCount: 0, + observationCount: 0, + status: "promoted", + agentId: "test-uuid-1", + confidence: 1, + promotedAt: "2026-03-25T10:00:00.000Z", + sourceRunId: null, + sourceType: "user", + sourceId: "chat:auto-capture", + fileScopePattern: null, + pinned: false, + accessScore: 0, + compositeScore: 0.9, + writeGateReason: null, + }; + const memoryService = { + search: vi.fn(async () => []), + writeMemory: vi.fn(() => ({ + accepted: true, + memory: savedMemory, + deduped: false, + })), + } as any; + const onEvent = vi.fn(); + const { service } = createService({ + memoryService, + onEvent, + computerUseArtifactBrokerService: { + getBackendStatus: vi.fn(() => ({ + backends: [], + localFallback: { + available: false, + detail: "disabled", + supportedKinds: [], + }, + })), + } as any, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Please remember we always use pnpm, not npm, in this repo.", + }); + + expect(memoryService.writeMemory).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "project", + category: "convention", + sourceType: "user", + }), + ); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "memory", + message: expect.stringContaining("Captured explicit user instruction"), }), }), ); @@ -1205,10 +1540,27 @@ describe("createAgentChatService", () => { const updated = await service.updateSession({ sessionId: session.id, - permissionMode: "full-auto", + unifiedPermissionMode: "full-auto", + }); + + expect(updated.unifiedPermissionMode).toBe("full-auto"); + }); + + it("keeps the Codex wrapper id while updating the runtime model name", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + const updated = await service.updateSession({ + sessionId: session.id, + modelId: "openai/gpt-5.4-codex", }); - expect(updated.permissionMode).toBe("full-auto"); + expect(updated.modelId).toBe("openai/gpt-5.4-codex"); + expect(updated.model).toBe("gpt-5.4"); }); it("updates computer use policy", async () => { @@ -1232,41 +1584,220 @@ describe("createAgentChatService", () => { expect(updated.computerUse!.mode).toBe("enabled"); }); - }); - // -------------------------------------------------------------------------- - // changePermissionMode - // -------------------------------------------------------------------------- + it("resets an idle Claude SDK session when permission mode changes", async () => { + const close = vi.fn(); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(async () => {}), + stream: vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-session-1" } as any; + yield { type: "result" } as any; + })()), + close, + sessionId: "sdk-session-1", + } as any); - describe("changePermissionMode", () => { - it("changes the permission mode on a session", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", - provider: "unified", - model: "", - modelId: "anthropic/claude-sonnet-4-6-api", + provider: "claude", + model: "sonnet", }); - service.changePermissionMode({ + await flushPromises(8); + + await service.updateSession({ sessionId: session.id, - permissionMode: "full-auto", + claudePermissionMode: "bypassPermissions", }); - // Verify by getting summary - const summary = await service.getSessionSummary(session.id); - expect(summary).not.toBeNull(); - expect(summary!.provider).toBe("unified"); + expect(close).toHaveBeenCalled(); }); - it("throws for unknown session id", () => { + it("rebinds the current Codex thread on the next turn after settings change", async () => { const { service } = createService(); - expect(() => - service.changePermissionMode({ - sessionId: "nonexistent-session", - permissionMode: "plan", - }), - ).toThrow(/not found/i); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + const persistedPath = path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`); + const persisted = JSON.parse(fs.readFileSync(persistedPath, "utf8")); + persisted.threadId = "thread-codex-1"; + fs.writeFileSync(persistedPath, JSON.stringify(persisted, null, 2), "utf8"); + + const resumePromise = service.resumeSession({ sessionId: session.id }); + const proc = getLatestSpawnProc(); + const reader = getLatestReader(); + const lineHandler = getReaderLineHandler(reader); + await completeCodexInitialize(proc, lineHandler); + const initialThreadResume = await waitForWrittenMethodCount(proc, "thread/resume", 1); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: initialThreadResume.id, + result: {}, + })); + await resumePromise; + + await service.updateSession({ + sessionId: session.id, + codexSandbox: "danger-full-access", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "second turn", + }); + + await flushPromises(); + const threadResume = await waitForWrittenMethodCount(proc, "thread/resume", 2); + expect(threadResume.params.threadId).toBe("thread-codex-1"); + expect(threadResume.params.sandbox).toBe("danger-full-access"); + + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: threadResume.id, + result: {}, + })); + await flushPromises(); + + const secondTurnStart = await waitForWrittenMethodCount(proc, "turn/start", 1); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: secondTurnStart.id, + result: { turn: { id: "turn-2" } }, + })); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-2", status: "completed" } }, + })); + await flushPromises(8); + }); + }); + + describe("codex runtime continuity", () => { + it("captures thread identity from thread/started notifications", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + const turnPromise = service.runSessionTurn({ + sessionId: session.id, + text: "hello codex", + timeoutMs: 15_000, + }); + + await flushPromises(); + const proc = getLatestSpawnProc(); + const reader = getLatestReader(); + const lineHandler = getReaderLineHandler(reader); + await completeCodexInitialize(proc, lineHandler); + + const threadStart = await waitForWrittenMethod(proc, "thread/start"); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: threadStart.id, + result: {}, + })); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + method: "thread/started", + params: { thread: { id: "thread-from-notification" } }, + })); + + await flushPromises(); + const turnStart = await waitForWrittenMethodCount(proc, "turn/start", 1); + + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: turnStart.id, + result: { turn: { id: "turn-notify-1" } }, + })); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-notify-1", status: "completed" } }, + })); + + const result = await turnPromise; + expect(result.threadId).toBe("thread-from-notification"); + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + session.id, + "chat:codex:thread-from-notification", + ); + + const persisted = JSON.parse( + fs.readFileSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`), "utf8"), + ); + expect(persisted.threadId).toBe("thread-from-notification"); + }); + + it("captures thread identity from nested raw Codex agent-message payloads", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + const firstTurnPromise = service.runSessionTurn({ + sessionId: session.id, + text: "hello codex", + timeoutMs: 15_000, + }); + + await flushPromises(); + const proc = getLatestSpawnProc(); + const reader = getLatestReader(); + const lineHandler = getReaderLineHandler(reader); + await completeCodexInitialize(proc, lineHandler); + + const threadStart = await waitForWrittenMethod(proc, "thread/start"); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: threadStart.id, + result: {}, + })); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + method: "codex/event/agent_message_content_delta", + params: { + msg: { + id: "agent-message-1", + conversationId: "thread-from-raw-msg", + content: "hello from nested raw event", + }, + }, + })); + + await flushPromises(); + const firstTurnStart = await waitForWrittenMethodCount(proc, "turn/start", 1); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: firstTurnStart.id, + result: { turn: { id: "turn-raw-1" } }, + })); + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-raw-1", status: "completed" } }, + })); + + const firstResult = await firstTurnPromise; + expect(firstResult.threadId).toBe("thread-from-raw-msg"); + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + session.id, + "chat:codex:thread-from-raw-msg", + ); + const persisted = JSON.parse( + fs.readFileSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`), "utf8"), + ); + expect(persisted.threadId).toBe("thread-from-raw-msg"); }); }); @@ -1441,7 +1972,28 @@ describe("createAgentChatService", () => { it("returns an array for codex provider", async () => { const { service } = createService(); - const models = await service.getAvailableModels({ provider: "codex" }); + const modelsPromise = service.getAvailableModels({ provider: "codex" }); + await flushPromises(); + + const proc = getLatestSpawnProc(); + const reader = getLatestReader(); + const lineHandler = getReaderLineHandler(reader); + await completeCodexInitialize(proc, lineHandler); + const modelList = await waitForWrittenMethod(proc, "model/list"); + + lineHandler(JSON.stringify({ + jsonrpc: "2.0", + id: modelList.id, + result: { + data: [{ + id: "openai/gpt-5.4-codex", + displayName: "GPT-5.4 Codex", + isDefault: true, + }], + }, + })); + + const models = await modelsPromise; expect(Array.isArray(models)).toBe(true); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c8a199c77..9aaef1105 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -29,6 +29,17 @@ import { shouldFlushBufferedAssistantTextForEvent, type BufferedAssistantText, } from "./chatTextBatching"; +import { + createRecoveryState, + canAttemptRecovery, + getRecoveryBackoffMs, + markRecoveryAttempt, + markRecoveryComplete, + markRecoverySuccess, + isRecoverableError, + createRecoveryNoticeEvent, + type RecoveryState, +} from "./sessionRecovery"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createSessionService } from "../sessions/sessionService"; @@ -52,6 +63,7 @@ import type { AgentChatCodexConfigSource, AgentChatCodexSandbox, AgentChatCreateArgs, + AgentChatNoticeDetail, AgentChatDisposeArgs, AgentChatExecutionMode, AgentChatEvent, @@ -84,12 +96,15 @@ import type { CtoCapabilityMode, } from "../../../shared/types"; import { + getRuntimeModelRefForDescriptor, getDefaultModelDescriptor, getModelById, getAvailableModels as getRegistryModels, + isModelProviderGroup, listModelDescriptorsForProvider, MODEL_REGISTRY, resolveModelAlias, + resolveModelDescriptorForProvider, resolveProviderGroupForModel, type ModelDescriptor, } from "../../../shared/modelRegistry"; @@ -110,7 +125,15 @@ import { } from "../ai/providerRuntimeHealth"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import type { createMemoryService, Memory } from "../memory/memoryService"; +import type { + createMemoryService, + Memory, + MemoryCategory, + MemoryImportance, + WriteMemoryResult, +} from "../memory/memoryService"; +import { resolveAgentMemoryWritePolicy } from "../memory/unifiedMemoryService"; +import type { ProjectMemoryFilesService } from "../memory/memoryFilesService"; import type { createCtoStateService } from "../cto/ctoStateService"; import type { createWorkerAgentService } from "../cto/workerAgentService"; import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; @@ -164,7 +187,6 @@ type PersistedChatState = { codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; unifiedPermissionMode?: AgentChatUnifiedPermissionMode; - permissionMode?: AgentChatSession["permissionMode"]; identityKey?: AgentChatIdentityKey; surface?: AgentChatSurface; automationId?: string | null; @@ -208,6 +230,8 @@ type CodexRuntime = { activeTurnId: string | null; startedTurnId: string | null; threadResumed: boolean; + pendingThreadRebind: boolean; + threadIdWaiters: Set<(threadId?: string) => void>; itemTurnIdByItemId: Map; commandOutputByItemId: Map; fileDeltaByItemId: Map; @@ -225,7 +249,7 @@ type ClaudeRuntime = { sdkSessionId: string | null; activeQuery: import("@anthropic-ai/claude-agent-sdk").Query | null; v2Session: ClaudeV2Session | null; - /** Single stream generator kept alive across turns (never closed by for-await). */ + /** Active V2 stream generator for the current turn. */ v2StreamGen: AsyncGenerator | null; /** Resolves when the subprocess is initialized (system:init received). */ v2WarmupDone: Promise | null; @@ -240,7 +264,7 @@ type ClaudeRuntime = { pendingSteers: string[]; approvals: Map; interrupted: boolean; - /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ + /** Set when a V2 session setting changes mid-turn; flushed when idle. */ pendingSessionReset?: boolean; turnMemoryPolicyState: TurnMemoryPolicyState | null; }; @@ -274,7 +298,8 @@ function asRecord(value: unknown): Record | null { : null; } -function pickCodexTurnId(...values: unknown[]): string | undefined { +/** Pick the first non-empty trimmed string from a list of unknowns. Used for turn, thread, and item IDs. */ +function pickCodexStringId(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value !== "string") continue; const trimmed = value.trim(); @@ -283,11 +308,105 @@ function pickCodexTurnId(...values: unknown[]): string | undefined { return undefined; } +function pickCodexText(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + if (value.length > 0) return value; + } + return undefined; +} + +function collectCodexPayloadRecords(value: unknown): Array> { + const records: Array> = []; + const queue: unknown[] = [value]; + const seen = new Set>(); + + while (queue.length > 0) { + const next = queue.shift(); + const record = asRecord(next); + if (!record || seen.has(record)) continue; + seen.add(record); + records.push(record); + + for (const key of ["msg", "payload", "data", "event", "item", "turn", "thread"]) { + const nested = asRecord(record[key]); + if (nested && !seen.has(nested)) { + queue.push(nested); + } + } + } + + return records; +} + function extractCodexTurnId(value: unknown): string | undefined { - const record = asRecord(value); - if (!record) return undefined; - const nestedTurn = asRecord(record.turn); - return pickCodexTurnId(record.turnId, record.turn_id, nestedTurn?.id); + for (const record of collectCodexPayloadRecords(value)) { + const nestedTurn = asRecord(record.turn); + const nestedItem = asRecord(record.item); + const turnId = pickCodexStringId( + record.turnId, + record.turn_id, + nestedTurn?.id, + nestedTurn?.turnId, + nestedTurn?.turn_id, + nestedItem?.turnId, + nestedItem?.turn_id, + ); + if (turnId) return turnId; + } + return undefined; +} + +function extractCodexThreadId(value: unknown): string | undefined { + for (const record of collectCodexPayloadRecords(value)) { + const nestedThread = asRecord(record.thread); + const threadId = pickCodexStringId( + record.threadId, + record.thread_id, + record.conversationId, + nestedThread?.id, + nestedThread?.threadId, + nestedThread?.thread_id, + ); + if (threadId) return threadId; + } + return undefined; +} + +function extractCodexItemId(value: unknown): string | undefined { + for (const record of collectCodexPayloadRecords(value)) { + const nestedItem = asRecord(record.item); + const itemId = pickCodexStringId( + record.itemId, + record.item_id, + nestedItem?.id, + nestedItem?.itemId, + nestedItem?.item_id, + ); + if (itemId) return itemId; + } + return undefined; +} + +function extractCodexTextPayload(value: unknown): string | undefined { + for (const record of collectCodexPayloadRecords(value)) { + const text = pickCodexText( + record.delta, + record.text, + record.content, + record.message, + ); + if (text) return text; + } + return undefined; +} + +function shiftPendingSteer(queue: string[]): string | null { + while (queue.length > 0) { + const next = (queue.shift() ?? "").trim(); + if (next.length > 0) return next; + } + return null; } function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true } | { ready: false; reason: string } { @@ -352,6 +471,7 @@ type ManagedChatSession = { turnId?: string; }>; eventSequence: number; + recoveryState: RecoveryState; }; type AgentChatTranscriptEntry = { @@ -500,25 +620,10 @@ function resolveSessionModelDescriptor(session: AgentChatSession): ModelDescript if (session.modelId) { return getModelById(session.modelId) ?? resolveModelAlias(session.modelId) ?? null; } - - if (session.provider === "claude") { - const resolvedClaudeModel = resolveClaudeCliModel(session.model); - return listModelDescriptorsForProvider("claude").find((descriptor) => - descriptor.sdkModelId === resolvedClaudeModel - || descriptor.shortId === session.model - || descriptor.id === session.model, - ) ?? null; - } - - if (session.provider === "codex") { - return listModelDescriptorsForProvider("codex").find((descriptor) => - descriptor.sdkModelId === session.model - || descriptor.shortId === session.model - || descriptor.id === session.model, - ) ?? null; - } - - return getModelById(session.model) ?? resolveModelAlias(session.model) ?? null; + return resolveModelDescriptorForProvider( + session.provider === "claude" ? resolveClaudeCliModel(session.model) : session.model, + isModelProviderGroup(session.provider) ? session.provider : undefined, + ) ?? null; } function sessionSupportsReasoning(session: AgentChatSession): boolean { @@ -643,7 +748,12 @@ function mapApprovalDecisionForCodex(decision: AgentChatApprovalDecision): "acce } function isPlanningApprovalGuarded(managed: ManagedChatSession): boolean { - return managed.session.permissionMode === "plan"; + const s = managed.session; + if (s.provider === "claude") return s.claudePermissionMode === "plan"; + if (s.provider === "unified") return s.unifiedPermissionMode === "plan"; + // Codex has no direct "plan" equivalent; treat untrusted+read-only as plan mode + if (s.provider === "codex") return s.codexApprovalPolicy === "untrusted" && s.codexSandbox === "read-only"; + return false; } function buildPlanningApprovalViolation(toolName: string): string { @@ -735,33 +845,8 @@ function resolveModelIdFromStoredValue( ): string | undefined { const normalized = model.trim().toLowerCase(); if (!normalized.length) return undefined; - - const aliasMatch = resolveModelAlias(normalized); - if (aliasMatch) { - if (providerHint === "codex" && !(aliasMatch.family === "openai" && aliasMatch.isCliWrapped)) return undefined; - if (providerHint === "claude" && !(aliasMatch.family === "anthropic" && aliasMatch.isCliWrapped)) return undefined; - if (providerHint === "unified" && aliasMatch.isCliWrapped) return undefined; - return aliasMatch.id; - } - - const matches = MODEL_REGISTRY.filter( - (entry) => - entry.id.toLowerCase() === normalized - || entry.shortId.toLowerCase() === normalized - || entry.sdkModelId.toLowerCase() === normalized - ); - if (!matches.length) return undefined; - - let preferred: ModelDescriptor | undefined; - if (providerHint === "codex") { - preferred = matches.find((entry) => entry.isCliWrapped && entry.family === "openai"); - } else if (providerHint === "claude") { - preferred = matches.find((entry) => entry.isCliWrapped && entry.family === "anthropic"); - } else if (providerHint === "unified") { - preferred = matches.find((entry) => !entry.isCliWrapped); - } - - return preferred?.id ?? matches[0]?.id; + const providerGroup = isModelProviderGroup(providerHint) ? providerHint : undefined; + return resolveModelDescriptorForProvider(normalized, providerGroup)?.id; } function fallbackModelForProvider(provider: AgentChatProvider): string { @@ -770,6 +855,12 @@ function fallbackModelForProvider(provider: AgentChatProvider): string { return DEFAULT_UNIFIED_MODEL_ID; } +function fallbackModelIdForProvider(provider: AgentChatProvider): string { + if (provider === "codex") return DEFAULT_CODEX_DESCRIPTOR?.id ?? "openai/gpt-5.4-codex"; + if (provider === "claude") return DEFAULT_CLAUDE_DESCRIPTOR?.id ?? "anthropic/claude-sonnet-4-6"; + return DEFAULT_UNIFIED_MODEL_ID; +} + function readProviderParentItemId(value: unknown): string | undefined { if (!value || typeof value !== "object") return undefined; const record = value as Record; @@ -1003,7 +1094,6 @@ function activityForToolName( // Permission mapping functions are shared with the orchestrator/mission system. // Delegate to the single source of truth in permissionMapping.ts. import { - mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; @@ -1012,19 +1102,12 @@ function codexPolicyArgs(policy: ReturnType): Recor return policy ? { approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox } : {}; } -function mapToUnifiedPermissionMode(mode: string | undefined): PermissionMode | undefined { - if (mode === "default" || mode === "config-toml") return "edit"; - if (mode === "plan" || mode === "edit" || mode === "full-auto") return mode; - return undefined; -} - const PLAN_STEP_STATUS_MAP: Record = { completed: "completed", inProgress: "in_progress", failed: "failed", }; -const VALID_PERMISSION_MODES = new Set(["default", "plan", "edit", "full-auto", "config-toml"]); const VALID_EXECUTION_MODES = new Set(["focused", "parallel", "subagents", "teams"]); const VALID_CLAUDE_PERMISSION_MODES = new Set(["default", "plan", "acceptEdits", "bypassPermissions"]); const VALID_CODEX_APPROVAL_POLICIES = new Set(["untrusted", "on-request", "on-failure", "never"]); @@ -1038,10 +1121,6 @@ function normalizePersistedEnum(value: unknown, validSet: Set< return validSet.has(trimmed) ? trimmed as T : undefined; } -function normalizePersistedPermissionMode(value: unknown): AgentChatSession["permissionMode"] | undefined { - return normalizePersistedEnum(value, VALID_PERMISSION_MODES); -} - function normalizePersistedClaudePermissionMode(value: unknown): AgentChatClaudePermissionMode | undefined { return normalizePersistedEnum(value, VALID_CLAUDE_PERMISSION_MODES); } @@ -1062,172 +1141,44 @@ function normalizePersistedUnifiedPermissionMode(value: unknown): AgentChatUnifi return normalizePersistedEnum(value, VALID_UNIFIED_PERMISSION_MODES); } -function legacyPermissionModeToClaudePermissionMode( - mode: AgentChatSession["permissionMode"] | undefined, -): AgentChatClaudePermissionMode | undefined { - if (!mode) return undefined; - return mapPermissionToClaude(mode); -} - -function legacyPermissionModeToCodexApprovalPolicy( - mode: AgentChatSession["permissionMode"] | undefined, -): AgentChatCodexApprovalPolicy | undefined { - if (!mode) return undefined; - if (mode === "config-toml") return undefined; - return mapPermissionToCodex(mode)?.approvalPolicy; -} - -function legacyPermissionModeToCodexSandbox( - mode: AgentChatSession["permissionMode"] | undefined, -): AgentChatCodexSandbox | undefined { - if (!mode) return undefined; - if (mode === "config-toml") return undefined; - return mapPermissionToCodex(mode)?.sandbox; -} - -function legacyPermissionModeToCodexConfigSource( - mode: AgentChatSession["permissionMode"] | undefined, -): AgentChatCodexConfigSource | undefined { - if (!mode) return undefined; - return mode === "config-toml" ? "config-toml" : "flags"; -} - -function legacyPermissionModeToUnifiedPermissionMode( - mode: AgentChatSession["permissionMode"] | undefined, -): AgentChatUnifiedPermissionMode | undefined { - if (!mode) return undefined; - return mode === "default" || mode === "config-toml" ? "edit" : mapToUnifiedPermissionMode(mode); -} - -function syncLegacyPermissionMode(session: Pick< - AgentChatSession, - "provider" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" ->): AgentChatSession["permissionMode"] | undefined { - if (session.provider === "claude") { - switch (session.claudePermissionMode) { - case "default": - return "default"; - case "plan": - return "plan"; - case "acceptEdits": - return "edit"; - case "bypassPermissions": - return "full-auto"; - default: - return undefined; - } - } - - if (session.provider === "codex") { - if (session.codexConfigSource === "config-toml") return "config-toml"; - if (session.codexApprovalPolicy === "never" && session.codexSandbox === "danger-full-access") return "full-auto"; - if (session.codexApprovalPolicy === "on-failure" && session.codexSandbox === "workspace-write") return "edit"; - if (session.codexApprovalPolicy === "untrusted" && session.codexSandbox === "read-only") return "plan"; - return undefined; - } - - switch (session.unifiedPermissionMode) { - case "plan": - case "edit": - case "full-auto": - return session.unifiedPermissionMode; - default: - return undefined; - } -} - -function applyLegacyPermissionModeToNativeControls( - session: Pick< - AgentChatSession, - "provider" | "permissionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" - >, - mode: AgentChatSession["permissionMode"] | undefined, -): void { - session.permissionMode = mode; - if (!mode) return; - - if (session.provider === "claude") { - session.claudePermissionMode = legacyPermissionModeToClaudePermissionMode(mode); - return; - } - - if (session.provider === "codex") { - session.codexApprovalPolicy = legacyPermissionModeToCodexApprovalPolicy(mode); - session.codexSandbox = legacyPermissionModeToCodexSandbox(mode); - session.codexConfigSource = legacyPermissionModeToCodexConfigSource(mode); - return; - } - - session.unifiedPermissionMode = legacyPermissionModeToUnifiedPermissionMode(mode); -} - -function hydrateNativePermissionControls( - session: Pick< - AgentChatSession, - "provider" | "permissionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" - >, -): void { - if (session.provider === "claude") { - session.claudePermissionMode = session.claudePermissionMode ?? legacyPermissionModeToClaudePermissionMode(session.permissionMode); - } else if (session.provider === "codex") { - session.codexApprovalPolicy = session.codexApprovalPolicy ?? legacyPermissionModeToCodexApprovalPolicy(session.permissionMode); - session.codexSandbox = session.codexSandbox ?? legacyPermissionModeToCodexSandbox(session.permissionMode); - session.codexConfigSource = session.codexConfigSource ?? legacyPermissionModeToCodexConfigSource(session.permissionMode); - } else { - session.unifiedPermissionMode = session.unifiedPermissionMode ?? legacyPermissionModeToUnifiedPermissionMode(session.permissionMode); - } - - session.permissionMode = syncLegacyPermissionMode(session); -} - function resolveSessionClaudePermissionMode( - session: Pick, + session: Pick, fallback: AgentChatClaudePermissionMode, ): AgentChatClaudePermissionMode { - return session.claudePermissionMode - ?? legacyPermissionModeToClaudePermissionMode(session.permissionMode) - ?? fallback; + return session.claudePermissionMode ?? fallback; } function resolveSessionCodexApprovalPolicy( - session: Pick, + session: Pick, fallback: AgentChatCodexApprovalPolicy, ): AgentChatCodexApprovalPolicy { - return session.codexApprovalPolicy - ?? legacyPermissionModeToCodexApprovalPolicy(session.permissionMode) - ?? fallback; + return session.codexApprovalPolicy ?? fallback; } function resolveSessionCodexSandbox( - session: Pick, + session: Pick, fallback: AgentChatCodexSandbox, ): AgentChatCodexSandbox { - return session.codexSandbox - ?? legacyPermissionModeToCodexSandbox(session.permissionMode) - ?? fallback; + return session.codexSandbox ?? fallback; } function resolveSessionCodexConfigSource( - session: Pick, + session: Pick, ): AgentChatCodexConfigSource { - return session.codexConfigSource - ?? legacyPermissionModeToCodexConfigSource(session.permissionMode) - ?? "flags"; + return session.codexConfigSource ?? "flags"; } function resolveSessionUnifiedPermissionMode( - session: Pick, + session: Pick, fallback: AgentChatUnifiedPermissionMode, ): AgentChatUnifiedPermissionMode { - return session.unifiedPermissionMode - ?? legacyPermissionModeToUnifiedPermissionMode(session.permissionMode) - ?? fallback; + return session.unifiedPermissionMode ?? fallback; } function normalizeSessionNativePermissionControls( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" + "provider" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" >, config: ResolvedChatConfig, ): void { @@ -1255,8 +1206,6 @@ function normalizeSessionNativePermissionControls( delete session.codexSandbox; delete session.codexConfigSource; } - - session.permissionMode = syncLegacyPermissionMode(session); } function normalizePersistedExecutionMode(value: unknown): AgentChatExecutionMode | undefined { @@ -1339,17 +1288,6 @@ function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { return provider === "codex" || provider === "claude" ? "full_mcp" : "fallback"; } -function guardedIdentityPermissionModeForProvider(provider: AgentChatProvider): AgentChatSession["permissionMode"] { - return provider === "claude" ? "default" : "edit"; -} - -function normalizeIdentityPermissionMode( - mode: AgentChatSession["permissionMode"] | undefined, - provider: AgentChatProvider, -): AgentChatSession["permissionMode"] { - return mode === "full-auto" ? "full-auto" : guardedIdentityPermissionModeForProvider(provider); -} - function isLightweightSession(session: Pick): boolean { return session.sessionProfile === "light"; } @@ -1367,6 +1305,7 @@ export function createAgentChatService(args: { transcriptsDir: string; projectId?: string; memoryService?: ReturnType | null; + memoryFilesService?: Pick | null; fileService?: ReturnType | null; episodicSummaryService?: EpisodicSummaryService | null; ctoStateService?: ReturnType | null; @@ -1395,6 +1334,7 @@ export function createAgentChatService(args: { transcriptsDir, projectId, memoryService, + memoryFilesService, fileService, episodicSummaryService, ctoStateService, @@ -1454,12 +1394,29 @@ export function createAgentChatService(args: { totalHits: number; injectedCount: number; includedProcedure: boolean; + bootstrapLoaded: boolean; + topicFilesLoaded: string[]; }; type AutoMemoryTurnPlan = { classification: AutoMemoryTurnClassification; contextText: string; telemetry: AutoMemoryTurnTelemetry; + selectedEntries: Array<{ + scope: "project" | "agent"; + category: string; + snippet: string; + pinned: boolean; + tier: number | null; + }>; + }; + + type AutoCapturedMemoryCandidate = { + category: Extract; + content: string; + importance: MemoryImportance; + writeMode: "default" | "strict"; + reason: string; }; const EMPTY_MEMORY_TELEMETRY: AutoMemoryTurnTelemetry = { @@ -1469,6 +1426,8 @@ export function createAgentChatService(args: { totalHits: 0, injectedCount: 0, includedProcedure: false, + bootstrapLoaded: false, + topicFilesLoaded: [], }; const ensureSubagentSnapshotMap = (sessionId: string): Map => { @@ -1486,10 +1445,19 @@ export function createAgentChatService(args: { return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; }; - const AUTO_MEMORY_REQUIRED_RE = /\b(?:fix|debug|investigat(?:e|ing|ion)|implement|refactor|patch|edit|write|add|remove|rename|update|change|test(?:s|ing)?|failing|error|exception|stack trace|crash|bug|diff|pull request|regression|build|compile|lint|typecheck)\b/i; + const AUTO_MEMORY_MUTATION_VERB_RE = /\b(?:fix|debug|investigat(?:e|ing|ion)|implement|refactor|patch|edit|write|add|remove|rename|update|change|run|reproduce)\b/i; + const AUTO_MEMORY_CODE_TARGET_RE = /\b(?:file|files|code|app|renderer|component|service|hook|prompt box|composer|thread|memory|chat|model|sandbox|approval|permission|setting|settings|bug|error|exception|stack trace|crash|regression|build|compile|lint|typecheck|test|tests|ui|layout|tsx?|jsx?|json|css|styles?)\b/i; + const AUTO_MEMORY_TOOLCHAIN_RE = /\b(?:unit tests?|integration tests?|e2e tests?|test suite|test failure|failing tests?|vitest|jest|playwright|cypress|npm test|pnpm test|yarn test|build failure|compile error|lint error|typecheck)\b/i; + const AUTO_MEMORY_PROCEDURE_HINT_RE = /\b(?:procedure|workflow|steps?|checklist|runbook|playbook|automate|finalize)\b/i; const AUTO_MEMORY_SOFT_RE = /\b(?:explain|why|how|walk through|summari[sz]e|context|overview|review|plan|brainstorm|design|architecture|tradeoff|decision|pattern|convention|gotcha)\b/i; - const AUTO_MEMORY_META_RE = /^(?:hi|hello|hey|thanks|thank you|ok(?:ay)?|cool|sounds good|nice|what model are you|who are you|are you there|can you help)\b/i; + const AUTO_MEMORY_META_RE = /^(?:hi|hello|hey|thanks|thank you|ok(?:ay)?|cool|sounds good|nice|what model are you|who are you|are you there|can you help|test(?:ing)?|what|why|lol|yep|nah|yeah|sure|ping|help|yo)\b/i; + const AUTO_MEMORY_TRIVIAL_TEST_RE = /^(?:(?:this|it)\s+is\s+)?(?:just\s+)?test(?:ing)?[.!?]*$/i; const AUTO_MEMORY_FILE_PATH_RE = /(?:^|\s)(?:\/|\.{1,2}\/|[A-Za-z]:\\|[A-Za-z0-9_.-]+\/)[^\s]+\.(?:ts|tsx|js|jsx|json|md|yml|yaml|py|go|rs|java|rb|sh)\b/i; + const AUTO_MEMORY_EXPLICIT_SAVE_RE = /\b(?:remember(?:\s+this|\s+that)?|please remember|keep in mind|note that)\b/i; + const AUTO_MEMORY_PREFERENCE_SAVE_RE = /\b(?:i prefer|my preference is|please keep(?: the)? responses?|prefer responses?|keep responses?)\b/i; + const AUTO_MEMORY_CONVENTION_SAVE_RE = /\b(?:we use|we always use|always use|never use|do not use|don't use|our convention is|repo convention|team convention)\b/i; + const AUTO_MEMORY_DECISION_SAVE_RE = /\b(?:decision:|we decided|decided to|we chose|chose to)\b/i; + const AUTO_MEMORY_GOTCHA_SAVE_RE = /\b(?:avoid|pitfall|gotcha|breaks?|fails?|failure|regression|will fail|causes?)\b/i; const CLAUDE_MUTATING_TOOL_RE = /\b(?:bash|write|edit|multiedit|notebookedit)\b/; const CHAT_MEMORY_GUARD_MESSAGE = "Search memory before mutating files or running mutating commands for this turn."; const CLAUDE_MUTATING_BASH_RE = /\b(?:rm|mv|cp|mkdir|touch|chmod|chown|patch|install|uninstall|add|remove|upgrade|apply|commit|rebase|merge|reset|checkout|switch|restore|sed\s+-i|perl\s+-i)\b|>>?|tee\b/i; @@ -1499,25 +1467,47 @@ export function createAgentChatService(args: { attachmentCount = 0, ): AutoMemoryTurnClassification => { const trimmed = promptText.trim(); - if (trimmed.length < 12) return "none"; + if (trimmed.length < 20) return "none"; if (trimmed.startsWith("/")) return "none"; if (/^before context compaction runs\b/i.test(trimmed)) return "none"; if (/^review this conversation and persist\b/i.test(trimmed)) return "none"; + if (AUTO_MEMORY_TRIVIAL_TEST_RE.test(trimmed)) return "none"; + if (trimmed.split(/\s+/).length <= 3 && !AUTO_MEMORY_CODE_TARGET_RE.test(trimmed) && !AUTO_MEMORY_FILE_PATH_RE.test(trimmed)) return "none"; if (attachmentCount > 0) return "required"; if (/```/.test(trimmed) || AUTO_MEMORY_FILE_PATH_RE.test(trimmed)) return "required"; - if (AUTO_MEMORY_REQUIRED_RE.test(trimmed)) return "required"; + if (AUTO_MEMORY_TOOLCHAIN_RE.test(trimmed)) return "required"; + if (AUTO_MEMORY_MUTATION_VERB_RE.test(trimmed) && AUTO_MEMORY_CODE_TARGET_RE.test(trimmed)) return "required"; if (AUTO_MEMORY_SOFT_RE.test(trimmed)) return "soft"; - if (AUTO_MEMORY_META_RE.test(trimmed) && trimmed.length <= 80) return "none"; + if (AUTO_MEMORY_META_RE.test(trimmed) && trimmed.length <= 60) return "none"; return "none"; }; + /** Returns true for any non-trivial prompt that should get the bootstrap memory context. */ + const shouldLoadAutoMemoryBootstrap = ( + promptText: string, + _attachmentCount = 0, + ): boolean => { + const trimmed = promptText.trim(); + if (trimmed.length < 18) return false; + if (trimmed.startsWith("/")) return false; + if (/^before context compaction runs\b/i.test(trimmed)) return false; + if (/^review this conversation and persist\b/i.test(trimmed)) return false; + if (AUTO_MEMORY_TRIVIAL_TEST_RE.test(trimmed)) return false; + if (trimmed.split(/\s+/).length <= 3 && !AUTO_MEMORY_CODE_TARGET_RE.test(trimmed) && !AUTO_MEMORY_FILE_PATH_RE.test(trimmed)) return false; + if (AUTO_MEMORY_META_RE.test(trimmed) && trimmed.length <= 60) return false; + return true; + }; + const selectAutoMemoryEntries = ( memories: Memory[], + promptText: string, maxEntries = 4, ): Memory[] => { const seen = new Set(); + const includeProcedure = AUTO_MEMORY_PROCEDURE_HINT_RE.test(promptText); return memories .filter((memory) => AUTO_MEMORY_CATEGORY_ALLOWLIST.has(String(memory.category ?? "").trim())) + .filter((memory) => memory.category !== "procedure" || includeProcedure) .filter((memory) => { if (seen.has(memory.id)) return false; seen.add(memory.id); @@ -1533,16 +1523,33 @@ export function createAgentChatService(args: { const buildAutoMemorySystemNotice = (plan: AutoMemoryTurnPlan): { message: string; - detail: string; + detail: AgentChatNoticeDetail; } | null => { - if (!plan.telemetry.searched) return null; - const message = `Checked memory: ${plan.telemetry.totalHits} hit${plan.telemetry.totalHits === 1 ? "" : "s"}, injected ${plan.telemetry.injectedCount} relevant entr${plan.telemetry.injectedCount === 1 ? "y" : "ies"}`; - const detail = [ - `Policy: ${plan.classification}`, - `Project hits: ${plan.telemetry.projectHits}`, - `Agent hits: ${plan.telemetry.agentHits}`, - ...(plan.telemetry.includedProcedure ? ["Included procedure memory in the injected set."] : []), - ].join("\n"); + const hasAutoMemoryFiles = plan.telemetry.bootstrapLoaded || plan.telemetry.topicFilesLoaded.length > 0; + if (!plan.telemetry.searched && !hasAutoMemoryFiles) return null; + const message = plan.telemetry.searched + ? (plan.telemetry.injectedCount > 0 + ? `Memory: ${plan.telemetry.injectedCount} relevant entr${plan.telemetry.injectedCount === 1 ? "y" : "ies"} injected` + : "Memory: searched, no relevant entries") + : `Memory: loaded bootstrap${plan.telemetry.topicFilesLoaded.length > 0 ? ` + ${plan.telemetry.topicFilesLoaded.length} topic file${plan.telemetry.topicFilesLoaded.length === 1 ? "" : "s"}` : ""}`; + const detail: AgentChatNoticeDetail = { + summary: plan.telemetry.searched + ? message + : "ADE loaded the generated project memory bootstrap for this non-trivial turn even though targeted memory search was not required.", + sections: hasAutoMemoryFiles + ? [{ + title: "Auto memory files", + items: [ + ...(plan.telemetry.bootstrapLoaded + ? ["Loaded the generated .ade/memory/MEMORY.md bootstrap index."] + : []), + ...(plan.telemetry.topicFilesLoaded.length > 0 + ? [`Loaded topic files: ${plan.telemetry.topicFilesLoaded.join(", ")}.`] + : []), + ], + }] + : undefined, + }; return { message, detail }; }; @@ -1571,17 +1578,231 @@ export function createAgentChatService(args: { return { message, detail }; }; + const splitAutoMemoryCaptureClauses = (promptText: string): string[] => { + const normalized = promptText.replace(/```[\s\S]*?```/g, " "); + const segments = normalized + .split(/\r?\n+/) + .flatMap((line) => line.split(/(?<=[.!])\s+/)); + return uniqueNonEmpty( + segments.map((segment) => segment.replace(/^[-*]\s*/, "").trim()), + 8, + ); + }; + + const MEMORY_CATEGORY_LABELS: Record = { + preference: "Preference", + convention: "Convention", + decision: "Decision", + gotcha: "Gotcha", + fact: "Fact", + }; + + const formatAutoCapturedMemoryContent = ( + category: AutoCapturedMemoryCandidate["category"], + clause: string, + ): string => { + const prefix = MEMORY_CATEGORY_LABELS[category]; + const cleaned = clause + .replace(/^(?:please\s+)?remember(?:\s+this|\s+that)?[:,]?\s*/i, "") + .replace(/^keep in mind[:,]?\s*/i, "") + .replace(/^note that[:,]?\s*/i, "") + .replace(/^that\s+/i, "") + .replace(new RegExp(`^${prefix}:\\s*`, "i"), "") + .trim() + .replace(/[.;:\s]+$/, ""); + const body = /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`; + return `${prefix}: ${body}`; + }; + + const extractAutoCapturedMemoryCandidate = (promptText: string): AutoCapturedMemoryCandidate | null => { + const trimmed = promptText.trim(); + if (trimmed.length < 16 || trimmed.length > 500) return null; + if (trimmed.startsWith("/")) return null; + if (AUTO_MEMORY_META_RE.test(trimmed) || AUTO_MEMORY_TRIVIAL_TEST_RE.test(trimmed)) return null; + + for (const clause of splitAutoMemoryCaptureClauses(trimmed)) { + const normalized = clause.replace(/\s+/g, " ").trim(); + if (normalized.length < 12 || normalized.length > 220) continue; + if (normalized.endsWith("?")) continue; + + const hasCodeHint = AUTO_MEMORY_CODE_TARGET_RE.test(normalized) + || AUTO_MEMORY_TOOLCHAIN_RE.test(normalized) + || /\b(?:npm|pnpm|yarn|bun|eslint|prettier|vitest|jest|playwright|typescript|tsc)\b/i.test(normalized) + || AUTO_MEMORY_FILE_PATH_RE.test(normalized); + const explicitSave = AUTO_MEMORY_EXPLICIT_SAVE_RE.test(normalized); + + if (AUTO_MEMORY_PREFERENCE_SAVE_RE.test(normalized)) { + return { + category: "preference", + content: formatAutoCapturedMemoryContent("preference", normalized), + importance: "medium", + writeMode: "strict", + reason: "explicit user preference", + }; + } + + if (AUTO_MEMORY_DECISION_SAVE_RE.test(normalized)) { + return { + category: "decision", + content: formatAutoCapturedMemoryContent("decision", normalized), + importance: "high", + writeMode: "strict", + reason: "explicit project decision", + }; + } + + if (AUTO_MEMORY_CONVENTION_SAVE_RE.test(normalized) && hasCodeHint) { + return { + category: "convention", + content: formatAutoCapturedMemoryContent("convention", normalized), + importance: "high", + writeMode: "strict", + reason: "explicit project convention", + }; + } + + if (AUTO_MEMORY_GOTCHA_SAVE_RE.test(normalized) && hasCodeHint) { + return { + category: "gotcha", + content: formatAutoCapturedMemoryContent("gotcha", normalized), + importance: "high", + writeMode: explicitSave ? "strict" : "default", + reason: "explicit failure mode or pitfall", + }; + } + + if (explicitSave) { + const category: AutoCapturedMemoryCandidate["category"] = hasCodeHint ? "convention" : "fact"; + return { + category, + content: formatAutoCapturedMemoryContent(category, normalized), + importance: hasCodeHint ? "high" : "medium", + writeMode: "strict", + reason: hasCodeHint ? "explicit remembered convention" : "explicit remembered fact", + }; + } + } + + return null; + }; + + const buildAutoCapturedMemoryNotice = ( + candidate: AutoCapturedMemoryCandidate, + result: WriteMemoryResult, + ): { message: string; detail?: string } => { + if (!result.accepted || !result.memory) { + return { + message: `Skipped auto-memory capture: ${result.reason ?? "write rejected"}`, + detail: `Candidate: ${candidate.content}`, + }; + } + + const memory = result.memory; + return { + message: result.deduped + ? "Merged explicit user instruction into memory" + : "Captured explicit user instruction into memory", + detail: [ + `Category: ${memory.category}`, + `Durability: ${memory.status}`, + `Tier: ${memory.tier}`, + `Reason: ${candidate.reason}`, + `Content: ${candidate.content}`, + ].join("\n"), + }; + }; + + const maybeAutoCaptureTurnMemory = ( + managed: ManagedChatSession, + promptText: string, + turnId?: string, + ): void => { + if (!memoryService || !projectId || isLightweightSession(managed.session)) return; + const candidate = extractAutoCapturedMemoryCandidate(promptText); + if (!candidate) return; + + const writePolicy = resolveAgentMemoryWritePolicy({ writeGateMode: candidate.writeMode }); + const result = memoryService.writeMemory({ + projectId, + scope: "project", + category: candidate.category, + content: candidate.content, + importance: candidate.importance, + status: writePolicy.status, + tier: writePolicy.tier, + confidence: writePolicy.confidence, + sourceSessionId: managed.session.id, + sourceType: "user", + sourceId: "chat:auto-capture", + agentId: managed.session.identityKey ?? managed.session.id, + writeGateMode: candidate.writeMode, + }); + + const notice = buildAutoCapturedMemoryNotice(candidate, result); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "memory", + message: notice.message, + ...(notice.detail ? { detail: notice.detail } : {}), + ...(turnId ? { turnId } : {}), + }); + }; + const buildAutoMemoryTurnPlan = async ( managed: ManagedChatSession, promptText: string, attachments: AgentChatFileRef[] = [], ): Promise => { const classification = classifyAutoMemoryTurn(promptText, attachments.length); + const shouldLoadBootstrap = shouldLoadAutoMemoryBootstrap(promptText, attachments.length); if (!memoryService || !projectId) { - return { classification: "none", contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY }; + return { classification: "none", contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY, selectedEntries: [] }; + } + if (isLightweightSession(managed.session) || (classification === "none" && !shouldLoadBootstrap)) { + return { classification, contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY, selectedEntries: [] }; } - if (isLightweightSession(managed.session) || classification === "none") { - return { classification, contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY }; + + const fileContext = (() => { + if (!memoryFilesService || !shouldLoadBootstrap) { + return { + text: "", + bootstrapLoaded: false, + topicFilesLoaded: [], + }; + } + try { + return memoryFilesService.buildPromptContext({ + promptText, + maxBootstrapLines: classification === "required" ? 80 : 60, + maxTopicFiles: classification === "required" ? 2 : 1, + maxTopicLines: classification === "required" ? 18 : 12, + maxChars: classification === "required" ? 2_400 : 1_600, + }); + } catch { + return { + text: "", + bootstrapLoaded: false, + topicFilesLoaded: [], + }; + } + })(); + + if (classification === "none") { + return { + classification, + contextText: fileContext.text, + telemetry: { + searched: false, + projectHits: 0, + agentHits: 0, + totalHits: 0, + injectedCount: 0, + includedProcedure: false, + bootstrapLoaded: fileContext.bootstrapLoaded, + topicFilesLoaded: fileContext.topicFilesLoaded, + }, + selectedEntries: [], + }; } const query = promptText.trim().slice(0, 300); @@ -1607,14 +1828,18 @@ export function createAgentChatService(args: { }).catch(() => []), ]); - const allQualifying = selectAutoMemoryEntries([...projectHits, ...agentHits], 32); + const allQualifying = selectAutoMemoryEntries([...projectHits, ...agentHits], promptText, 32); const selected = allQualifying.slice(0, 4); - const contextText = selected.length === 0 - ? "" - : [ + const contextSections = [ + fileContext.text.length > 0 ? fileContext.text : null, + selected.length > 0 + ? [ "Relevant ADE memory for this turn (use it when helpful; current code and files win if they disagree):", ...selected.map((memory) => `- [${memory.scope}/${memory.category}] ${compactMemorySnippet(memory.content, 180)}`), - ].join("\n"); + ].join("\n") + : null, + ].filter((section): section is string => Boolean(section)); + const contextText = contextSections.join("\n\n"); return { classification, @@ -1626,7 +1851,16 @@ export function createAgentChatService(args: { totalHits: allQualifying.length, injectedCount: selected.length, includedProcedure: selected.some((memory) => memory.category === "procedure"), + bootstrapLoaded: fileContext.bootstrapLoaded, + topicFilesLoaded: fileContext.topicFilesLoaded, }, + selectedEntries: selected.map((memory) => ({ + scope: memory.scope === "agent" ? "agent" : "project", + category: memory.category, + snippet: compactMemorySnippet(memory.content, 180), + pinned: Boolean(memory.pinned), + tier: typeof memory.tier === "number" ? memory.tier : null, + })), }; }; @@ -2022,6 +2256,7 @@ export function createAgentChatService(args: { if (event.type !== "user_message" && event.type !== "text") return; const text = event.text.trim(); if (!text.length) return; + if (event.type === "user_message" && event.deliveryState === "queued") return; const role = event.type === "user_message" ? "user" : "assistant"; const turnId = "turnId" in event ? event.turnId : undefined; @@ -2457,7 +2692,6 @@ export function createAgentChatService(args: { managed.runtime = runtime; managed.session.provider = "unified"; managed.session.unifiedPermissionMode = permMode; - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; managed.session.capabilityMode = "fallback"; return "handled"; }; @@ -2564,7 +2798,6 @@ export function createAgentChatService(args: { ...(managed.session.codexSandbox ? { codexSandbox: managed.session.codexSandbox } : {}), ...(managed.session.codexConfigSource ? { codexConfigSource: managed.session.codexConfigSource } : {}), ...(managed.session.unifiedPermissionMode ? { unifiedPermissionMode: managed.session.unifiedPermissionMode } : {}), - ...(managed.session.permissionMode ? { permissionMode: managed.session.permissionMode } : {}), ...(managed.session.identityKey ? { identityKey: managed.session.identityKey } : {}), ...(managed.session.surface ? { surface: managed.session.surface } : {}), ...(managed.session.automationId ? { automationId: managed.session.automationId } : {}), @@ -2591,6 +2824,69 @@ export function createAgentChatService(args: { } }; + const resolveCodexThreadWaiters = (runtime: CodexRuntime, threadId?: string): void => { + if (runtime.threadIdWaiters.size === 0) return; + for (const waiter of runtime.threadIdWaiters) { + try { + waiter(threadId); + } catch { + // ignore waiter errors + } + } + runtime.threadIdWaiters.clear(); + }; + + const setCodexThreadIdentity = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + threadId: string | null | undefined, + ): string | null => { + const normalized = String(threadId ?? "").trim(); + if (!normalized.length) return null; + const changed = managed.session.threadId !== normalized; + managed.session.threadId = normalized; + sessionService.setResumeCommand(managed.session.id, `chat:codex:${normalized}`); + resolveCodexThreadWaiters(runtime, normalized); + if (changed) { + persistChatState(managed); + } + return normalized; + }; + + const waitForCodexThreadIdentity = async ( + runtime: CodexRuntime, + timeoutMs = 1200, + ): Promise => { + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + runtime.threadIdWaiters.delete(waiter); + resolve(undefined); + }, timeoutMs); + const waiter = (threadId?: string) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + runtime.threadIdWaiters.delete(waiter); + resolve(threadId); + }; + runtime.threadIdWaiters.add(waiter); + }); + }; + + const maybeDrainQueuedSteer = async ( + managed: ManagedChatSession, + queue: string[], + runner: (text: string) => Promise, + ): Promise => { + if (managed.closed) return; + const steerText = shiftPendingSteer(queue); + if (!steerText) return; + await runner(steerText); + }; + const readPersistedState = (sessionId: string): PersistedChatState | null => { const filePath = metadataPathFor(sessionId); if (!fs.existsSync(filePath)) return null; @@ -2609,7 +2905,6 @@ export function createAgentChatService(args: { const sessionProfile = normalizeSessionProfile(record.sessionProfile); const reasoningEffort = normalizeReasoningEffort(record.reasoningEffort); const executionMode = normalizePersistedExecutionMode(record.executionMode); - const permissionMode = normalizePersistedPermissionMode(record.permissionMode); const claudePermissionMode = normalizePersistedClaudePermissionMode(record.claudePermissionMode); const codexApprovalPolicy = normalizePersistedCodexApprovalPolicy(record.codexApprovalPolicy); const codexSandbox = normalizePersistedCodexSandbox(record.codexSandbox); @@ -2646,7 +2941,6 @@ export function createAgentChatService(args: { ...(codexSandbox ? { codexSandbox } : {}), ...(codexConfigSource ? { codexConfigSource } : {}), ...(unifiedPermissionMode ? { unifiedPermissionMode } : {}), - ...(permissionMode ? { permissionMode } : {}), ...(identityKey ? { identityKey } : {}), surface, ...(typeof record.automationId === "string" && record.automationId.trim().length @@ -2665,7 +2959,6 @@ export function createAgentChatService(args: { ...(messages?.length ? { messages } : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; - hydrateNativePermissionControls(hydrated as Parameters[0]); return hydrated; } catch { return null; @@ -3281,8 +3574,8 @@ export function createAgentChatService(args: { const fallbackModel = persisted?.model ?? fallbackModelForProvider(provider); const hydratedModelId = persisted?.modelId ?? resolveModelIdFromStoredValue(fallbackModel, provider) - ?? (provider === "unified" ? DEFAULT_UNIFIED_MODEL_ID : undefined); - const model = provider === "unified" ? (hydratedModelId ?? fallbackModel) : fallbackModel; + ?? fallbackModelIdForProvider(provider); + const model = provider === "unified" ? hydratedModelId : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); const managed: ManagedChatSession = { @@ -3291,7 +3584,7 @@ export function createAgentChatService(args: { laneId: row.laneId, provider, model, - ...(hydratedModelId ? { modelId: hydratedModelId } : {}), + modelId: hydratedModelId, ...(persisted?.sessionProfile ? { sessionProfile: persisted.sessionProfile } : {}), reasoningEffort: persisted?.reasoningEffort ?? null, executionMode: persisted?.executionMode ?? null, @@ -3300,7 +3593,6 @@ export function createAgentChatService(args: { ...(persisted?.codexSandbox ? { codexSandbox: persisted.codexSandbox } : {}), ...(persisted?.codexConfigSource ? { codexConfigSource: persisted.codexConfigSource } : {}), ...(persisted?.unifiedPermissionMode ? { unifiedPermissionMode: persisted.unifiedPermissionMode } : {}), - ...(persisted?.permissionMode ? { permissionMode: persisted.permissionMode } : {}), ...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}), capabilityMode: persisted?.capabilityMode ?? inferCapabilityMode(provider), computerUse: normalizePersistedComputerUse(persisted?.computerUse), @@ -3330,6 +3622,7 @@ export function createAgentChatService(args: { bufferedText: null, recentConversationEntries: [], eventSequence: 0, + recoveryState: createRecoveryState(), }; normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; @@ -3347,18 +3640,50 @@ export function createAgentChatService(args: { attachments?: AgentChatFileRef[]; }, ): Promise => { - if (!managed.session.threadId) { - throw new Error(`Codex session '${managed.session.id}' is missing thread id.`); - } if (!managed.runtime || managed.runtime.kind !== "codex") { throw new Error(`Codex runtime is not available for session '${managed.session.id}'.`); } - if (managed.runtime.activeTurnId) { + const runtime = managed.runtime; + if (runtime.activeTurnId) { throw new Error("A turn is already active. Use steer or interrupt."); } - const runtime = managed.runtime; + let threadId = managed.session.threadId ?? null; + if (!threadId) { + threadId = (await waitForCodexThreadIdentity(runtime)) ?? null; + if (threadId) { + setCodexThreadIdentity(managed, runtime, threadId); + } + } + if (!threadId) { + // Recovery attempt 1: check persisted state + const persisted = readPersistedState(managed.session.id); + if (persisted?.threadId) { + threadId = persisted.threadId; + setCodexThreadIdentity(managed, runtime, threadId); + } + } + if (!threadId) { + // Recovery attempt 2: rebind fresh thread + logger.warn("agent_chat.codex_thread_recovery", { + sessionId: managed.session.id, + message: "Thread identity lost; starting fresh thread for recovery.", + }); + const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); + await startFreshCodexThread(managed, runtime, codexPolicy, mcpServers); + threadId = managed.session.threadId ?? null; + } + if (!threadId) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "thread_error", + message: "This Codex chat lost its thread identity.", + detail: "ADE could not recover the current Codex thread id after all recovery attempts. Please start a new chat session.", + }); + throw new Error(`Codex session '${managed.session.id}' is missing thread id after recovery.`); + } const attachments = args.attachments ?? []; const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; + maybeAutoCaptureTurnMemory(managed, displayText); const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, displayText, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); @@ -3366,7 +3691,7 @@ export function createAgentChatService(args: { if (args.promptText.trim().startsWith("/review")) { emitChatEvent(managed, { type: "user_message", text: displayText, attachments }); const reviewResult = await runtime.request<{ turn?: { id?: string } }>("review/start", { - threadId: managed.session.threadId, + threadId, target: "uncommittedChanges", }); const reviewTurnId = typeof reviewResult.turn?.id === "string" ? reviewResult.turn.id : null; @@ -3424,7 +3749,7 @@ export function createAgentChatService(args: { } const result = await managed.runtime.request<{ turn?: { id?: string } }>("turn/start", { - threadId: managed.session.threadId, + threadId, input, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}) }); @@ -3588,6 +3913,7 @@ export function createAgentChatService(args: { try { const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; + maybeAutoCaptureTurnMemory(managed, autoMemoryPrompt, turnId); const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); runtime.turnMemoryPolicyState = { @@ -3657,11 +3983,12 @@ export function createAgentChatService(args: { // V2 pattern: send() then stream() per turn. Session stays alive between turns. await runtime.v2Session.send(messageToSend); + runtime.v2StreamGen = runtime.v2Session.stream(); // Don't emit a pre-emptive "thinking" activity — wait for actual content from the stream. // The renderer will show the turn as "started" (from the status event above) which is sufficient. - for await (const msg of runtime.v2Session.stream()) { + for await (const msg of runtime.v2StreamGen) { if (runtime.interrupted) break; markFirstStreamEvent(msg.type); @@ -4111,7 +4438,9 @@ export function createAgentChatService(args: { runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; + runtime.v2StreamGen = null; runtime.turnMemoryPolicyState = null; + runtime.activeSubagents.clear(); managed.session.status = "idle"; reportProviderRuntimeReady("claude"); @@ -4151,17 +4480,17 @@ export function createAgentChatService(args: { persistChatState(managed); // Process queued steers (skip if session was disposed during execution) - if (!managed.closed && runtime.pendingSteers.length) { - const steerText = runtime.pendingSteers.shift() ?? ""; - if (steerText.trim().length) { - await runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }); - } - } + await maybeDrainQueuedSteer( + managed, + runtime.pendingSteers, + async (steerText) => runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), + ); } catch (error) { runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; + runtime.activeSubagents.clear(); // Close V2 session on error so the next turn starts fresh try { runtime.v2Session?.close(); } catch { /* ignore */ } @@ -4195,6 +4524,15 @@ export function createAgentChatService(args: { message: errorMessage, turnId, }); + if (isAuthFailure || /\b(network|timed out|econn|socket)\b/i.test(errorMessage)) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "provider_health", + message: "Claude runtime issue", + detail: errorMessage, + turnId, + }); + } emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); emitChatEvent(managed, { type: "done", @@ -4216,11 +4554,23 @@ export function createAgentChatService(args: { sdkSessionId: runtime.sdkSessionId, error: error instanceof Error ? error.message : String(error), }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "thread_error", + message: "Claude session state was reset after a session error.", + detail: error instanceof Error ? error.message : String(error), + turnId, + }); runtime.sdkSessionId = null; } } persistChatState(managed); + await maybeDrainQueuedSteer( + managed, + runtime.pendingSteers, + async (steerText) => runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), + ); } }; @@ -4279,6 +4629,7 @@ export function createAgentChatService(args: { try { const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; + maybeAutoCaptureTurnMemory(managed, autoMemoryPrompt, turnId); const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); const turnMemoryPolicyState: TurnMemoryPolicyState | undefined = memoryService && projectId @@ -4553,7 +4904,6 @@ export function createAgentChatService(args: { modelId, reasoningEffort, reuseExisting, - permissionMode: "full-auto", }), })); } @@ -4796,12 +5146,11 @@ export function createAgentChatService(args: { persistChatState(managed); // Process queued steers (skip if session was disposed during execution) - if (!managed.closed && runtime.pendingSteers.length) { - const steerText = runtime.pendingSteers.shift() ?? ""; - if (steerText.trim().length) { - await runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }); - } - } + await maybeDrainQueuedSteer( + managed, + runtime.pendingSteers, + async (steerText) => runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), + ); } catch (error) { clearTimeout(turnTimeout); runtime.busy = false; @@ -4823,8 +5172,8 @@ export function createAgentChatService(args: { const { message: errorMessage, errorInfo } = classifyUnifiedError( error, - runtime.modelDescriptor.family, - runtime.modelDescriptor.displayName, + runtime.modelDescriptor?.family ?? "unknown", + runtime.modelDescriptor?.displayName ?? managed.session.model, ); emitChatEvent(managed, { @@ -4852,6 +5201,11 @@ export function createAgentChatService(args: { } persistChatState(managed); + await maybeDrainQueuedSteer( + managed, + runtime.pendingSteers, + async (steerText) => runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), + ); } }; @@ -5367,6 +5721,18 @@ export function createAgentChatService(args: { const method = typeof payload.method === "string" ? payload.method : ""; const params = (payload.params as Record | null) ?? {}; const turnIdFromParams = extractCodexTurnId(params); + const threadIdFromParams = extractCodexThreadId(params); + const itemIdFromParams = extractCodexItemId(params); + + if (threadIdFromParams) { + setCodexThreadIdentity(managed, runtime, threadIdFromParams); + } + + if (method === "thread/started") { + runtime.threadResumed = true; + persistChatState(managed); + return; + } if (method === "turn/started") { const turn = (params.turn as { id?: unknown } | null) ?? null; @@ -5445,30 +5811,41 @@ export function createAgentChatService(args: { sessionService.setHeadShaEnd(managed.session.id, endSha); } + if (runtime.pendingThreadRebind) { + runtime.pendingThreadRebind = false; + runtime.threadResumed = false; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Codex settings updated for the next turn.", + detail: "Approval, sandbox, or config source changed while this turn was running. ADE will rebind the thread with those settings on the next message.", + }); + } + persistChatState(managed); return; } if (method === "item/agentMessage/delta") { - const delta = String((params.delta as string | undefined) ?? ""); + const delta = extractCodexTextPayload(params) ?? ""; if (!delta.length) return; emitChatEvent(managed, { type: "text", text: delta, - turnId: typeof params.turnId === "string" ? params.turnId : undefined, - itemId: typeof params.itemId === "string" ? params.itemId : undefined + turnId: turnIdFromParams ?? undefined, + itemId: itemIdFromParams ?? undefined, }); return; } if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { - const delta = String((params.delta as string | undefined) ?? ""); + const delta = extractCodexTextPayload(params) ?? ""; if (!delta.length) return; emitChatEvent(managed, { type: "reasoning", text: delta, - turnId: typeof params.turnId === "string" ? params.turnId : undefined, - itemId: typeof params.itemId === "string" ? params.itemId : undefined, + turnId: turnIdFromParams ?? undefined, + itemId: itemIdFromParams ?? undefined, summaryIndex: typeof params.summaryIndex === "number" ? params.summaryIndex : undefined }); return; @@ -5598,7 +5975,18 @@ export function createAgentChatService(args: { const turnId = resolvedAbortTurnId ?? randomUUID(); runtime.activeTurnId = null; runtime.startedTurnId = null; + runtime.itemTurnIdByItemId.clear(); managed.session.status = "idle"; + if (runtime.pendingThreadRebind) { + runtime.pendingThreadRebind = false; + runtime.threadResumed = false; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Codex settings updated for the next turn.", + detail: "This turn was interrupted after settings changed. ADE will rebind the thread with those settings on the next message.", + }); + } emitChatEvent(managed, { type: "status", turnStatus: "interrupted", @@ -5616,7 +6004,7 @@ export function createAgentChatService(args: { } if (method === "codex/event/web_search_begin") { - const query = pickCodexTurnId(params.query, params.searchQuery, params.input) ?? ""; + const query = pickCodexStringId(params.query, params.searchQuery, params.input) ?? ""; emitChatEvent(managed, { type: "activity", activity: "web_searching", @@ -5637,7 +6025,26 @@ export function createAgentChatService(args: { method === "thread/status/changed" || method === "codex/event/task_started" || method === "codex/event/mcp_startup_update" + || method === "codex/event/task_complete" + || method === "codex/event/token_count" + || method === "thread/tokenUsage/updated" + ) { + return; + } + + if ( + method === "codex/event/agent_message" + || method === "codex/event/agent_message_delta" + || method === "codex/event/agent_message_content_delta" ) { + const text = extractCodexTextPayload(params); + if (!text) return; + emitChatEvent(managed, { + type: "text", + text, + turnId: turnIdFromParams ?? runtime.activeTurnId ?? undefined, + itemId: itemIdFromParams ?? undefined, + }); return; } @@ -5777,6 +6184,8 @@ export function createAgentChatService(args: { activeTurnId: null, startedTurnId: null, threadResumed: false, + pendingThreadRebind: false, + threadIdWaiters: new Set(), itemTurnIdByItemId: new Map(), commandOutputByItemId: new Map(), fileDeltaByItemId: new Map(), @@ -5970,7 +6379,6 @@ export function createAgentChatService(args: { delete managed.session.codexApprovalPolicy; delete managed.session.codexSandbox; } - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; const mcpServers = isLightweightSession(managed.session) ? {} : buildAdeMcpServers( @@ -5999,12 +6407,15 @@ export function createAgentChatService(args: { experimentalRawEvents: false, persistExtendedHistory: true }); - const newThreadId = typeof startResponse.thread?.id === "string" ? startResponse.thread.id : undefined; - if (newThreadId) { - managed.session.threadId = newThreadId; - sessionService.setResumeCommand(managed.session.id, `chat:codex:${newThreadId}`); + const newThreadId = setCodexThreadIdentity(managed, runtime, extractCodexThreadId(startResponse)); + if (!newThreadId && !managed.session.threadId) { + const recoveredThreadId = await waitForCodexThreadIdentity(runtime); + if (recoveredThreadId) { + setCodexThreadIdentity(managed, runtime, recoveredThreadId); + } } runtime.threadResumed = true; + runtime.pendingThreadRebind = false; persistChatState(managed); // Fetch available skills and populate slash commands @@ -6045,7 +6456,6 @@ export function createAgentChatService(args: { chatConfig.claudePermissionMode, ); managed.session.claudePermissionMode = claudePermissionMode; - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; const lightweight = isLightweightSession(managed.session); const claudeExecutable = resolveClaudeCodeExecutable(); const opts: ClaudeSDKOptions = { @@ -6324,6 +6734,7 @@ export function createAgentChatService(args: { laneId: "temporary", provider: "codex", model: DEFAULT_CODEX_MODEL, + modelId: fallbackModelIdForProvider("codex"), capabilityMode: "full_mcp", status: "idle", createdAt: nowIso(), @@ -6349,6 +6760,7 @@ export function createAgentChatService(args: { bufferedText: null, recentConversationEntries: [], eventSequence: 0, + recoveryState: createRecoveryState(), }; let runtime: CodexRuntime | null = null; @@ -6484,7 +6896,6 @@ export function createAgentChatService(args: { codexSandbox: requestedCodexSandbox, codexConfigSource: requestedCodexConfigSource, unifiedPermissionMode: requestedUnifiedPermissionMode, - permissionMode: requestedPermMode, identityKey, surface, automationId, @@ -6506,15 +6917,17 @@ export function createAgentChatService(args: { ? DEFAULT_CLAUDE_MODEL : ""); // Resolve modelId from registry if provided - const resolvedModelId = modelId && getModelById(modelId) + const inferredModelId = modelId && getModelById(modelId) ? modelId : resolveModelIdFromStoredValue(normalizedInputModel, provider); + const resolvedModelId = inferredModelId ?? (provider === "unified" ? undefined : fallbackModelIdForProvider(provider)); if (provider === "unified" && !resolvedModelId) { throw new Error("Unified chat requires a known model ID. Select a model from the registry."); } - const resolvedDescriptor = resolvedModelId ? getModelById(resolvedModelId) : undefined; + const ensuredModelId = resolvedModelId ?? fallbackModelIdForProvider(provider); + const resolvedDescriptor = getModelById(ensuredModelId); if (resolvedModelId && !resolvedDescriptor) { throw new Error(`Unknown model '${resolvedModelId}'.`); } @@ -6530,7 +6943,7 @@ export function createAgentChatService(args: { ); } effectiveProvider = resolved; - normalizedModel = resolvedDescriptor.isCliWrapped ? resolvedDescriptor.shortId : resolvedDescriptor.id; + normalizedModel = getRuntimeModelRefForDescriptor(resolvedDescriptor, resolved); } const rawEffort = effectiveProvider === "codex" @@ -6541,39 +6954,27 @@ export function createAgentChatService(args: { : validateReasoningEffort(effectiveProvider === "claude" ? "claude" : "codex", rawEffort); const capabilityMode = inferCapabilityMode(effectiveProvider); const computerUsePolicy = normalizeComputerUsePolicy(computerUse, createDefaultComputerUsePolicy()); - const effectivePermissionMode = identityKey - ? normalizeIdentityPermissionMode(requestedPermMode, effectiveProvider) - : requestedPermMode; const chatConfig = resolveChatConfig(); const nativePermissionFields = (() => { if (effectiveProvider === "claude") { - const claudePermissionMode = requestedClaudePermissionMode - ?? legacyPermissionModeToClaudePermissionMode(effectivePermissionMode) - ?? chatConfig.claudePermissionMode; - return { claudePermissionMode }; + return { + claudePermissionMode: requestedClaudePermissionMode ?? chatConfig.claudePermissionMode, + }; } if (effectiveProvider === "codex") { - const codexConfigSource = requestedCodexConfigSource - ?? legacyPermissionModeToCodexConfigSource(effectivePermissionMode) - ?? "flags"; + const codexConfigSource = requestedCodexConfigSource ?? "flags"; if (codexConfigSource === "config-toml") { return { codexConfigSource }; } return { - codexApprovalPolicy: requestedCodexApprovalPolicy - ?? legacyPermissionModeToCodexApprovalPolicy(effectivePermissionMode) - ?? chatConfig.codexApprovalPolicy, - codexSandbox: requestedCodexSandbox - ?? legacyPermissionModeToCodexSandbox(effectivePermissionMode) - ?? chatConfig.codexSandboxMode, + codexApprovalPolicy: requestedCodexApprovalPolicy ?? chatConfig.codexApprovalPolicy, + codexSandbox: requestedCodexSandbox ?? chatConfig.codexSandboxMode, codexConfigSource, }; } return { - unifiedPermissionMode: requestedUnifiedPermissionMode - ?? legacyPermissionModeToUnifiedPermissionMode(effectivePermissionMode) - ?? chatConfig.unifiedPermissionMode, + unifiedPermissionMode: requestedUnifiedPermissionMode ?? chatConfig.unifiedPermissionMode, }; })(); @@ -6595,11 +6996,10 @@ export function createAgentChatService(args: { laneId, provider: effectiveProvider, model: normalizedModel, - ...(resolvedModelId ? { modelId: resolvedModelId } : {}), + modelId: ensuredModelId, sessionProfile: sessionProfile ?? "workflow", ...(normalizedReasoningEffort ? { reasoningEffort: normalizedReasoningEffort } : {}), ...nativePermissionFields, - ...(effectivePermissionMode ? { permissionMode: effectivePermissionMode } : {}), ...(identityKey ? { identityKey } : {}), surface: surface ?? "work", automationId: automationId?.trim() ? automationId.trim() : null, @@ -6631,6 +7031,7 @@ export function createAgentChatService(args: { bufferedText: null, recentConversationEntries: [], eventSequence: 0, + recoveryState: createRecoveryState(), }; normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; @@ -6700,7 +7101,7 @@ export function createAgentChatService(args: { } const targetProvider = resolveProviderGroupForModel(targetDescriptor); - const targetModel = targetDescriptor.isCliWrapped ? targetDescriptor.shortId : targetDescriptor.id; + const targetModel = getRuntimeModelRefForDescriptor(targetDescriptor, targetProvider); const targetReasoningEffort = pickHandoffReasoningEffort( targetDescriptor, managed.session.reasoningEffort ?? sourceSession.reasoningEffort, @@ -6731,7 +7132,6 @@ export function createAgentChatService(args: { codexSandbox: managed.session.codexSandbox, codexConfigSource: managed.session.codexConfigSource, unifiedPermissionMode: managed.session.unifiedPermissionMode, - permissionMode: managed.session.permissionMode, surface: managed.session.surface, computerUse: managed.session.computerUse, }); @@ -6835,6 +7235,7 @@ export function createAgentChatService(args: { if (managed.closed) return; const message = error instanceof Error ? error.message : String(error); + const normalizedMessage = message.toLowerCase(); const turnId = randomUUID(); managed.session.status = "idle"; @@ -6859,6 +7260,33 @@ export function createAgentChatService(args: { message, turnId, }); + if ( + normalizedMessage.includes("missing thread id") + || normalizedMessage.includes("lost its thread") + || (normalizedMessage.includes("session") && normalizedMessage.includes("missing")) + ) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "thread_error", + message: "This chat session hit a thread-level failure.", + detail: message, + turnId, + }); + } else if ( + normalizedMessage.includes("auth") + || normalizedMessage.includes("authentication") + || normalizedMessage.includes("network") + || normalizedMessage.includes("timed out") + || normalizedMessage.includes("rate limit") + ) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "provider_health", + message: `${managed.session.provider} runtime issue`, + detail: message, + turnId, + }); + } emitChatEvent(managed, { type: "status", turnStatus: "failed", @@ -6930,8 +7358,9 @@ export function createAgentChatService(args: { ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); - managed.session.threadId = threadIdToResume; + setCodexThreadIdentity(managed, runtime, threadIdToResume); runtime.threadResumed = true; + runtime.pendingThreadRebind = false; // Fetch skills after resume if not already fetched if (runtime.slashCommands.length === 0) { runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) @@ -7027,13 +7456,13 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "user_message", text: trimmed, - turnId: runtime.activeTurnId ?? undefined, + deliveryState: "queued", }); emitChatEvent(managed, { type: "system_notice", noticeKind: "info", - message: "Message queued — will be sent when the current turn completes.", - turnId: runtime.activeTurnId ?? undefined, + message: "Message queued for the next turn.", + detail: "ADE is still inside the current turn, so this follow-up will send as soon as that turn finishes.", }); persistChatState(managed); return; @@ -7084,13 +7513,15 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "user_message", text: trimmed, - turnId: runtime.activeTurnId ?? undefined, + deliveryState: "queued", }); emitChatEvent(managed, { type: "system_notice", noticeKind: "info", - message: "Message queued — will be sent when the current turn completes.", - turnId: runtime.activeTurnId ?? undefined, + message: "Message queued for the next turn.", + detail: runtime.activeSubagents.size > 0 + ? `Claude is still busy in the current turn with ${runtime.activeSubagents.size} active subagent${runtime.activeSubagents.size === 1 ? "" : "s"}, so ADE will send this follow-up after that turn finishes.` + : "Claude is still busy in the current turn, so ADE will send this follow-up after that turn finishes.", }); persistChatState(managed); return; @@ -7133,8 +7564,15 @@ export function createAgentChatService(args: { }); runtime.interrupted = true; cancelClaudeWarmup(managed, runtime, "interrupt"); - runtime.activeQuery?.interrupt().catch(() => {}); - // Close the V2 session on interrupt — it will be recreated on the next turn + const streamGen = runtime.v2StreamGen; + if (streamGen && typeof streamGen.return === "function") { + try { + await streamGen.return(undefined as never); + } catch { + // ignore stream termination failures during interrupt + } + } + // Close the V2 session on interrupt — it will be recreated on the next turn. try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; runtime.v2StreamGen = null; @@ -7172,9 +7610,9 @@ export function createAgentChatService(args: { ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); - managed.session.threadId = threadId; + setCodexThreadIdentity(managed, runtime, threadId); runtime.threadResumed = true; - sessionService.setResumeCommand(sessionId, `chat:codex:${threadId}`); + runtime.pendingThreadRebind = false; // Fetch skills after resume if not already fetched if (runtime.slashCommands.length === 0) { runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) @@ -7217,7 +7655,6 @@ export function createAgentChatService(args: { managed.runtime.messages = persistedMessages.map((m) => ({ role: m.role, content: m.content })); } managed.session.unifiedPermissionMode = persisted?.unifiedPermissionMode ?? managed.session.unifiedPermissionMode; - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; managed.runtime.permissionMode = resolveSessionUnifiedPermissionMode( managed.session, resolveChatConfig().unifiedPermissionMode, @@ -7272,7 +7709,6 @@ export function createAgentChatService(args: { ...(persisted?.codexSandbox ? { codexSandbox: persisted.codexSandbox } : {}), ...(persisted?.codexConfigSource ? { codexConfigSource: persisted.codexConfigSource } : {}), ...(persisted?.unifiedPermissionMode ? { unifiedPermissionMode: persisted.unifiedPermissionMode } : {}), - ...(persisted?.permissionMode ? { permissionMode: persisted.permissionMode } : {}), ...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}), surface: persisted?.surface ?? "work", automationId: persisted?.automationId ?? null, @@ -7318,7 +7754,6 @@ export function createAgentChatService(args: { laneId: string; modelId?: string | null; reasoningEffort?: string | null; - permissionMode?: AgentChatSession["permissionMode"]; reuseExisting?: boolean; }): Promise => { const laneId = args.laneId.trim(); @@ -7340,11 +7775,6 @@ export function createAgentChatService(args: { if (args.reasoningEffort) { managed.session.reasoningEffort = normalizeReasoningEffort(args.reasoningEffort); } - managed.session.permissionMode = normalizeIdentityPermissionMode( - args.permissionMode ?? managed.session.permissionMode, - managed.session.provider, - ); - applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); refreshReconstructionContext(managed); persistChatState(managed); @@ -7399,13 +7829,20 @@ export function createAgentChatService(args: { ? workerAdapterConfig.model.trim() : fallbackModelForProvider(provider); + // Identity sessions default to full-auto via provider-native fields. + const identityPermissionFields = (() => { + if (provider === "claude") return { claudePermissionMode: "bypassPermissions" as const }; + if (provider === "codex") return { codexApprovalPolicy: "never" as const, codexSandbox: "danger-full-access" as const }; + return { unifiedPermissionMode: "full-auto" as const }; + })(); + const created = await createSession({ laneId, provider, model: preferredModel, ...(resolvedModelId ? { modelId: resolvedModelId } : {}), reasoningEffort: args.reasoningEffort ?? pref?.reasoningEffort ?? null, - permissionMode: args.permissionMode ?? "full-auto", + ...identityPermissionFields, identityKey: args.identityKey }); @@ -7477,7 +7914,6 @@ export function createAgentChatService(args: { managed.runtime.permissionMode = "edit"; managed.session.unifiedPermissionMode = "edit"; } - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; } managed.runtime.pendingApprovals.delete(itemId); pending.resolve({ decision, answers, responseText }); @@ -7597,7 +8033,6 @@ export function createAgentChatService(args: { codexSandbox, codexConfigSource, unifiedPermissionMode, - permissionMode, computerUse, }: AgentChatUpdateSessionArgs): Promise => { const managed = ensureManagedSession(sessionId); @@ -7605,6 +8040,8 @@ export function createAgentChatService(args: { const isIdentitySession = Boolean(managed.session.identityKey); const hasConversation = managed.recentConversationEntries.length > 0 || readTranscriptConversationEntries(managed).length > 0; let resetRuntimeForComputerUse = false; + let claudeNativeSettingsChanged = false; + let codexThreadSettingsChanged = false; if (modelId !== undefined) { const nextModelId = String(modelId ?? "").trim(); @@ -7618,7 +8055,10 @@ export function createAgentChatService(args: { } const nextProvider: AgentChatProvider = resolveProviderGroupForModel(descriptor); - const nextModel = descriptor.isCliWrapped ? descriptor.shortId : descriptor.id; + const nextModel = getRuntimeModelRefForDescriptor( + descriptor, + isModelProviderGroup(nextProvider) ? nextProvider : undefined, + ); const previousModelId = managed.session.modelId ?? resolveModelIdFromStoredValue(managed.session.model, managed.session.provider) ?? managed.session.model; @@ -7663,13 +8103,6 @@ export function createAgentChatService(args: { resumeCommand: resumeCommandForProvider(nextProvider, sessionId) }); - if (isIdentitySession) { - managed.session.permissionMode = normalizeIdentityPermissionMode( - managed.session.permissionMode, - nextProvider, - ); - applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); - } normalizeSessionNativePermissionControls(managed.session, chatConfig); // Apply reasoningEffort BEFORE pre-warming so the V2 session is created @@ -7717,26 +8150,23 @@ export function createAgentChatService(args: { } } - if (permissionMode !== undefined) { - managed.session.permissionMode = isIdentitySession - ? normalizeIdentityPermissionMode(permissionMode, managed.session.provider) - : permissionMode; - applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); - } - if (claudePermissionMode !== undefined) { + claudeNativeSettingsChanged = managed.session.claudePermissionMode !== claudePermissionMode; managed.session.claudePermissionMode = claudePermissionMode; } if (codexApprovalPolicy !== undefined) { + codexThreadSettingsChanged = codexThreadSettingsChanged || managed.session.codexApprovalPolicy !== codexApprovalPolicy; managed.session.codexApprovalPolicy = codexApprovalPolicy; } if (codexSandbox !== undefined) { + codexThreadSettingsChanged = codexThreadSettingsChanged || managed.session.codexSandbox !== codexSandbox; managed.session.codexSandbox = codexSandbox; } if (codexConfigSource !== undefined) { + codexThreadSettingsChanged = codexThreadSettingsChanged || managed.session.codexConfigSource !== codexConfigSource; managed.session.codexConfigSource = codexConfigSource; } @@ -7745,8 +8175,7 @@ export function createAgentChatService(args: { } if ( - permissionMode !== undefined - || claudePermissionMode !== undefined + claudePermissionMode !== undefined || codexApprovalPolicy !== undefined || codexSandbox !== undefined || codexConfigSource !== undefined @@ -7761,6 +8190,61 @@ export function createAgentChatService(args: { } } + if (claudeNativeSettingsChanged && managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) { + if (managed.runtime.busy) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Interrupting the current Claude turn to apply permissions.", + detail: `Claude permission mode is now ${managed.session.claudePermissionMode ?? "default"}.`, + }); + managed.runtime.interrupted = true; + cancelClaudeWarmup(managed, managed.runtime, "session_reset"); + const streamGen = managed.runtime.v2StreamGen; + if (streamGen && typeof streamGen.return === "function") { + try { + await streamGen.return(undefined as never); + } catch { + // ignore interrupt errors + } + } + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + } else { + cancelClaudeWarmup(managed, managed.runtime, "session_reset"); + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + } + managed.runtime.v2Session = null; + managed.runtime.v2StreamGen = null; + managed.runtime.v2WarmupDone = null; + managed.runtime.pendingSessionReset = false; + } + + if (codexThreadSettingsChanged && managed.runtime?.kind === "codex") { + if (managed.runtime.activeTurnId && managed.session.threadId) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Interrupting the current Codex turn to apply settings.", + detail: "ADE will rebind this thread with the new approval, sandbox, or config source before the next message.", + }); + try { + await managed.runtime.request("turn/interrupt", { + threadId: managed.session.threadId, + turnId: managed.runtime.activeTurnId, + }); + } catch (error) { + logger.warn("agent_chat.codex_interrupt_for_settings_failed", { + sessionId, + threadId: managed.session.threadId, + turnId: managed.runtime.activeTurnId, + error: error instanceof Error ? error.message : String(error), + }); + managed.runtime.pendingThreadRebind = true; + } + } + managed.runtime.threadResumed = false; + } + if (computerUse !== undefined) { const nextComputerUse = normalizeComputerUsePolicy(computerUse, createDefaultComputerUsePolicy()); const prevComputerUse = managed.session.computerUse; @@ -7821,7 +8305,7 @@ export function createAgentChatService(args: { // picks up the correct model for warmup. managed.session.provider = "claude"; managed.session.modelId = descriptor.id; - managed.session.model = descriptor.shortId; + managed.session.model = getRuntimeModelRefForDescriptor(descriptor, "claude"); // Ensure a Claude runtime exists and kick off pre-warming ensureClaudeSessionRuntime(managed); @@ -7837,28 +8321,6 @@ export function createAgentChatService(args: { return deriveSessionCapabilities(managed); }; - const changePermissionMode = ({ sessionId, permissionMode }: import("../../../shared/types").AgentChatChangePermissionModeArgs): void => { - const managed = ensureManagedSession(sessionId); - const nextMode = managed.session.identityKey - ? normalizeIdentityPermissionMode(permissionMode, managed.session.provider) - : permissionMode; - managed.session.permissionMode = nextMode; - applyLegacyPermissionModeToNativeControls(managed.session, nextMode); - normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); - if (managed.runtime?.kind === "unified") { - managed.runtime.permissionMode = resolveSessionUnifiedPermissionMode( - managed.session, - resolveChatConfig().unifiedPermissionMode, - ); - } - persistChatState(managed); - - logger.info("agent_chat.permission_mode_changed", { - sessionId, - permissionMode: nextMode, - }); - }; - const getSlashCommands = ({ sessionId }: import("../../../shared/types").AgentChatSlashCommandsArgs): import("../../../shared/types").AgentChatSlashCommand[] => { const managed = managedSessions.get(sessionId); if (!managed) return []; @@ -8033,7 +8495,6 @@ export function createAgentChatService(args: { disposeAll, updateSession, warmupModel, - changePermissionMode, listSubagents, getSessionCapabilities, /** Clean up temp attachment files older than 7 days. Call on app startup. */ @@ -8057,6 +8518,16 @@ export function createAgentChatService(args: { }, setComputerUseArtifactBrokerService(svc: ComputerUseArtifactBrokerService) { computerUseArtifactBrokerRef = svc; + // Detach the old observer so its session-tracking state is released. + // Clear all active sessions before dropping the reference so any + // in-flight de-duplication sets are freed eagerly rather than waiting + // for GC. + if (proofObserver) { + for (const sessionId of managedSessions.keys()) { + proofObserver.clearSession(sessionId); + } + proofObserver = null; + } proofObserver = createProofObserver({ broker: svc }); }, }; diff --git a/apps/desktop/src/main/services/chat/sessionRecovery.test.ts b/apps/desktop/src/main/services/chat/sessionRecovery.test.ts new file mode 100644 index 000000000..c09fafee5 --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionRecovery.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from "vitest"; +import { + canAttemptRecovery, + createRecoveryNoticeEvent, + createRecoveryState, + getRecoveryBackoffMs, + isRecoverableError, + markRecoveryAttempt, + markRecoveryComplete, + markRecoverySuccess, + resetRecoveryState, + type RecoveryState, +} from "./sessionRecovery"; + +describe("createRecoveryState", () => { + it("returns a fresh state with zero attempts and no error", () => { + const state = createRecoveryState(); + expect(state).toEqual({ + attempts: 0, + lastAttemptAt: 0, + recovering: false, + lastError: null, + }); + }); + + it("returns a new object on each call", () => { + const a = createRecoveryState(); + const b = createRecoveryState(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); +}); + +describe("canAttemptRecovery", () => { + it("allows recovery on a fresh state", () => { + expect(canAttemptRecovery(createRecoveryState())).toBe(true); + }); + + it("disallows recovery when already recovering", () => { + const state: RecoveryState = { + attempts: 0, + lastAttemptAt: 0, + recovering: true, + lastError: null, + }; + expect(canAttemptRecovery(state)).toBe(false); + }); + + it("disallows recovery after max attempts (3)", () => { + const state: RecoveryState = { + attempts: 3, + lastAttemptAt: Date.now(), + recovering: false, + lastError: "some error", + }; + expect(canAttemptRecovery(state)).toBe(false); + }); + + it("allows recovery with 1 or 2 attempts used", () => { + expect(canAttemptRecovery({ attempts: 1, lastAttemptAt: Date.now(), recovering: false, lastError: "err" })).toBe(true); + expect(canAttemptRecovery({ attempts: 2, lastAttemptAt: Date.now(), recovering: false, lastError: "err" })).toBe(true); + }); + + it("disallows recovery when attempts exceed max", () => { + const state: RecoveryState = { + attempts: 5, + lastAttemptAt: 0, + recovering: false, + lastError: "old error", + }; + expect(canAttemptRecovery(state)).toBe(false); + }); +}); + +describe("getRecoveryBackoffMs", () => { + it("returns base backoff (2000ms) on first attempt", () => { + expect(getRecoveryBackoffMs({ attempts: 0, lastAttemptAt: 0, recovering: false, lastError: null })).toBe(2000); + }); + + it("doubles each attempt via exponential backoff", () => { + expect(getRecoveryBackoffMs({ attempts: 1, lastAttemptAt: 0, recovering: false, lastError: null })).toBe(4000); + expect(getRecoveryBackoffMs({ attempts: 2, lastAttemptAt: 0, recovering: false, lastError: null })).toBe(8000); + expect(getRecoveryBackoffMs({ attempts: 3, lastAttemptAt: 0, recovering: false, lastError: null })).toBe(16000); + }); + + it("caps the exponent at 4 regardless of attempt count", () => { + const at4 = getRecoveryBackoffMs({ attempts: 4, lastAttemptAt: 0, recovering: false, lastError: null }); + const at10 = getRecoveryBackoffMs({ attempts: 10, lastAttemptAt: 0, recovering: false, lastError: null }); + expect(at4).toBe(32000); + expect(at10).toBe(32000); + }); +}); + +describe("markRecoveryAttempt", () => { + it("increments attempts and sets recovering to true", () => { + const before = createRecoveryState(); + const after = markRecoveryAttempt(before, "connection lost"); + expect(after.attempts).toBe(1); + expect(after.recovering).toBe(true); + expect(after.lastError).toBe("connection lost"); + expect(after.lastAttemptAt).toBeGreaterThan(0); + }); + + it("returns a new object without mutating the original", () => { + const before = createRecoveryState(); + const after = markRecoveryAttempt(before, "fail"); + expect(before.attempts).toBe(0); + expect(before.recovering).toBe(false); + expect(after).not.toBe(before); + }); + + it("correctly increments from existing attempts", () => { + let state = createRecoveryState(); + state = markRecoveryAttempt(state, "err1"); + state = markRecoveryComplete(state); + state = markRecoveryAttempt(state, "err2"); + expect(state.attempts).toBe(2); + expect(state.lastError).toBe("err2"); + }); +}); + +describe("markRecoveryComplete", () => { + it("sets recovering to false without changing attempts", () => { + const recovering: RecoveryState = { + attempts: 2, + lastAttemptAt: Date.now(), + recovering: true, + lastError: "timeout", + }; + const done = markRecoveryComplete(recovering); + expect(done.recovering).toBe(false); + expect(done.attempts).toBe(2); + expect(done.lastError).toBe("timeout"); + }); + + it("returns a new object", () => { + const before: RecoveryState = { attempts: 1, lastAttemptAt: 0, recovering: true, lastError: "x" }; + const after = markRecoveryComplete(before); + expect(after).not.toBe(before); + expect(before.recovering).toBe(true); + }); +}); + +describe("markRecoverySuccess", () => { + it("resets attempts to 0, clears error, and stops recovering", () => { + const state: RecoveryState = { + attempts: 3, + lastAttemptAt: Date.now(), + recovering: true, + lastError: "was broken", + }; + const success = markRecoverySuccess(state); + expect(success.attempts).toBe(0); + expect(success.recovering).toBe(false); + expect(success.lastError).toBeNull(); + // lastAttemptAt is preserved from original state + expect(success.lastAttemptAt).toBe(state.lastAttemptAt); + }); +}); + +describe("resetRecoveryState", () => { + it("returns the same object when already clean", () => { + const clean = createRecoveryState(); + const result = resetRecoveryState(clean); + expect(result).toBe(clean); + }); + + it("returns a fresh state when attempts > 0", () => { + const dirty: RecoveryState = { attempts: 2, lastAttemptAt: 500, recovering: false, lastError: "err" }; + const result = resetRecoveryState(dirty); + expect(result).toEqual(createRecoveryState()); + expect(result).not.toBe(dirty); + }); + + it("returns a fresh state when recovering is true", () => { + const busy: RecoveryState = { attempts: 0, lastAttemptAt: 0, recovering: true, lastError: null }; + const result = resetRecoveryState(busy); + expect(result).toEqual(createRecoveryState()); + }); +}); + +describe("isRecoverableError", () => { + describe("terminal errors (not recoverable)", () => { + it.each([ + "Authentication failed for API", + "Unauthorized access to resource", + "Invalid API key provided", + "Billing issue: payment required", + "Quota exceeded for this model", + "Rate limit reached, try later", + "Permission denied: cannot access", + "Access denied to this resource", + "Model not found in registry", + "Command not found: claude-cli", + ])("returns false for terminal error: %s", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + }); + }); + + describe("recoverable errors (transient)", () => { + it.each([ + "ECONNRESET: connection was reset", + "ECONNREFUSED: server not available", + "EPIPE: broken pipe in stream", + "spawn ENOENT: process not found", + "Process received SIGTERM", + "Process received SIGKILL", + "Process exited with code 1", + "Child process crashed unexpectedly", + "Request timeout exceeded 30s", + "Connection timed out after 10s", + "Stream closed unexpectedly", + "Stream ended prematurely", + "Stream was destroyed by peer", + "Unexpected end of JSON input", + "Connection closed by server", + ])("returns true for recoverable error: %s", (msg) => { + expect(isRecoverableError(msg)).toBe(true); + }); + }); + + it("accepts Error objects in addition to strings", () => { + expect(isRecoverableError(new Error("ECONNRESET"))).toBe(true); + expect(isRecoverableError(new Error("Authentication failed"))).toBe(false); + }); + + it("defaults to recoverable for unknown errors", () => { + expect(isRecoverableError("Some unknown internal error occurred")).toBe(true); + expect(isRecoverableError("")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isRecoverableError("AUTHENTICATION FAILED")).toBe(false); + expect(isRecoverableError("econnreset")).toBe(true); + }); +}); + +describe("createRecoveryNoticeEvent", () => { + it("creates an 'attempting' notice with attempt count", () => { + const event = createRecoveryNoticeEvent({ + attempt: 1, + maxAttempts: 3, + error: "connection lost", + status: "attempting", + }); + expect(event).toEqual({ + type: "system_notice", + noticeKind: "provider_health", + message: "Reconnecting to agent (attempt 1/3)...", + detail: undefined, + }); + }); + + it("creates a 'succeeded' notice", () => { + const event = createRecoveryNoticeEvent({ + attempt: 2, + maxAttempts: 3, + error: "timeout", + status: "succeeded", + }); + expect(event).toEqual({ + type: "system_notice", + noticeKind: "provider_health", + message: "Successfully reconnected to agent.", + detail: undefined, + }); + }); + + it("creates a 'failed' notice with error detail", () => { + const event = createRecoveryNoticeEvent({ + attempt: 3, + maxAttempts: 3, + error: "ECONNREFUSED", + status: "failed", + }); + expect(event).toEqual({ + type: "system_notice", + noticeKind: "provider_health", + message: "Failed to reconnect after 3 attempts: ECONNREFUSED", + detail: "ECONNREFUSED", + }); + }); + + it("only includes detail for failed status", () => { + const attempting = createRecoveryNoticeEvent({ attempt: 1, maxAttempts: 3, error: "err", status: "attempting" }); + const succeeded = createRecoveryNoticeEvent({ attempt: 1, maxAttempts: 3, error: "err", status: "succeeded" }); + const failed = createRecoveryNoticeEvent({ attempt: 1, maxAttempts: 3, error: "err", status: "failed" }); + expect(attempting.detail).toBeUndefined(); + expect(succeeded.detail).toBeUndefined(); + expect(failed.detail).toBe("err"); + }); +}); diff --git a/apps/desktop/src/main/services/chat/sessionRecovery.ts b/apps/desktop/src/main/services/chat/sessionRecovery.ts new file mode 100644 index 000000000..b9c9f7b72 --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionRecovery.ts @@ -0,0 +1,116 @@ +export type RecoveryState = { + /** Number of recovery attempts for this session. */ + attempts: number; + /** Last recovery attempt timestamp. */ + lastAttemptAt: number; + /** Whether recovery is currently in progress. */ + recovering: boolean; + /** The error that triggered recovery. */ + lastError: string | null; +}; + +const MAX_RECOVERY_ATTEMPTS = 3; +const RECOVERY_BACKOFF_BASE_MS = 2000; +const RECOVERY_COOLDOWN_MS = 30_000; + +export function createRecoveryState(): RecoveryState { + return { + attempts: 0, + lastAttemptAt: 0, + recovering: false, + lastError: null, + }; +} + +export function canAttemptRecovery(state: RecoveryState): boolean { + if (state.recovering) return false; + if (state.attempts >= MAX_RECOVERY_ATTEMPTS) return false; + const elapsed = Date.now() - state.lastAttemptAt; + // After MAX_RECOVERY_ATTEMPTS, require a cooldown before resetting + if (state.attempts >= MAX_RECOVERY_ATTEMPTS && elapsed < RECOVERY_COOLDOWN_MS) return false; + return true; +} + +export function getRecoveryBackoffMs(state: RecoveryState): number { + return RECOVERY_BACKOFF_BASE_MS * Math.pow(2, Math.min(state.attempts, 4)); +} + +export function markRecoveryAttempt(state: RecoveryState, error: string): RecoveryState { + return { + ...state, + attempts: state.attempts + 1, + lastAttemptAt: Date.now(), + recovering: true, + lastError: error, + }; +} + +export function markRecoveryComplete(state: RecoveryState): RecoveryState { + return { + ...state, + recovering: false, + }; +} + +export function markRecoverySuccess(state: RecoveryState): RecoveryState { + return { + ...state, + attempts: 0, + recovering: false, + lastError: null, + }; +} + +export function resetRecoveryState(state: RecoveryState): RecoveryState { + if (state.attempts === 0 && !state.recovering) return state; + return createRecoveryState(); +} + +/** + * Determines if an error is recoverable (transient) vs terminal. + * Terminal errors should not trigger recovery attempts. + */ +export function isRecoverableError(error: string | Error): boolean { + const message = typeof error === "string" ? error : error.message; + const lower = message.toLowerCase(); + + // Terminal errors - don't retry + if (lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key")) return false; + if (lower.includes("billing") || lower.includes("quota exceeded") || lower.includes("rate limit")) return false; + if (lower.includes("permission denied") || lower.includes("access denied")) return false; + if (lower.includes("not found") && (lower.includes("model") || lower.includes("command"))) return false; + + // Recoverable errors - process crashes, network issues, timeouts + if (lower.includes("econnreset") || lower.includes("econnrefused") || lower.includes("epipe")) return true; + if (lower.includes("spawn") || lower.includes("sigterm") || lower.includes("sigkill")) return true; + if (lower.includes("process exited") || lower.includes("child process")) return true; + if (lower.includes("timeout") || lower.includes("timed out")) return true; + if (lower.includes("stream") && (lower.includes("closed") || lower.includes("ended") || lower.includes("destroyed"))) return true; + if (lower.includes("unexpected end") || lower.includes("connection closed")) return true; + + // Default: recoverable (optimistic) + return true; +} + +/** + * Emits a system notice event for recovery status. + */ +export function createRecoveryNoticeEvent(args: { + attempt: number; + maxAttempts: number; + error: string; + status: "attempting" | "succeeded" | "failed"; +}) { + const message = args.status === "attempting" + ? `Reconnecting to agent (attempt ${args.attempt}/${args.maxAttempts})...` + : args.status === "succeeded" + ? "Successfully reconnected to agent." + : `Failed to reconnect after ${args.maxAttempts} attempts: ${args.error}`; + + return { + type: "system_notice" as const, + noticeKind: "provider_health" as const, + message, + detail: args.status === "failed" ? args.error : undefined, + }; +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 146292469..e97cf3ca7 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -154,7 +154,7 @@ import type { ExportHistoryArgs, ExportHistoryResult, AgentChatApproveArgs, - AgentChatChangePermissionModeArgs, + AgentChatClaudePermissionMode, AgentChatCreateArgs, AgentChatDisposeArgs, AgentChatGetSummaryArgs, @@ -175,6 +175,7 @@ import type { AgentChatSessionCapabilities, AgentChatSessionCapabilitiesArgs, AgentChatSteerArgs, + AgentChatUnifiedPermissionMode, AgentChatUpdateSessionArgs, AgentChatSlashCommand, AgentChatSlashCommandsArgs, @@ -549,7 +550,7 @@ import type { createSyncHostService } from "../sync/syncHostService"; import type { createSyncService } from "../sync/syncService"; import type { AdeProjectService } from "../projects/adeProjectService"; import type { ConfigReloadService } from "../projects/configReloadService"; -import { getErrorMessage, isRecord, nowIso, toMemoryEntryDto, toOptionalString } from "../shared/utils"; +import { getErrorMessage, isRecord, isWithinDir, nowIso, toMemoryEntryDto, toOptionalString } from "../shared/utils"; export type AppContext = { db: AdeDb; @@ -1265,10 +1266,56 @@ function mapPrAiPermissionMode(mode: AiPermissionMode): AgentChatPermissionMode return "plan"; } -function mapAgentChatPermissionModeToPrAi(mode: AgentChatPermissionMode | null | undefined): AiPermissionMode | null { - if (mode === "full-auto") return "full_edit"; - if (mode === "edit") return "guarded_edit"; - if (mode === "plan" || mode === "default") return "read_only"; +/** + * Map an AiPermissionMode to provider-native permission fields for AgentChatCreateArgs. + */ +function mapPrAiPermissionModeToNativeFields( + mode: AiPermissionMode, + provider: string, +): Partial> { + const legacy = mapPrAiPermissionMode(mode); + if (provider === "claude") { + const map: Record = { + "full-auto": "bypassPermissions", + "edit": "acceptEdits", + "plan": "plan", + "default": "default", + }; + return { claudePermissionMode: map[legacy] ?? "default" }; + } + if (provider === "codex") { + if (legacy === "full-auto") return { codexApprovalPolicy: "never", codexSandbox: "danger-full-access" }; + if (legacy === "edit") return { codexApprovalPolicy: "on-failure", codexSandbox: "workspace-write" }; + return { codexApprovalPolicy: "untrusted", codexSandbox: "read-only" }; + } + const umap: Record = { + "full-auto": "full-auto", + "edit": "edit", + "plan": "plan", + }; + return { unifiedPermissionMode: umap[legacy] ?? "edit" }; +} + +function deriveAiPermissionModeFromSummary( + summary: Pick | null | undefined, +): AiPermissionMode | null { + if (!summary) return null; + if (summary.provider === "claude") { + if (summary.claudePermissionMode === "bypassPermissions") return "full_edit"; + if (summary.claudePermissionMode === "acceptEdits") return "guarded_edit"; + if (summary.claudePermissionMode === "plan") return "read_only"; + if (summary.claudePermissionMode === "default") return "read_only"; + return null; + } + if (summary.provider === "codex") { + if (summary.codexApprovalPolicy === "never" && summary.codexSandbox === "danger-full-access") return "full_edit"; + if (summary.codexApprovalPolicy === "on-failure") return "guarded_edit"; + if (summary.codexApprovalPolicy === "untrusted") return "read_only"; + return null; + } + if (summary.unifiedPermissionMode === "full-auto") return "full_edit"; + if (summary.unifiedPermissionMode === "edit") return "guarded_edit"; + if (summary.unifiedPermissionMode === "plan") return "read_only"; return null; } @@ -1627,8 +1674,9 @@ export function registerIpc({ } catch { throw new Error("Invalid URL"); } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Only http(s) URLs are allowed."); + const ALLOWED_URL_SCHEMES = new Set(["http:", "https:", "mailto:"]); + if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) { + throw new Error("Only http(s) and mailto: URLs are allowed."); } await shell.openExternal(parsed.toString()); }); @@ -1636,9 +1684,14 @@ export function registerIpc({ ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise => { const raw = typeof arg?.path === "string" ? arg.path.trim() : ""; if (!raw) return; - // Basic path boundary validation — reject obvious traversal patterns const normalized = path.resolve(raw); - if (normalized !== raw && raw.includes("..")) return; + // Validate the path is within the project workspace or user home directory. + // Reject requests to reveal arbitrary system paths (e.g. /etc, /System). + const projectRoot = getCtx().project.rootPath; + const homeDir = app.getPath("home"); + if (!isWithinDir(projectRoot, normalized) && !isWithinDir(homeDir, normalized)) { + throw new Error("Path is outside allowed directories."); + } shell.showItemInFolder(normalized); }); @@ -3729,11 +3782,6 @@ export function registerIpc({ return ctx.agentChatService.warmupModel(arg); }); - ipcMain.handle(IPC.agentChatChangePermissionMode, async (_event, arg: AgentChatChangePermissionModeArgs): Promise => { - const ctx = getCtx(); - ctx.agentChatService.changePermissionMode(arg); - }); - ipcMain.handle(IPC.agentChatSlashCommands, async (_event, arg: AgentChatSlashCommandsArgs): Promise => { const ctx = getCtx(); return ctx.agentChatService.getSlashCommands(arg); @@ -4555,7 +4603,7 @@ export function registerIpc({ model: summary?.model ?? runtime.modelId, modelId: summary?.modelId ?? runtime.modelId, reasoning: summary?.reasoningEffort ?? runtime.reasoning, - permissionMode: mapAgentChatPermissionModeToPrAi(summary?.permissionMode) ?? runtime.permissionMode, + permissionMode: deriveAiPermissionModeFromSummary(summary) ?? runtime.permissionMode, status: "running", }); } @@ -4578,7 +4626,7 @@ export function registerIpc({ model: summary?.model ?? persistedRun.model ?? null, modelId: summary?.modelId ?? persistedRun.model ?? null, reasoning: summary?.reasoningEffort ?? persistedRun.reasoningEffort ?? null, - permissionMode: mapAgentChatPermissionModeToPrAi(summary?.permissionMode) ?? persistedRun.permissionMode ?? null, + permissionMode: deriveAiPermissionModeFromSummary(summary) ?? persistedRun.permissionMode ?? null, status: mapExternalResolverStatusToPrAi(persistedRun.status), }); }); @@ -4680,7 +4728,7 @@ export function registerIpc({ model: modelDescriptor?.shortId ?? model, ...(modelDescriptor?.id ? { modelId: modelDescriptor.id } : {}), ...(reasoning ? { reasoningEffort: reasoning } : {}), - permissionMode: mapPrAiPermissionMode(permissionMode) + ...mapPrAiPermissionModeToNativeFields(permissionMode, provider), }); const promptText = fs.readFileSync(prep.promptFilePath, "utf8"); const runtimeContext: PrAiResolutionContext = { @@ -5395,7 +5443,6 @@ export function registerIpc({ laneId, modelId: arg.modelId ?? null, reasoningEffort: arg.reasoningEffort ?? null, - permissionMode: arg.permissionMode, }); }); @@ -5465,7 +5512,6 @@ export function registerIpc({ laneId, modelId: arg.modelId ?? null, reasoningEffort: arg.reasoningEffort ?? null, - permissionMode: arg.permissionMode, }); }); diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts new file mode 100644 index 000000000..2b0817643 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts @@ -0,0 +1,699 @@ +import { afterEach, describe, expect, it, beforeEach, vi } from "vitest"; +import { createAutoRebaseService } from "./autoRebaseService"; +import type { AutoRebaseEventPayload, AutoRebaseLaneStatus, LaneSummary } from "../../../shared/types"; + +vi.mock("../git/git", () => ({ + getHeadSha: vi.fn().mockResolvedValue("abc123"), +})); + +vi.mock("../shared/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + nowIso: vi.fn(() => "2026-03-25T12:00:00.000Z"), + }; +}); + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; +} + +function createDb() { + const store = new Map(); + return { + getJson: vi.fn((key: string) => store.get(key) ?? null), + setJson: vi.fn((key: string, value: unknown) => { + if (value === null || value === undefined) { + store.delete(key); + } else { + store.set(key, value); + } + }), + _store: store, + } as any; +} + +function makeLane(id: string, overrides: Partial = {}): LaneSummary { + return { + id, + name: overrides.name ?? `Lane ${id}`, + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: `refs/heads/feature/${id}`, + worktreePath: `/tmp/${id}`, + attachedRootPath: null, + parentLaneId: overrides.parentLaneId ?? null, + childCount: overrides.childCount ?? 0, + stackDepth: overrides.stackDepth ?? 0, + parentStatus: overrides.parentStatus ?? null, + isEditProtected: false, + status: overrides.status ?? { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: -1, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: overrides.createdAt ?? "2026-03-10T00:00:00.000Z", + archivedAt: null, + }; +} + +describe("autoRebaseService", () => { + let db: ReturnType; + let events: AutoRebaseEventPayload[]; + let laneList: LaneSummary[]; + let laneService: any; + let conflictService: any; + let projectConfigService: any; + + beforeEach(() => { + vi.clearAllMocks(); + db = createDb(); + events = []; + laneList = []; + laneService = { + list: vi.fn(async () => laneList), + rebaseStart: vi.fn(async () => ({ run: { error: null } })), + }; + conflictService = { + simulateMerge: vi.fn(async () => ({ outcome: "clean", conflictingFiles: [] })), + }; + projectConfigService = { + getEffective: vi.fn(() => ({ git: { autoRebaseOnHeadChange: true } })), + }; + }); + + function createService() { + return createAutoRebaseService({ + db, + logger: createLogger(), + laneService, + conflictService, + projectConfigService, + onEvent: (event) => events.push(event), + }); + } + + // --------------------------------------------------------------------------- + // sanitizeStoredStatus / TTL expiration + // --------------------------------------------------------------------------- + + describe("listStatuses — TTL expiration", () => { + it("includes autoRebased status when within the 15-minute TTL", async () => { + const service = createService(); + const now = Date.now(); + + laneList = [makeLane("lane-a", { parentLaneId: "root", status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false } })]; + + // Store a status that was updated 5 minutes ago (within TTL) + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "autoRebased", + updatedAt: new Date(now - 5 * 60_000).toISOString(), + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0].state).toBe("autoRebased"); + }); + + it("clears autoRebased status after the 15-minute TTL has elapsed", async () => { + const service = createService(); + const now = Date.now(); + + laneList = [makeLane("lane-a", { parentLaneId: "root", status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false } })]; + + // Store a status that was updated 20 minutes ago (past TTL) + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "autoRebased", + updatedAt: new Date(now - 20 * 60_000).toISOString(), + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + // Verify it was cleared from the db + expect(db.getJson("auto_rebase:status:lane-a")).toBeNull(); + }); + + it("clears autoRebased status with malformed updatedAt date", async () => { + const service = createService(); + + laneList = [makeLane("lane-a", { parentLaneId: "root", status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false } })]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "autoRebased", + updatedAt: "not-a-real-date", + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + expect(db.getJson("auto_rebase:status:lane-a")).toBeNull(); + }); + + it("clears autoRebased status with empty updatedAt string", async () => { + const service = createService(); + + laneList = [makeLane("lane-a", { parentLaneId: "root", status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false } })]; + + // sanitizeStoredStatus returns null when updatedAt is empty + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "autoRebased", + updatedAt: "", + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + // sanitizeStoredStatus returns null for empty updatedAt, so no status is loaded + expect(statuses).toHaveLength(0); + }); + }); + + // --------------------------------------------------------------------------- + // Lanes without parent are skipped + // --------------------------------------------------------------------------- + + describe("listStatuses — lanes without parent", () => { + it("clears status for a lane that has no parentLaneId", async () => { + const service = createService(); + + // Lane with no parent + laneList = [makeLane("lane-orphan", { parentLaneId: null })]; + + db.setJson("auto_rebase:status:lane-orphan", { + laneId: "lane-orphan", + parentLaneId: null, + parentHeadSha: null, + state: "rebasePending", + updatedAt: "2026-03-25T11:00:00.000Z", + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + expect(db.getJson("auto_rebase:status:lane-orphan")).toBeNull(); + }); + }); + + // --------------------------------------------------------------------------- + // Non-autoRebased status cleared when behind <= 0 + // --------------------------------------------------------------------------- + + describe("listStatuses — behind count", () => { + it("clears non-autoRebased status when lane is not behind its parent", async () => { + const service = createService(); + + // Lane has parent but is not behind + laneList = [makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 1, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + })]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "rebasePending", + updatedAt: "2026-03-25T11:00:00.000Z", + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + }); + + it("keeps non-autoRebased status when lane is behind its parent", async () => { + const service = createService(); + + const root = makeLane("root"); + const child = makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 3, remoteBehind: 0, rebaseInProgress: false }, + }); + laneList = [root, child]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "rebasePending", + updatedAt: "2026-03-25T11:00:00.000Z", + conflictCount: 0, + message: "Pending.", + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0].laneId).toBe("lane-a"); + expect(statuses[0].state).toBe("rebasePending"); + }); + }); + + // --------------------------------------------------------------------------- + // Parent lane disappearance + // --------------------------------------------------------------------------- + + describe("listStatuses — parent lane disappearance", () => { + it("clears status when the stored parentLaneId no longer exists in lane list", async () => { + const service = createService(); + + // Lane references a parent that does not exist + laneList = [makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + })]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "deleted-parent", + parentHeadSha: "abc", + state: "rebasePending", + updatedAt: "2026-03-25T11:00:00.000Z", + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + expect(db.getJson("auto_rebase:status:lane-a")).toBeNull(); + }); + + it("keeps status when parentLaneId in stored status is null", async () => { + const service = createService(); + + // Status has null parentLaneId — the check `status.parentLaneId && !laneById.has(...)` is falsy + const root = makeLane("root"); + const child = makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + }); + laneList = [root, child]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: null, + parentHeadSha: null, + state: "rebaseConflict", + updatedAt: "2026-03-25T11:00:00.000Z", + conflictCount: 2, + message: "Conflict.", + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0].state).toBe("rebaseConflict"); + }); + }); + + // --------------------------------------------------------------------------- + // Sorting of returned statuses + // --------------------------------------------------------------------------- + + describe("listStatuses — sorting", () => { + it("returns statuses sorted by updatedAt descending", async () => { + const service = createService(); + const now = Date.now(); + + const root = makeLane("root"); + const a = makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + }); + const b = makeLane("lane-b", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + }); + laneList = [root, a, b]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "rebasePending", + updatedAt: new Date(now - 10_000).toISOString(), + conflictCount: 0, + message: null, + }); + + db.setJson("auto_rebase:status:lane-b", { + laneId: "lane-b", + parentLaneId: "root", + parentHeadSha: "def", + state: "rebaseConflict", + updatedAt: new Date(now - 5_000).toISOString(), + conflictCount: 1, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(2); + // lane-b was updated more recently, so it should come first + expect(statuses[0].laneId).toBe("lane-b"); + expect(statuses[1].laneId).toBe("lane-a"); + }); + }); + + // --------------------------------------------------------------------------- + // sanitizeStoredStatus edge cases + // --------------------------------------------------------------------------- + + describe("listStatuses — malformed stored data", () => { + it("ignores stored data that is not a valid record", async () => { + const service = createService(); + + laneList = [makeLane("lane-a", { parentLaneId: "root" })]; + db.setJson("auto_rebase:status:lane-a", "just a string"); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + }); + + it("ignores stored data with unrecognized state value", async () => { + const service = createService(); + + laneList = [makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + })]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "unknownState", + updatedAt: "2026-03-25T11:00:00.000Z", + conflictCount: 0, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(0); + }); + + it("sanitizes negative conflictCount to zero", async () => { + const service = createService(); + const now = Date.now(); + + const root = makeLane("root"); + const child = makeLane("lane-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + }); + laneList = [root, child]; + + db.setJson("auto_rebase:status:lane-a", { + laneId: "lane-a", + parentLaneId: "root", + parentHeadSha: "abc", + state: "rebaseConflict", + updatedAt: new Date(now).toISOString(), + conflictCount: -5, + message: null, + }); + + const statuses = await service.listStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0].conflictCount).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // emit + // --------------------------------------------------------------------------- + + describe("emit", () => { + it("calls onEvent with the current statuses", async () => { + const service = createService(); + + laneList = []; + await service.emit(); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("auto-rebase-updated"); + expect(events[0].statuses).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // onHeadChanged — gating + // --------------------------------------------------------------------------- + + describe("onHeadChanged", () => { + it("does nothing when auto-rebase is disabled", async () => { + projectConfigService.getEffective.mockReturnValue({ git: { autoRebaseOnHeadChange: false } }); + const service = createService(); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + // No queue should have been scheduled — laneService.list should not be called + expect(laneService.list).not.toHaveBeenCalled(); + }); + + it("ignores events with reason starting with auto_rebase", async () => { + const service = createService(); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "auto_rebase_cascade", + }); + + expect(laneService.list).not.toHaveBeenCalled(); + }); + + it("ignores events with empty laneId", async () => { + const service = createService(); + + await service.onHeadChanged({ + laneId: " ", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + expect(laneService.list).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // processRoot — cascade behavior (tested indirectly via onHeadChanged + timers) + // + // processRoot is the core cascade logic. It is not directly exported, but is + // invoked via the debounced queue triggered by onHeadChanged. We test it by + // triggering onHeadChanged and advancing fake timers. + // --------------------------------------------------------------------------- + + describe("processRoot cascade via onHeadChanged", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("skips processing when root lane is not found", async () => { + const service = createService(); + laneList = []; // root lane does not exist + + await service.onHeadChanged({ + laneId: "nonexistent-root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + // Advance past the debounce timer (1200ms) + await vi.advanceTimersByTimeAsync(1500); + + // No rebase should have been attempted + expect(laneService.rebaseStart).not.toHaveBeenCalled(); + }); + + it("skips root lane with no descendants", async () => { + const service = createService(); + laneList = [makeLane("root")]; // root has no children + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).not.toHaveBeenCalled(); + }); + + it("triggers rebase for child lane that is behind", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 1, behind: 3, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "child-1", + scope: "lane_only", + pushMode: "none", + actor: "system", + reason: "auto_rebase", + }), + ); + }); + + it("marks downstream lanes as rebasePending when an ancestor has conflicts", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + const grandchild = makeLane("grandchild-1", { + parentLaneId: "child-1", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T02:00:00.000Z", + }); + laneList = [root, child, grandchild]; + + // Simulate merge conflict on child-1 + conflictService.simulateMerge.mockResolvedValue({ + outcome: "conflict", + conflictingFiles: ["file.ts"], + }); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + + // child-1 should be marked as rebaseConflict + const childStatus = db.getJson("auto_rebase:status:child-1") as AutoRebaseLaneStatus; + expect(childStatus.state).toBe("rebaseConflict"); + expect(childStatus.conflictCount).toBe(1); + + // grandchild should be blocked as rebasePending + const grandchildStatus = db.getJson("auto_rebase:status:grandchild-1") as AutoRebaseLaneStatus; + expect(grandchildStatus.state).toBe("rebasePending"); + expect(grandchildStatus.message).toContain("child-1"); + }); + + it("handles lane disappearance during cascade processing", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + const child2 = makeLane("child-2", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T02:00:00.000Z", + }); + + // First call returns both children, second call child-1 is gone + let callCount = 0; + laneService.list.mockImplementation(async () => { + callCount++; + if (callCount <= 1) return [root, child, child2]; + // child-1 disappeared during processing + return [root, child2]; + }); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + + // Should not throw. child-2 should still be processed. + // The cascade order is computed from the first call, so child-1 is in the order + // but will be skipped because it's not found in the refreshed lane list. + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ laneId: "child-2" }), + ); + }); + + it("emits event after processRoot completes", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[events.length - 1].type).toBe("auto-rebase-updated"); + }); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index 4506064e0..d2af299d0 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -128,7 +128,11 @@ export function createAutoRebaseService(args: { if (status.state === "autoRebased") { const updatedAtMs = Date.parse(status.updatedAt); - if (!Number.isFinite(updatedAtMs) || nowMs - updatedAtMs > AUTO_REBASED_TTL_MS) { + if (!Number.isFinite(updatedAtMs)) { + clearStatus(lane.id); + continue; + } + if (nowMs - updatedAtMs > AUTO_REBASED_TTL_MS) { clearStatus(lane.id); continue; } @@ -204,7 +208,14 @@ export function createAutoRebaseService(args: { lanes = await laneService.list({ includeArchived: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const lane = laneById.get(laneId); - if (!lane || !lane.parentLaneId) continue; + if (!lane) { + logger.info("autoRebase.lane_not_found", { laneId }); + continue; + } + if (!lane.parentLaneId) { + logger.debug("autoRebase.no_parent", { laneId }); + continue; + } if (blocked) { setStatus({ diff --git a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts index e5048092a..f10d88cd4 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts @@ -17,6 +17,7 @@ import type { } from "../../../shared/types"; import type { Logger } from "../logging/logger"; +import { isWithinDir } from "../shared/utils"; function cloneDockerConfig(config: LaneDockerConfig): LaneDockerConfig { return config.services @@ -175,6 +176,11 @@ export function createLaneEnvironmentService({ const sourcePath = path.resolve(projectRoot, file.source); const destPath = path.resolve(worktreePath, file.dest); + if (!isWithinDir(worktreePath, destPath)) { + logger.warn("lane_env_init.env_file_path_escape", { dest: file.dest, worktreePath }); + throw new Error("Path escapes allowed directory"); + } + // Ensure destination directory exists const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { @@ -230,12 +236,21 @@ export function createLaneEnvironmentService({ return execCommand(["docker", ...args], worktreePath, 300_000); } + const ALLOWED_INSTALL_COMMANDS = new Set([ + "npm", "yarn", "pnpm", "pip", "pip3", "bundle", "cargo", "go", "composer", "poetry", "pipenv", "bun" + ]); + async function installDependencies( worktreePath: string, deps: LaneDependencyInstallConfig[] ): Promise<{ failures: string[] }> { const failures: string[] = []; for (const dep of deps) { + const baseCommand = dep.command[0]; + if (!ALLOWED_INSTALL_COMMANDS.has(baseCommand)) { + logger.warn("lane_env_init.dependency_command_not_allowed", { command: baseCommand }); + continue; + } const cwd = dep.cwd ? path.resolve(worktreePath, dep.cwd) : worktreePath; const result = await execCommand(dep.command, cwd); if (result.exitCode !== 0) { @@ -258,6 +273,15 @@ export function createLaneEnvironmentService({ const sourcePath = path.resolve(adeDir, mp.source); const destPath = path.resolve(worktreePath, mp.dest); + if (!isWithinDir(adeDir, sourcePath)) { + logger.warn("lane_env_init.mount_source_path_escape", { source: mp.source, adeDir }); + throw new Error("Path escapes allowed directory"); + } + if (!isWithinDir(worktreePath, destPath)) { + logger.warn("lane_env_init.mount_dest_path_escape", { dest: mp.dest, worktreePath }); + throw new Error("Path escapes allowed directory"); + } + const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); @@ -283,6 +307,11 @@ export function createLaneEnvironmentService({ const dest = cp.dest ?? cp.source; const destPath = path.resolve(worktreePath, dest); + if (!isWithinDir(worktreePath, destPath)) { + logger.warn("lane_env_init.copy_dest_path_escape", { dest, worktreePath }); + throw new Error("Path escapes allowed directory"); + } + if (!fs.existsSync(sourcePath)) { logger.warn("lane_env_init.copy_path_missing", { source: cp.source }); continue; diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index d835a4ac2..4286413b5 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -689,7 +689,8 @@ export function createLaneService({ laneId, }); queueOverrideCache.set(laneId, override); - } catch { + } catch (err) { + console.warn("[laneService] lane_list.queue_override_failed", { laneId, err: String(err) }); queueOverrideCache.set(laneId, null); } }), diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts index de050555b..fc5ff0fa4 100644 --- a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts +++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { URL } from "node:url"; import type http from "node:http"; import type { @@ -62,7 +62,6 @@ export function createOAuthRedirectService({ const cfg: OAuthRedirectConfig = { ...DEFAULT_CONFIG, ...userConfig }; const sessions = new Map(); const stateSecret = randomBytes(32); - let sessionCounter = 0; // --------------------------------------------------------------------------- // State-parameter encoding @@ -102,7 +101,10 @@ export function createOAuthRedirectService({ const signature = rest.slice(0, signatureEnd); const laneId = Buffer.from(rest.slice(signatureEnd + STATE_SEP.length, laneEnd), "base64url").toString("utf-8"); const originalState = rest.slice(laneEnd + STATE_SEP.length); - if (!laneId.trim() || !signature) return null; + if (!laneId.trim() || !signature) { + logger.debug("oauth_redirect.decode_error", { reason: "empty laneId or signature" }); + return null; + } const expectedSignature = signState(laneId, originalState); const actualBytes = Buffer.from(signature); @@ -111,11 +113,13 @@ export function createOAuthRedirectService({ actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes) ) { + logger.warn("oauth_redirect.signature_mismatch", { laneId }); return null; } return { laneId, originalState }; - } catch { + } catch (err) { + logger.debug("oauth_redirect.decode_error", { error: String(err) }); return null; } } @@ -197,7 +201,7 @@ export function createOAuthRedirectService({ laneId: string, callbackPath: string, ): OAuthSession { - const id = `oauth-${++sessionCounter}-${Date.now()}`; + const id = `oauth-${randomUUID()}`; const session: OAuthSession = { id, laneId, @@ -478,7 +482,6 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA} /** Clean up. */ dispose(): void { sessions.clear(); - sessionCounter = 0; }, }; } diff --git a/apps/desktop/src/main/services/lanes/portAllocationService.ts b/apps/desktop/src/main/services/lanes/portAllocationService.ts index 0bf5f79bf..d554eca12 100644 --- a/apps/desktop/src/main/services/lanes/portAllocationService.ts +++ b/apps/desktop/src/main/services/lanes/portAllocationService.ts @@ -54,7 +54,8 @@ export function createPortAllocationService({ // --- helpers --------------------------------------------------------------- function maxSlots(): number { - return Math.floor((cfg.maxPort - cfg.basePort + 1) / cfg.portsPerLane); + const slots = Math.floor((cfg.maxPort - cfg.basePort + 1) / cfg.portsPerLane); + return Math.max(0, slots); } function getActiveLeases(): PortLease[] { @@ -104,6 +105,38 @@ export function createPortAllocationService({ }; } + /** Scan all active leases for overlapping port ranges and record new conflicts. */ + function runConflictDetection(): PortConflict[] { + const active = getActiveLeases(); + const newConflicts: PortConflict[] = []; + + for (let i = 0; i < active.length; i++) { + for (let j = i + 1; j < active.length; j++) { + const conflict = detectConflictsBetween(active[i], active[j]); + if (conflict) { + const alreadyExists = conflicts.some( + (c) => + !c.resolved && + ((c.laneIdA === conflict.laneIdA && c.laneIdB === conflict.laneIdB) || + (c.laneIdA === conflict.laneIdB && c.laneIdB === conflict.laneIdA)) + ); + if (!alreadyExists) { + conflicts.push(conflict); + newConflicts.push(conflict); + broadcastEvent({ type: "port-conflict-detected", conflict }); + logger.warn("port_allocation.conflict_detected", { + laneA: conflict.laneIdA, + laneB: conflict.laneIdB, + port: conflict.port, + }); + } + } + } + } + + return newConflicts; + } + // --- public API ------------------------------------------------------------ return { @@ -213,35 +246,7 @@ export function createPortAllocationService({ * Returns newly detected conflicts. */ detectConflicts(): PortConflict[] { - const active = getActiveLeases(); - const newConflicts: PortConflict[] = []; - - for (let i = 0; i < active.length; i++) { - for (let j = i + 1; j < active.length; j++) { - const conflict = detectConflictsBetween(active[i], active[j]); - if (conflict) { - // Check if this conflict pair already exists (unresolved) - const alreadyExists = conflicts.some( - (c) => - !c.resolved && - ((c.laneIdA === conflict.laneIdA && c.laneIdB === conflict.laneIdB) || - (c.laneIdA === conflict.laneIdB && c.laneIdB === conflict.laneIdA)) - ); - if (!alreadyExists) { - conflicts.push(conflict); - newConflicts.push(conflict); - broadcastEvent({ type: "port-conflict-detected", conflict }); - logger.warn("port_allocation.conflict_detected", { - laneA: conflict.laneIdA, - laneB: conflict.laneIdB, - port: conflict.port, - }); - } - } - } - } - - return newConflicts; + return runConflictDetection(); }, /** @@ -277,6 +282,7 @@ export function createPortAllocationService({ if (orphaned.length > 0) { persist(); logger.info("port_allocation.orphans_recovered", { count: orphaned.length }); + runConflictDetection(); } return orphaned; diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index dbd60412e..1e14fdf37 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -182,7 +182,16 @@ export function createRebaseSuggestionService(args: { dismissedAt: null }; - if (!existing || JSON.stringify(existing) !== JSON.stringify(nextState)) { + if ( + !existing || + existing.laneId !== nextState.laneId || + existing.parentLaneId !== nextState.parentLaneId || + existing.parentHeadSha !== nextState.parentHeadSha || + existing.behindCount !== nextState.behindCount || + existing.lastSuggestedAt !== nextState.lastSuggestedAt || + existing.deferredUntil !== nextState.deferredUntil || + existing.dismissedAt !== nextState.dismissedAt + ) { saveState(nextState); } diff --git a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts index ceaa85613..9664dd5d1 100644 --- a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts +++ b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts @@ -74,6 +74,22 @@ export function createRuntimeDiagnosticsService({ const lease = getPortLease(laneId); const route = getProxyRoute(laneId); const proxyStatus = getProxyStatus(); + if (!proxyStatus) { + logger.warn("runtime_diagnostics.proxy_status_missing", { laneId }); + const health: LaneHealthCheck = { + laneId, + status: "unhealthy", + processAlive: false, + portResponding: false, + proxyRouteActive: false, + fallbackMode: fallbackLanes.has(laneId), + lastCheckedAt: new Date().toISOString(), + issues: [{ type: "proxy-route-missing", message: "Proxy status unavailable." }], + }; + healthCache.set(laneId, health); + broadcastEvent({ type: "health-updated", laneId, health }); + return health; + } const isFallback = fallbackLanes.has(laneId); // 1. Port responding check @@ -287,9 +303,9 @@ export function createRuntimeDiagnosticsService({ const conflicts = getPortConflicts().filter((c) => !c.resolved); return { lanes, - proxyRunning: proxyStatus.running, - proxyPort: proxyStatus.proxyPort, - totalRoutes: proxyStatus.routes.length, + proxyRunning: proxyStatus?.running ?? false, + proxyPort: proxyStatus?.proxyPort ?? 0, + totalRoutes: proxyStatus?.routes.length ?? 0, activeConflicts: conflicts.length, fallbackLanes: Array.from(fallbackLanes), }; diff --git a/apps/desktop/src/main/services/memory/humanWorkDigestService.test.ts b/apps/desktop/src/main/services/memory/humanWorkDigestService.test.ts new file mode 100644 index 000000000..16a590bd0 --- /dev/null +++ b/apps/desktop/src/main/services/memory/humanWorkDigestService.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { clusterFiles } from "./humanWorkDigestService"; + +describe("clusterFiles", () => { + it("groups files by their top-level directory", () => { + const result = clusterFiles([ + "src/main/app.ts", + "src/renderer/index.tsx", + "docs/README.md", + ]); + expect(result).toEqual([ + { + label: "src", + files: ["src/main/app.ts", "src/renderer/index.tsx"], + summary: "2 file(s) touched under src.", + }, + { + label: "docs", + files: ["docs/README.md"], + summary: "1 file(s) touched under docs.", + }, + ]); + }); + + it("assigns files without a path separator to the 'root' bucket", () => { + const result = clusterFiles(["package.json", ".gitignore", "tsconfig.json"]); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + label: "root", + files: ["package.json", ".gitignore", "tsconfig.json"], + summary: "3 file(s) touched under root.", + }); + }); + + it("returns empty array for empty input", () => { + expect(clusterFiles([])).toEqual([]); + }); + + it("skips blank and whitespace-only entries", () => { + const result = clusterFiles(["src/a.ts", "", " ", "src/b.ts"]); + expect(result).toHaveLength(1); + expect(result[0]!.files).toEqual(["src/a.ts", "src/b.ts"]); + }); + + it("trims whitespace from file paths", () => { + const result = clusterFiles([" src/a.ts ", " docs/b.md "]); + // Alphabetical tie-break: docs before src + expect(result[0]!.files[0]).toBe("docs/b.md"); + expect(result[1]!.files[0]).toBe("src/a.ts"); + }); + + it("sorts clusters by count descending, then label alphabetically", () => { + const result = clusterFiles([ + "apps/desktop/main.ts", + "apps/desktop/renderer.ts", + "apps/desktop/preload.ts", + "docs/README.md", + "lib/utils.ts", + "lib/helpers.ts", + ]); + expect(result.map((c) => c.label)).toEqual(["apps", "lib", "docs"]); + expect(result[0]!.files).toHaveLength(3); + expect(result[1]!.files).toHaveLength(2); + expect(result[2]!.files).toHaveLength(1); + }); + + it("breaks count ties with alphabetical label order", () => { + const result = clusterFiles([ + "zeta/a.ts", + "alpha/b.ts", + ]); + // Both have count 1, so should be sorted alphabetically + expect(result.map((c) => c.label)).toEqual(["alpha", "zeta"]); + }); + + it("handles mixed root and nested files", () => { + const result = clusterFiles([ + "package.json", + "src/main.ts", + "README.md", + "src/lib/utils.ts", + ]); + const labels = result.map((c) => c.label); + expect(labels).toContain("root"); + expect(labels).toContain("src"); + const rootCluster = result.find((c) => c.label === "root")!; + expect(rootCluster.files).toEqual(["package.json", "README.md"]); + }); + + it("generates the correct summary text", () => { + const result = clusterFiles(["a/one.ts", "a/two.ts", "a/three.ts"]); + expect(result[0]!.summary).toBe("3 file(s) touched under a."); + }); + + it("handles a single file", () => { + const result = clusterFiles(["src/index.ts"]); + expect(result).toEqual([ + { + label: "src", + files: ["src/index.ts"], + summary: "1 file(s) touched under src.", + }, + ]); + }); + + it("handles deeply nested paths using only the first segment", () => { + const result = clusterFiles([ + "apps/desktop/src/main/services/chat/foo.ts", + "apps/web/src/index.ts", + ]); + expect(result).toHaveLength(1); + expect(result[0]!.label).toBe("apps"); + expect(result[0]!.files).toHaveLength(2); + }); +}); diff --git a/apps/desktop/src/main/services/memory/humanWorkDigestService.ts b/apps/desktop/src/main/services/memory/humanWorkDigestService.ts index cbfce65df..8a4f78fba 100644 --- a/apps/desktop/src/main/services/memory/humanWorkDigestService.ts +++ b/apps/desktop/src/main/services/memory/humanWorkDigestService.ts @@ -2,30 +2,8 @@ import type { ChangeDigest, KnowledgeSyncStatus } from "../../../shared/types"; import { runGit } from "../git/git"; import type { Logger } from "../logging/logger"; -function formatDigestContent(digest: ChangeDigest): string { - const lines: string[] = [ - `Human work digest ${digest.fromSha.slice(0, 8)} -> ${digest.toSha.slice(0, 8)}`, - `${digest.commitCount} commit(s) changed.`, - digest.diffstat, - ]; - if (digest.commitSummaries.length > 0) { - lines.push("Commits:"); - lines.push(...digest.commitSummaries.map((entry) => `- ${entry}`)); - } - if (digest.fileClusters.length > 0) { - lines.push("Clusters:"); - for (const cluster of digest.fileClusters) { - lines.push(`- ${cluster.label}: ${cluster.summary}`); - } - } - if (digest.changedFiles.length > 0) { - lines.push("Changed files:"); - lines.push(...digest.changedFiles.slice(0, 40).map((entry) => `- ${entry}`)); - } - return lines.join("\n"); -} - -function clusterFiles(files: string[]): ChangeDigest["fileClusters"] { +/** Exported for testing. */ +export function clusterFiles(files: string[]): ChangeDigest["fileClusters"] { const buckets = new Map(); for (const file of files) { const trimmed = file.trim(); @@ -48,7 +26,6 @@ export function createHumanWorkDigestService(args: { projectId: string; projectRoot: string; logger?: Pick | null; - memoryService?: unknown; }) { // In-memory cursor -- no longer persisted to the memory store. let inMemoryCursorSha: string | null = null; diff --git a/apps/desktop/src/main/services/memory/memoryBriefingService.test.ts b/apps/desktop/src/main/services/memory/memoryBriefingService.test.ts new file mode 100644 index 000000000..298f744c9 --- /dev/null +++ b/apps/desktop/src/main/services/memory/memoryBriefingService.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { buildQuery, cleanParts, type BuildMemoryBriefingArgs } from "./memoryBriefingService"; + +describe("cleanParts", () => { + it("returns trimmed non-empty strings", () => { + expect(cleanParts(["hello", " world "])).toEqual(["hello", "world"]); + }); + + it("filters out null and undefined values", () => { + expect(cleanParts([null, "keep", undefined, "this"])).toEqual(["keep", "this"]); + }); + + it("filters out empty strings and whitespace-only strings", () => { + expect(cleanParts(["", " ", "valid", " "])).toEqual(["valid"]); + }); + + it("returns empty array when all values are blank/null", () => { + expect(cleanParts([null, undefined, "", " "])).toEqual([]); + }); + + it("returns empty array for empty input", () => { + expect(cleanParts([])).toEqual([]); + }); + + it("converts null and undefined to empty string before trimming", () => { + // String(null) = "null" which is non-empty; but the ?? "" catches it first + expect(cleanParts([null])).toEqual([]); + expect(cleanParts([undefined])).toEqual([]); + }); + + it("preserves order of remaining values", () => { + expect(cleanParts(["c", null, "a", "", "b"])).toEqual(["c", "a", "b"]); + }); +}); + +describe("buildQuery", () => { + it("combines taskDescription and phaseContext", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: "fix memory scoping", + phaseContext: "implementation phase", + }; + expect(buildQuery(args)).toBe("fix memory scoping implementation phase"); + }); + + it("includes handoff summaries", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: "task", + handoffSummaries: ["summary A", "summary B"], + }; + expect(buildQuery(args)).toBe("task summary A summary B"); + }); + + it("includes file patterns", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: "task", + filePatterns: ["src/**/*.ts", "docs/*.md"], + }; + expect(buildQuery(args)).toBe("task src/**/*.ts docs/*.md"); + }); + + it("combines all fields together", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: "fix bug", + phaseContext: "debugging", + handoffSummaries: ["worker-1 done"], + filePatterns: ["src/main.ts"], + }; + expect(buildQuery(args)).toBe("fix bug debugging worker-1 done src/main.ts"); + }); + + it("returns empty string when all fields are absent", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + }; + expect(buildQuery(args)).toBe(""); + }); + + it("returns empty string when all fields are null", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: null, + phaseContext: null, + handoffSummaries: undefined, + filePatterns: undefined, + }; + expect(buildQuery(args)).toBe(""); + }); + + it("trims whitespace from individual parts", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: " leading whitespace ", + phaseContext: " trailing too ", + }; + expect(buildQuery(args)).toBe("leading whitespace trailing too"); + }); + + it("skips empty handoff summaries and file patterns", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + taskDescription: "task", + handoffSummaries: ["", " ", "valid summary"], + filePatterns: ["", "valid/*.ts"], + }; + expect(buildQuery(args)).toBe("task valid summary valid/*.ts"); + }); + + it("handles only phaseContext without taskDescription", () => { + const args: BuildMemoryBriefingArgs = { + projectId: "proj-1", + phaseContext: "review phase", + }; + expect(buildQuery(args)).toBe("review phase"); + }); +}); diff --git a/apps/desktop/src/main/services/memory/memoryBriefingService.ts b/apps/desktop/src/main/services/memory/memoryBriefingService.ts index b27d18046..b403e69c1 100644 --- a/apps/desktop/src/main/services/memory/memoryBriefingService.ts +++ b/apps/desktop/src/main/services/memory/memoryBriefingService.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { Memory, UnifiedMemoryService } from "./unifiedMemoryService"; import type { HumanWorkDigestService } from "./humanWorkDigestService"; +import type { ProjectMemoryFilesService } from "./memoryFilesService"; export type MemoryBriefingLevel = "lite" | "standard" | "deep"; @@ -45,11 +46,13 @@ const BUDGET_LIMITS: Record = { deep: 20, }; -function cleanParts(values: Array): string[] { +/** Exported for testing. */ +export function cleanParts(values: Array): string[] { return values.map((value) => String(value ?? "").trim()).filter((value) => value.length > 0); } -function buildQuery(args: BuildMemoryBriefingArgs): string { +/** Exported for testing. */ +export function buildQuery(args: BuildMemoryBriefingArgs): string { return cleanParts([ args.taskDescription, args.phaseContext, @@ -159,6 +162,7 @@ function readInstructionFiles(projectRoot: string): Memory[] { export function createMemoryBriefingService(args: { memoryService: Pick; + memoryFilesService?: Pick | null; projectRoot?: string | null; humanWorkDigestService?: Pick | null; }) { @@ -217,6 +221,24 @@ export function createMemoryBriefingService(args: { directSourceEntries.push(...readInstructionFiles(args.projectRoot)); } + const autoMemoryBootstrap = (() => { + if (!args.memoryFilesService) return ""; + try { + return args.memoryFilesService.readBootstrapIndex({ maxLines: 80, maxChars: 3_000 }); + } catch { + return ""; + } + })(); + if (autoMemoryBootstrap.trim().length > 0) { + directSourceEntries.push( + syntheticMemory( + "procedure", + `ADE auto memory bootstrap (.ade/memory/MEMORY.md):\n${autoMemoryBootstrap}`, + "ade-auto-memory-bootstrap", + ), + ); + } + const l1 = [...directSourceEntries, ...l1FromMemory].slice(0, BUDGET_LIMITS[levels.l1] + directSourceEntries.length); const l2 = input.includeAgentMemory && input.agentId diff --git a/apps/desktop/src/main/services/memory/memoryFilesService.test.ts b/apps/desktop/src/main/services/memory/memoryFilesService.test.ts new file mode 100644 index 000000000..2b5ad1950 --- /dev/null +++ b/apps/desktop/src/main/services/memory/memoryFilesService.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { createProjectMemoryFilesService } from "./memoryFilesService"; +import type { Memory } from "./unifiedMemoryService"; + +function makeMemory(overrides: Partial): Memory { + const now = "2026-03-25T10:00:00.000Z"; + return { + id: overrides.id ?? "memory-1", + projectId: overrides.projectId ?? "project-1", + scope: overrides.scope ?? "project", + scopeOwnerId: overrides.scopeOwnerId ?? null, + tier: overrides.tier ?? 2, + category: overrides.category ?? "fact", + content: overrides.content ?? "Fact: default memory.", + importance: overrides.importance ?? "medium", + sourceSessionId: overrides.sourceSessionId ?? null, + sourcePackKey: overrides.sourcePackKey ?? null, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + lastAccessedAt: overrides.lastAccessedAt ?? now, + accessCount: overrides.accessCount ?? 0, + observationCount: overrides.observationCount ?? 0, + status: overrides.status ?? "promoted", + agentId: overrides.agentId ?? null, + confidence: overrides.confidence ?? 1, + promotedAt: overrides.promotedAt ?? now, + sourceRunId: overrides.sourceRunId ?? null, + sourceType: overrides.sourceType ?? "user", + sourceId: overrides.sourceId ?? null, + fileScopePattern: overrides.fileScopePattern ?? null, + pinned: overrides.pinned ?? false, + accessScore: overrides.accessScore ?? 0, + compositeScore: overrides.compositeScore ?? 0.8, + writeGateReason: overrides.writeGateReason ?? null, + embedded: overrides.embedded ?? true, + }; +} + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors in tests. + } + } +}); + +describe("createProjectMemoryFilesService", () => { + it("writes a bootstrap index plus topic files from promoted project memory", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-memory-files-")); + tempDirs.push(projectRoot); + const service = createProjectMemoryFilesService({ + projectRoot, + projectId: "project-1", + memoryService: { + listMemories: () => [ + makeMemory({ + id: "decision-1", + category: "decision", + pinned: true, + tier: 1, + importance: "high", + content: "Decision: use generated auto-memory files as a bootstrap layer, not a new source of truth.", + }), + makeMemory({ + id: "gotcha-1", + category: "gotcha", + importance: "high", + content: "Gotcha: renderer-only fixes drift from the shared contract and come back later as regressions.", + }), + makeMemory({ + id: "procedure-1", + category: "procedure", + content: "Procedure: run targeted desktop checks before full Electron builds when iterating on memory behavior.", + }), + ], + } as any, + }); + + service.sync(); + + const memoryDir = resolveAdeLayout(projectRoot).memoryDir; + const indexPath = path.join(memoryDir, "MEMORY.md"); + const topicPaths = { + decisions: path.join(memoryDir, "decisions.md"), + gotchas: path.join(memoryDir, "gotchas.md"), + }; + expect(fs.existsSync(indexPath)).toBe(true); + expect(fs.existsSync(topicPaths.decisions)).toBe(true); + expect(fs.existsSync(topicPaths.gotchas)).toBe(true); + + const indexText = fs.readFileSync(indexPath, "utf8"); + expect(indexText).toContain("# ADE Auto Memory"); + expect(indexText).toContain("decisions.md"); + expect(indexText).toContain("Decision: use generated auto-memory files as a bootstrap layer"); + + const gotchasText = fs.readFileSync(topicPaths.gotchas, "utf8"); + expect(gotchasText).toContain("# Gotchas"); + expect(gotchasText).toContain("renderer-only fixes drift from the shared contract"); + }); + + it("builds bounded prompt context from the bootstrap index and matching topic files", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-memory-files-")); + tempDirs.push(projectRoot); + const service = createProjectMemoryFilesService({ + projectRoot, + projectId: "project-1", + memoryService: { + listMemories: () => [ + makeMemory({ + id: "convention-1", + category: "convention", + pinned: true, + tier: 1, + content: "Convention: keep ADE memory storage in SQLite and treat generated markdown as a mirror.", + }), + makeMemory({ + id: "procedure-1", + category: "procedure", + content: "Procedure: run the desktop memory tests before broader builds when iterating on auto-memory behavior.", + }), + ], + } as any, + }); + + service.sync(); + const promptContext = service.buildPromptContext({ + promptText: "Please fix the failing memory tests and preserve the SQLite-backed workflow.", + maxBootstrapLines: 40, + maxTopicFiles: 2, + maxTopicLines: 12, + maxChars: 2_000, + }); + + expect(promptContext.bootstrapLoaded).toBe(true); + expect(promptContext.topicFilesLoaded).toContain("procedures.md"); + expect(promptContext.text).toContain("ADE auto memory bootstrap"); + expect(promptContext.text).toContain("Relevant ADE auto memory topic"); + expect(promptContext.text).toContain("run the desktop memory tests"); + }); +}); diff --git a/apps/desktop/src/main/services/memory/memoryFilesService.ts b/apps/desktop/src/main/services/memory/memoryFilesService.ts new file mode 100644 index 000000000..3bec74d32 --- /dev/null +++ b/apps/desktop/src/main/services/memory/memoryFilesService.ts @@ -0,0 +1,359 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { writeTextAtomic } from "../shared/utils"; +import type { + createUnifiedMemoryService, + Memory, + MemoryCategory, + MemoryImportance, +} from "./unifiedMemoryService"; + +type TopicKey = + | "decisions" + | "conventions" + | "preferences" + | "gotchas" + | "patterns" + | "procedures" + | "facts"; + +type TopicDefinition = { + key: TopicKey; + title: string; + fileName: string; + categories: MemoryCategory[]; + description: string; + promptHint: RegExp; +}; + +type PromptContextResult = { + text: string; + bootstrapLoaded: boolean; + topicFilesLoaded: string[]; +}; + +export type ProjectMemoryFilesService = { + sync: () => void; + readBootstrapIndex: (opts?: { maxLines?: number; maxChars?: number }) => string; + buildPromptContext: (opts: { + promptText: string; + maxBootstrapLines?: number; + maxTopicFiles?: number; + maxTopicLines?: number; + maxChars?: number; + }) => PromptContextResult; +}; + +const TOPICS: TopicDefinition[] = [ + { + key: "decisions", + title: "Decisions", + fileName: "decisions.md", + categories: ["decision"], + description: "Durable architecture choices and tradeoffs.", + promptHint: /\b(?:decision|trade(?:-| )?off|architecture|architectural|why did|why do|choose|chose|chosen|approach)\b/i, + }, + { + key: "conventions", + title: "Conventions", + fileName: "conventions.md", + categories: ["convention"], + description: "Repo rules, naming, and team habits that should be followed by default.", + promptHint: /\b(?:convention|standard|style|naming|format|folder|structure|organization|repo|repository|workspace)\b/i, + }, + { + key: "preferences", + title: "Preferences", + fileName: "preferences.md", + categories: ["preference"], + description: "Durable user and project preferences worth carrying across sessions.", + promptHint: /\b(?:prefer|preference|tone|format|respond|response|brief|concise|verbose|always|never)\b/i, + }, + { + key: "gotchas", + title: "Gotchas", + fileName: "gotchas.md", + categories: ["gotcha"], + description: "Known pitfalls, failure modes, and sharp edges.", + promptHint: /\b(?:gotcha|pitfall|sharp edge|bug|error|failing|failure|breaks?|broken|regression|issue|trap)\b/i, + }, + { + key: "patterns", + title: "Patterns", + fileName: "patterns.md", + categories: ["pattern"], + description: "Reusable implementation patterns and shared solutions.", + promptHint: /\b(?:pattern|patterns|shared approach|integration|api|flow|state|component|service|hook)\b/i, + }, + { + key: "procedures", + title: "Procedures", + fileName: "procedures.md", + categories: ["procedure"], + description: "Repeatable workflows, validation steps, and operational runbooks.", + promptHint: /\b(?:procedure|workflow|steps?|checklist|runbook|playbook|validate|verification|test|build|lint|typecheck|release|deploy)\b/i, + }, + { + key: "facts", + title: "Facts", + fileName: "facts.md", + categories: ["fact"], + description: "Stable project facts and context that are hard to infer quickly from code alone.", + promptHint: /\b(?:fact|context|background|overview|system|module|domain|product)\b/i, + }, +]; + +const PROMOTED_TOPIC_CATEGORIES = TOPICS.flatMap((topic) => topic.categories); +const PLACEHOLDER_LINE = "- No promoted project memories yet. Use ADE memory tools to capture durable project knowledge."; + +function clipText(value: string, maxChars = 220): string { + const normalized = String(value ?? "").replace(/\s+/g, " ").trim(); + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}...`; +} + +function memoryImportanceRank(value: MemoryImportance): number { + if (value === "high") return 3; + if (value === "medium") return 2; + return 1; +} + +function sortMemories(left: Memory, right: Memory): number { + if (left.pinned !== right.pinned) return left.pinned ? -1 : 1; + if (left.tier !== right.tier) return left.tier - right.tier; + const importanceDelta = memoryImportanceRank(right.importance) - memoryImportanceRank(left.importance); + if (importanceDelta !== 0) return importanceDelta; + if (right.confidence !== left.confidence) return right.confidence - left.confidence; + return String(right.updatedAt).localeCompare(String(left.updatedAt)); +} + +function readBoundedText(filePath: string, opts?: { maxLines?: number; maxChars?: number }): string { + if (!fs.existsSync(filePath)) return ""; + const maxLines = Math.max(1, Math.min(200, Math.floor(opts?.maxLines ?? 200))); + const maxChars = Math.max(200, Math.min(8_000, Math.floor(opts?.maxChars ?? 2_400))); + const raw = fs.readFileSync(filePath, "utf8"); + const lines = raw.split(/\r?\n/).slice(0, maxLines); + const joined = lines.join("\n").trim(); + if (joined.length <= maxChars) return joined; + return joined.slice(0, maxChars).trimEnd(); +} + +function hasMeaningfulMemoryContent(value: string): boolean { + const trimmed = value.trim(); + return trimmed.length > 0 && !trimmed.includes(PLACEHOLDER_LINE); +} + +function buildTopicHeader(topic: TopicDefinition): string[] { + return [ + `# ${topic.title}`, + "", + "Internal ADE-generated project memory topic file. The source of truth is ADE's promoted project memory store; manual edits here may be overwritten.", + "", + "## When to load", + `- ${topic.description}`, + "", + ]; +} + +function labelForMemory(memory: Memory): string { + const details = [ + `category=${memory.category}`, + `tier=${memory.tier}`, + memory.pinned ? "pinned=yes" : null, + `importance=${memory.importance}`, + `confidence=${memory.confidence.toFixed(2)}`, + memory.fileScopePattern ? `path=${memory.fileScopePattern}` : null, + memory.sourceType ? `source=${memory.sourceType}` : null, + ].filter((part): part is string => Boolean(part)); + return details.join(" | "); +} + +export function createProjectMemoryFilesService(args: { + projectRoot: string; + projectId: string; + memoryService: Pick, "listMemories">; +}): ProjectMemoryFilesService { + const layout = resolveAdeLayout(args.projectRoot); + const memoryDir = layout.memoryDir; + const indexPath = path.join(memoryDir, "MEMORY.md"); + const topicPaths = Object.fromEntries( + TOPICS.map((topic) => [topic.key, path.join(memoryDir, topic.fileName)]), + ) as Record; + + const listPromotedProjectMemories = (): Memory[] => { + const seen = new Set(); + return args.memoryService + .listMemories({ + projectId: args.projectId, + scope: "project", + status: "promoted", + categories: PROMOTED_TOPIC_CATEGORIES, + limit: 400, + }) + .filter((memory) => { + if (seen.has(memory.id)) return false; + seen.add(memory.id); + return true; + }) + .sort(sortMemories); + }; + + const renderTopicFile = (topic: TopicDefinition, entries: Memory[]): string => { + const lines = buildTopicHeader(topic); + if (entries.length === 0) { + lines.push("## Entries"); + lines.push(PLACEHOLDER_LINE); + return `${lines.join("\n").trim()}\n`; + } + + lines.push("## Entries"); + for (const [index, memory] of entries.entries()) { + lines.push(`### ${index + 1}. ${clipText(memory.content, 96)}`); + lines.push(`- ${labelForMemory(memory)}`); + lines.push(`- updated=${memory.updatedAt}`); + lines.push(`- content=${clipText(memory.content, 420)}`); + lines.push(""); + } + while (lines[lines.length - 1] === "") lines.pop(); + return `${lines.join("\n").trim()}\n`; + }; + + const renderIndexFile = (memories: Memory[], grouped: Map): string => { + const pinned = memories.filter((memory) => memory.pinned).slice(0, 6); + const highSignal = memories.slice(0, 12); + const lines: string[] = [ + "# ADE Auto Memory", + "", + "Internal ADE-generated project memory bootstrap. ADE writes this from promoted project memory so sessions can load a compact, Claude-style memory index before deeper retrieval.", + "", + "## How to use this file", + "- Read this file first for repo-wide habits, decisions, and pitfalls.", + "- Open the listed topic files when the current task clearly touches that area.", + "- Current source files, tests, configs, and user instructions win if they disagree.", + "", + "## Topic files", + ...TOPICS.map((topic) => { + const count = grouped.get(topic.key)?.length ?? 0; + return `- ${topic.fileName} (${count}): ${topic.description}`; + }), + "", + ]; + + lines.push("## Pinned highlights"); + if (pinned.length === 0) { + lines.push("- No pinned project memories yet."); + } else { + for (const memory of pinned) { + lines.push(`- [${memory.category}] ${clipText(memory.content, 180)}`); + } + } + lines.push(""); + + lines.push("## Current high-signal memory"); + if (highSignal.length === 0) { + lines.push(PLACEHOLDER_LINE); + } else { + for (const topic of TOPICS) { + const entries = (grouped.get(topic.key) ?? []).slice(0, 2); + if (entries.length === 0) continue; + lines.push(`### ${topic.title}`); + for (const memory of entries) { + lines.push(`- ${clipText(memory.content, 180)}`); + } + lines.push(""); + } + while (lines[lines.length - 1] === "") lines.pop(); + } + + lines.push(""); + lines.push(`Updated: ${new Date().toISOString()}`); + return `${lines.join("\n").trim()}\n`; + }; + + const ensureFilesExist = (): void => { + if (fs.existsSync(indexPath)) return; + sync(); + }; + + const sync = (): void => { + const memories = listPromotedProjectMemories(); + const grouped = new Map(); + for (const topic of TOPICS) { + grouped.set( + topic.key, + memories.filter((memory) => topic.categories.includes(memory.category)), + ); + } + + fs.mkdirSync(memoryDir, { recursive: true }); + writeTextAtomic(indexPath, renderIndexFile(memories, grouped)); + for (const topic of TOPICS) { + writeTextAtomic(topicPaths[topic.key], renderTopicFile(topic, grouped.get(topic.key) ?? [])); + } + }; + + const readBootstrapIndex = (opts?: { maxLines?: number; maxChars?: number }): string => { + ensureFilesExist(); + const text = readBoundedText(indexPath, opts); + return hasMeaningfulMemoryContent(text) ? text : ""; + }; + + const buildPromptContext = (opts: { + promptText: string; + maxBootstrapLines?: number; + maxTopicFiles?: number; + maxTopicLines?: number; + maxChars?: number; + }): PromptContextResult => { + ensureFilesExist(); + const maxChars = Math.max(400, Math.min(6_000, Math.floor(opts.maxChars ?? 2_400))); + const sections: string[] = []; + const bootstrap = readBootstrapIndex({ + maxLines: opts.maxBootstrapLines ?? 80, + maxChars, + }); + + if (bootstrap.length > 0) { + sections.push([ + "ADE auto memory bootstrap (generated from promoted project memory):", + bootstrap, + ].join("\n")); + } + + const promptText = String(opts.promptText ?? ""); + const matchingTopics = TOPICS + .filter((topic) => topic.promptHint.test(promptText)) + .slice(0, Math.max(0, Math.min(3, Math.floor(opts.maxTopicFiles ?? 2)))); + + const loadedTopics: string[] = []; + for (const topic of matchingTopics) { + const topicText = readBoundedText(topicPaths[topic.key], { + maxLines: opts.maxTopicLines ?? 18, + maxChars: Math.max(200, Math.floor(maxChars / 2)), + }); + if (!hasMeaningfulMemoryContent(topicText)) continue; + loadedTopics.push(topic.fileName); + sections.push([ + `Relevant ADE auto memory topic (${topic.fileName}):`, + topicText, + ].join("\n")); + } + + let text = sections.filter((section) => section.trim().length > 0).join("\n\n").trim(); + if (text.length > maxChars) { + text = text.slice(0, maxChars).trimEnd(); + } + return { + text, + bootstrapLoaded: bootstrap.length > 0, + topicFilesLoaded: loadedTopics, + }; + }; + + return { + sync, + readBootstrapIndex, + buildPromptContext, + }; +} diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts index f7d2f4ae3..ef344b911 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it, vi } from "vitest"; import { buildClaudeReadOnlyWorkerAllowedTools, buildCodexMcpConfigFlags, + cleanupMcpConfigFile, createUnifiedOrchestratorAdapter, + forceReadOnlyPermissionConfig, + getUnifiedUnsupportedModelReason, resolveAdeMcpServerLaunch, resolveUnifiedRuntimeRoot, } from "./unifiedOrchestratorAdapter"; @@ -518,3 +521,107 @@ describe("createUnifiedOrchestratorAdapter", () => { }); }); }); + +describe("getUnifiedUnsupportedModelReason", () => { + it("returns null for supported CLI-wrapped models", () => { + expect(getUnifiedUnsupportedModelReason("anthropic/claude-sonnet-4-6")).toBeNull(); + }); + + it("returns a not-registered message for unknown model refs", () => { + const reason = getUnifiedUnsupportedModelReason("nonexistent/fantasy-model-99"); + expect(reason).toBe("Model 'nonexistent/fantasy-model-99' is not registered."); + }); + + it("returns a not-registered message for empty string", () => { + const reason = getUnifiedUnsupportedModelReason(""); + expect(reason).toBe("Model '' is not registered."); + }); + + it("returns null for Codex CLI models", () => { + expect(getUnifiedUnsupportedModelReason("openai/gpt-5.3-codex")).toBeNull(); + }); +}); + +describe("forceReadOnlyPermissionConfig", () => { + it("returns the original config unchanged when readOnlyExecution is false", () => { + const config = { + _providers: { claude: "full-auto" as const }, + }; + expect(forceReadOnlyPermissionConfig(config, false)).toBe(config); + }); + + it("downgrades permissions when readOnlyExecution is true", () => { + const config = { + _providers: { + claude: "full-auto" as const, + codex: "full-auto" as const, + }, + }; + const result = forceReadOnlyPermissionConfig(config, true); + expect(result).not.toBe(config); + expect(result?._providers?.claude).toBe("default"); + expect(result?._providers?.codex).toBe("plan"); + expect(result?._providers?.codexSandbox).toBe("read-only"); + expect(result?._providers?.writablePaths).toEqual([]); + }); + + it("returns a downgraded config even when original config is undefined", () => { + const result = forceReadOnlyPermissionConfig(undefined, true); + expect(result?._providers?.claude).toBe("default"); + expect(result?._providers?.codex).toBe("plan"); + }); + + it("returns undefined when config is undefined and not read-only", () => { + expect(forceReadOnlyPermissionConfig(undefined, false)).toBeUndefined(); + }); +}); + +describe("cleanupMcpConfigFile", () => { + it("silently handles non-existent config files without throwing", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cleanup-nonexistent-")); + expect(() => cleanupMcpConfigFile(projectRoot, "attempt-missing")).not.toThrow(); + }); + + it("removes an existing MCP config file", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cleanup-existing-")); + const configDir = path.join(projectRoot, ".ade", "cache", "orchestrator", "mcp-configs"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "worker-attempt-cleanup.json"); + fs.writeFileSync(configPath, "{}", "utf8"); + expect(fs.existsSync(configPath)).toBe(true); + + cleanupMcpConfigFile(projectRoot, "attempt-cleanup"); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("removes a lane-local config file when laneWorktreePath is provided", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cleanup-lane-")); + const lanePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cleanup-lane-wt-")); + const localConfigName = `.ade-worker-mcp-attempt-lane.json`; + const localConfigPath = path.join(lanePath, localConfigName); + fs.writeFileSync(localConfigPath, "{}", "utf8"); + expect(fs.existsSync(localConfigPath)).toBe(true); + + cleanupMcpConfigFile(projectRoot, "attempt-lane", lanePath); + expect(fs.existsSync(localConfigPath)).toBe(false); + }); + + it("removes a worker prompt file alongside the config", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cleanup-prompt-")); + const promptDir = path.join(projectRoot, ".ade", "cache", "orchestrator", "worker-prompts"); + fs.mkdirSync(promptDir, { recursive: true }); + const promptPath = path.join(promptDir, "worker-attempt-prompt.txt"); + fs.writeFileSync(promptPath, "some prompt text", "utf8"); + expect(fs.existsSync(promptPath)).toBe(true); + + cleanupMcpConfigFile(projectRoot, "attempt-prompt"); + expect(fs.existsSync(promptPath)).toBe(false); + }); + + it("skips lane-local cleanup when laneWorktreePath is empty", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cleanup-empty-lane-")); + expect(() => cleanupMcpConfigFile(projectRoot, "attempt-empty", "")).not.toThrow(); + expect(() => cleanupMcpConfigFile(projectRoot, "attempt-empty", " ")).not.toThrow(); + expect(() => cleanupMcpConfigFile(projectRoot, "attempt-empty", null)).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts index f36d6da06..70c03fae1 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -316,6 +316,33 @@ function resolveManagedPermissionMode(args: { : undefined; } +function mapPermissionModeToNativeFields( + provider: "claude" | "codex" | "unified", + mode: AgentChatPermissionMode | undefined, +): Partial> { + if (!mode) return {}; + if (provider === "claude") { + const map: Record = { + "full-auto": "bypassPermissions", + "edit": "acceptEdits", + "plan": "plan", + "default": "default", + }; + return { claudePermissionMode: map[mode] ?? "default" }; + } + if (provider === "codex") { + if (mode === "full-auto") return { codexApprovalPolicy: "never", codexSandbox: "danger-full-access" }; + if (mode === "edit") return { codexApprovalPolicy: "on-failure", codexSandbox: "workspace-write" }; + return { codexApprovalPolicy: "untrusted", codexSandbox: "read-only" }; + } + const umap: Record = { + "full-auto": "full-auto", + "edit": "edit", + "plan": "plan", + }; + return { unifiedPermissionMode: umap[mode] ?? "edit" }; +} + function resolveManagedExecutionMode(args: { provider: "claude" | "codex" | "unified"; teamRuntime?: TeamRuntimeConfig; @@ -635,7 +662,7 @@ export function createUnifiedOrchestratorAdapter(options?: { model, modelId: descriptor.id, reasoningEffort: reasoningEffort ?? null, - permissionMode, + ...mapPermissionModeToNativeFields(provider, permissionMode), ...(workerOwnerId ? { identityKey: `agent:${workerOwnerId}` as const } : {}), }); return { diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts index 0d7352139..bc5c41377 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts @@ -338,7 +338,7 @@ describe("launchPrIssueResolutionChat", () => { modelId: "openai/gpt-5.4-codex", surface: "work", sessionProfile: "workflow", - permissionMode: "edit", + unifiedPermissionMode: "edit", })); expect(updateMeta).toHaveBeenCalledWith({ sessionId: "session-1", title: "Resolve PR #80 issues" }); expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts index e50ba2203..41f12cdb2 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -409,7 +409,7 @@ export async function launchPrIssueResolutionChat( model: descriptor.id, modelId: descriptor.id, ...(reasoningEffort ? { reasoningEffort } : {}), - permissionMode: mapPermissionMode(args.permissionMode), + unifiedPermissionMode: mapPermissionMode(args.permissionMode) as import("../../../shared/types").AgentChatUnifiedPermissionMode, surface: "work", sessionProfile: "workflow", }); diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.ts index 8f19c3930..b248300da 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.ts @@ -141,7 +141,7 @@ export async function launchRebaseResolutionChat( model: descriptor.id, modelId: descriptor.id, ...(reasoningEffort ? { reasoningEffort } : {}), - permissionMode: mapPermissionMode(args.permissionMode), + unifiedPermissionMode: mapPermissionMode(args.permissionMode) as import("../../../shared/types").AgentChatUnifiedPermissionMode, surface: "work", sessionProfile: "workflow", }); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ce00311b3..445035e93 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -832,7 +832,6 @@ declare global { updateSession: (args: AgentChatUpdateSessionArgs) => Promise; warmupModel: (args: { sessionId: string; modelId: string }) => Promise; onEvent: (cb: (ev: AgentChatEventEnvelope) => void) => () => void; - changePermissionMode: (args: import("../shared/types").AgentChatChangePermissionModeArgs) => Promise; slashCommands: (args: import("../shared/types").AgentChatSlashCommandsArgs) => Promise; fileSearch: (args: import("../shared/types").AgentChatFileSearchArgs) => Promise; listSubagents: (args: import("../shared/types").AgentChatSubagentListArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8cc50e6ea..88eb21e5b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1147,8 +1147,6 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.agentChatEvent, listener); return () => ipcRenderer.removeListener(IPC.agentChatEvent, listener); }, - changePermissionMode: async (args: import("../shared/types").AgentChatChangePermissionModeArgs): Promise => - ipcRenderer.invoke(IPC.agentChatChangePermissionMode, args), slashCommands: async (args: import("../shared/types").AgentChatSlashCommandsArgs): Promise => ipcRenderer.invoke(IPC.agentChatSlashCommands, args), fileSearch: async (args: import("../shared/types").AgentChatFileSearchArgs): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index dd2f47c07..7f54e47d0 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1158,7 +1158,6 @@ if (typeof window !== "undefined" && !(window as any).ade) { dispose: resolvedArg(undefined), updateSession: resolvedArg({ id: "mock" }), onEvent: noop, - changePermissionMode: resolvedArg(undefined), slashCommands: resolvedArg([]), fileSearch: resolvedArg([]), }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 3a24a58aa..8b0ae6022 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ComponentProps } from "react"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; import { AgentChatComposer } from "./AgentChatComposer"; @@ -91,11 +91,65 @@ describe("AgentChatComposer", () => { it("shows native Codex runtime controls", () => { renderComposer(); - expect(screen.getByDisplayValue("ADE flags")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Plan" }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: "Guarded edit" }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: "Full auto" }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe("true"); + }); + + it("maps Codex preset modes and reveals custom controls", () => { + const onCodexApprovalPolicyChange = vi.fn(); + const onCodexSandboxChange = vi.fn(); + const onCodexConfigSourceChange = vi.fn(); + renderComposer({ + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + onCodexApprovalPolicyChange, + onCodexSandboxChange, + onCodexConfigSourceChange, + }); + + fireEvent.click(screen.getByRole("button", { name: "Plan" })); + expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("flags"); + expect(onCodexApprovalPolicyChange).toHaveBeenLastCalledWith("untrusted"); + expect(onCodexSandboxChange).toHaveBeenLastCalledWith("read-only"); + + fireEvent.click(screen.getByRole("button", { name: "Guarded edit" })); + expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("flags"); + expect(onCodexApprovalPolicyChange).toHaveBeenLastCalledWith("on-failure"); + expect(onCodexSandboxChange).toHaveBeenLastCalledWith("workspace-write"); + + fireEvent.click(screen.getByRole("button", { name: "Full auto" })); + expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("flags"); + expect(onCodexApprovalPolicyChange).toHaveBeenLastCalledWith("never"); + expect(onCodexSandboxChange).toHaveBeenLastCalledWith("danger-full-access"); + + fireEvent.click(screen.getByRole("button", { name: "Custom" })); + expect(onCodexConfigSourceChange).toHaveBeenLastCalledWith("config-toml"); + }); + + it("shows the raw Codex controls in custom mode", () => { + renderComposer({ + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "config-toml", + }); + + expect(screen.getByDisplayValue("config.toml")).toBeTruthy(); expect(screen.getByDisplayValue("On request")).toBeTruthy(); expect(screen.getByDisplayValue("Workspace write")).toBeTruthy(); }); + it("enables native text assistance on the prompt textarea", () => { + renderComposer(); + + const textarea = screen.getByPlaceholderText("Steer the active turn..."); + expect(textarea.getAttribute("spellcheck")).toBe("true"); + expect(textarea.getAttribute("autocorrect")).toBe("on"); + expect(textarea.getAttribute("autocapitalize")).toBe("sentences"); + }); + it("opens the advanced popover and wires the advanced controls", () => { const onExecutionModeChange = vi.fn(); const onComputerUsePolicyChange = vi.fn(); @@ -124,4 +178,234 @@ describe("AgentChatComposer", () => { fireEvent.click(screen.getByRole("button", { name: "Advanced" })); expect(screen.queryByText("Advanced settings")).toBeNull(); }); + + it("keeps the textarea text-assist attributes enabled by default", () => { + renderComposer(); + + const textarea = screen.getByRole("textbox"); + expect(textarea.getAttribute("spellcheck")).toBe("true"); + expect(textarea.getAttribute("autocorrect")).toBe("on"); + expect(textarea.getAttribute("autocapitalize")).toBe("sentences"); + }); + + it("keeps an unavailable API-only model visible in the selector", () => { + renderComposer({ + modelId: "openai/gpt-5.4-mini", + availableModelIds: ["openai/gpt-5.4"], + }); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + fireEvent.click(screen.getByRole("button", { name: /^API\b/i })); + + const option = screen.getByRole("option", { name: /GPT-5\.4-Mini/i }); + expect(option.getAttribute("aria-disabled")).toBe("true"); + expect(option.textContent).toContain("API only · not configured"); + }); + + it("lists GPT-5.4-Mini in the OpenAI section even when it is unavailable", () => { + renderComposer({ + modelId: "openai/gpt-5.4", + availableModelIds: ["openai/gpt-5.4"], + }); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + fireEvent.click(screen.getByRole("button", { name: /^API\b/i })); + + const option = screen.getByRole("option", { name: /GPT-5\.4-Mini/i }); + expect(option.getAttribute("aria-disabled")).toBe("true"); + expect(option.textContent).toContain("API only · not configured"); + }); + + /* ── Attachment picker tests ── */ + + it("opens the attachment picker when pressing @ in the textarea (turn inactive)", () => { + renderComposer({ turnActive: false, draft: "" }); + + const textarea = screen.getByPlaceholderText("Message the assistant..."); + fireEvent.keyDown(textarea, { key: "@" }); + + expect(screen.getByPlaceholderText("Search files...")).toBeTruthy(); + }); + + it("does not open the attachment picker when pressing @ during an active turn", () => { + renderComposer({ turnActive: true, draft: "" }); + + const textarea = screen.getByPlaceholderText("Steer the active turn..."); + fireEvent.keyDown(textarea, { key: "@" }); + + expect(screen.queryByPlaceholderText("Search files...")).toBeNull(); + }); + + it("searches for files via onSearchAttachments when typing in the picker", async () => { + vi.useFakeTimers(); + + const onSearchAttachments = vi.fn().mockResolvedValue([ + { path: "/project/src/index.ts", type: "file" }, + { path: "/project/src/app.tsx", type: "file" }, + ]); + renderComposer({ turnActive: false, draft: "", onSearchAttachments }); + + const textarea = screen.getByPlaceholderText("Message the assistant..."); + fireEvent.keyDown(textarea, { key: "@" }); + + const searchInput = screen.getByPlaceholderText("Search files..."); + fireEvent.change(searchInput, { target: { value: "index" } }); + + // The search debounce is 120ms + await act(async () => { vi.advanceTimersByTime(150); }); + + expect(onSearchAttachments).toHaveBeenCalledWith("index"); + + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.getByText("/project/src/index.ts")).toBeTruthy(); + expect(screen.getByText("/project/src/app.tsx")).toBeTruthy(); + }); + }); + + it("discards stale search results when a newer search completes first", async () => { + vi.useFakeTimers(); + + let resolveFirst!: (value: Array<{ path: string; type: "file" }>) => void; + let resolveSecond!: (value: Array<{ path: string; type: "file" }>) => void; + + const firstPromise = new Promise>((r) => { resolveFirst = r; }); + const secondPromise = new Promise>((r) => { resolveSecond = r; }); + + const onSearchAttachments = vi.fn() + .mockReturnValueOnce(firstPromise) + .mockReturnValueOnce(secondPromise); + + renderComposer({ turnActive: false, draft: "", onSearchAttachments }); + + const textarea = screen.getByPlaceholderText("Message the assistant..."); + fireEvent.keyDown(textarea, { key: "@" }); + const searchInput = screen.getByPlaceholderText("Search files..."); + + // Type "old" and wait for debounce + fireEvent.change(searchInput, { target: { value: "old" } }); + await act(async () => { vi.advanceTimersByTime(150); }); + + // Type "new" and wait for debounce — this increments searchRequestIdRef + fireEvent.change(searchInput, { target: { value: "new" } }); + await act(async () => { vi.advanceTimersByTime(150); }); + + // The second (newer) search resolves first + await act(async () => { resolveSecond([{ path: "/project/new-result.ts", type: "file" }]); }); + + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.getByText("/project/new-result.ts")).toBeTruthy(); + }); + + // Now the first (stale) search resolves — its results should be discarded + await act(async () => { resolveFirst([{ path: "/project/stale-result.ts", type: "file" }]); }); + + // Wait a tick to make sure no re-render happens with stale data + await waitFor(() => { + expect(screen.queryByText("/project/stale-result.ts")).toBeNull(); + expect(screen.getByText("/project/new-result.ts")).toBeTruthy(); + }); + }); + + it("selects an attachment from results and closes the picker", async () => { + vi.useFakeTimers(); + + const onAddAttachment = vi.fn(); + const onSearchAttachments = vi.fn().mockResolvedValue([ + { path: "/project/utils.ts", type: "file" }, + ]); + + renderComposer({ turnActive: false, draft: "", onAddAttachment, onSearchAttachments }); + + const textarea = screen.getByPlaceholderText("Message the assistant..."); + fireEvent.keyDown(textarea, { key: "@" }); + + const searchInput = screen.getByPlaceholderText("Search files..."); + fireEvent.change(searchInput, { target: { value: "utils" } }); + await act(async () => { vi.advanceTimersByTime(150); }); + + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.getByText("/project/utils.ts")).toBeTruthy(); + }); + + fireEvent.click(screen.getByText("/project/utils.ts")); + + expect(onAddAttachment).toHaveBeenCalledWith({ path: "/project/utils.ts", type: "file" }); + // Picker should close after selection + expect(screen.queryByPlaceholderText("Search files...")).toBeNull(); + }); + + it("selects an attachment via Enter key on the highlighted result", async () => { + vi.useFakeTimers(); + + const onAddAttachment = vi.fn(); + const onSearchAttachments = vi.fn().mockResolvedValue([ + { path: "/project/alpha.ts", type: "file" }, + { path: "/project/beta.ts", type: "file" }, + ]); + + renderComposer({ turnActive: false, draft: "", onAddAttachment, onSearchAttachments }); + + const textarea = screen.getByPlaceholderText("Message the assistant..."); + fireEvent.keyDown(textarea, { key: "@" }); + + const searchInput = screen.getByPlaceholderText("Search files..."); + fireEvent.change(searchInput, { target: { value: "project" } }); + await act(async () => { vi.advanceTimersByTime(150); }); + + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.getByText("/project/alpha.ts")).toBeTruthy(); + }); + + // Move cursor down to second result and press Enter + fireEvent.keyDown(searchInput, { key: "ArrowDown" }); + fireEvent.keyDown(searchInput, { key: "Enter" }); + + expect(onAddAttachment).toHaveBeenCalledWith({ path: "/project/beta.ts", type: "file" }); + expect(screen.queryByPlaceholderText("Search files...")).toBeNull(); + }); + + it("adds a file attachment via the hidden file input", async () => { + const onAddAttachment = vi.fn(); + renderComposer({ turnActive: false, draft: "", onAddAttachment }); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(["content"], "a.ts", { type: "text/plain" }); + Object.defineProperty(file, "path", { value: "/project/a.ts", writable: false }); + + fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(onAddAttachment).toHaveBeenCalledTimes(1); + }); + + expect(onAddAttachment).toHaveBeenCalledWith({ path: "/project/a.ts", type: "file" }); + }); + + it("prevents submitting a whitespace-only message", () => { + const onSubmit = vi.fn(); + renderComposer({ turnActive: false, draft: " ", onSubmit, busy: false }); + + const textarea = screen.getByPlaceholderText("Message the assistant..."); + + // Try submitting via Enter + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("disables the Send button when draft is whitespace-only", () => { + renderComposer({ turnActive: false, draft: " \n\t " }); + + const sendButton = screen.getByTitle("Send"); + expect(sendButton.hasAttribute("disabled")).toBe(true); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 2f676e4e9..c3f594f51 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { At, CaretDown, Image, Paperclip, Square, X, PaperPlaneTilt, Lightning } from "@phosphor-icons/react"; +import { At, CaretDown, Image, Paperclip, Square, PaperPlaneTilt, Lightning } from "@phosphor-icons/react"; import { inferAttachmentType, type AgentChatApprovalDecision, @@ -46,7 +46,10 @@ const LOCAL_SLASH_COMMANDS: SlashCommandEntry[] = [ ]; /** Well-known defaults shown before the SDK session is initialized. */ -const CLAUDE_DEFAULT_COMMANDS: SlashCommandEntry[] = []; +const CLAUDE_DEFAULT_COMMANDS: SlashCommandEntry[] = [ + { command: "/compact", label: "Compact", description: "Compact conversation history", source: "sdk" }, + { command: "/memory", label: "Memory", description: "View or edit CLAUDE.md", source: "sdk" }, +]; const CODEX_DEFAULT_COMMANDS: SlashCommandEntry[] = [ { command: "/review", label: "Review", description: "Review uncommitted changes", source: "sdk" }, @@ -129,6 +132,40 @@ const UNIFIED_PERMISSION_OPTIONS: Array<{ value: AgentChatUnifiedPermissionMode; { value: "full-auto", label: "Full auto" }, ]; +type CodexComposerMode = "plan" | "guarded-edit" | "full-auto" | "custom"; + +const CODEX_MODE_PRESETS: Record, { + approval: AgentChatCodexApprovalPolicy; + sandbox: AgentChatCodexSandbox; +}> = { + plan: { approval: "untrusted", sandbox: "read-only" }, + "guarded-edit": { approval: "on-failure", sandbox: "workspace-write" }, + "full-auto": { approval: "never", sandbox: "danger-full-access" }, +}; + +const CODEX_MODE_OPTIONS: Array<{ + value: CodexComposerMode; + label: string; + detail: string; +}> = [ + { value: "plan", label: "Plan", detail: "Read only" }, + { value: "guarded-edit", label: "Guarded edit", detail: "Safer edits" }, + { value: "full-auto", label: "Full auto", detail: "No prompts" }, + { value: "custom", label: "Custom", detail: "Use config.toml" }, +]; + +function resolveCodexComposerMode( + configSource: AgentChatCodexConfigSource | undefined, + approval: AgentChatCodexApprovalPolicy | undefined, + sandbox: AgentChatCodexSandbox | undefined, +): CodexComposerMode { + if (configSource === "config-toml") return "custom"; + if (approval === CODEX_MODE_PRESETS.plan.approval && sandbox === CODEX_MODE_PRESETS.plan.sandbox) return "plan"; + if (approval === CODEX_MODE_PRESETS["guarded-edit"].approval && sandbox === CODEX_MODE_PRESETS["guarded-edit"].sandbox) return "guarded-edit"; + if (approval === CODEX_MODE_PRESETS["full-auto"].approval && sandbox === CODEX_MODE_PRESETS["full-auto"].sandbox) return "full-auto"; + return "custom"; +} + type AdvancedSettingsPopoverProps = { executionModeOptions: ExecutionModeOption[]; executionMode: AgentChatExecutionMode | null; @@ -157,15 +194,14 @@ function AdvancedSettingsPopover({ onIncludeProjectDocsChange, }: AdvancedSettingsPopoverProps) { const [hoveredExecutionMode, setHoveredExecutionMode] = useState(null); - const activeBackend = computerUseSnapshot?.activeBackend?.name ?? (computerUsePolicy.allowLocalFallback ? "Fallback allowed" : "No fallback"); const activeExecutionMode = executionModeOptions.find((option) => option.value === executionMode) ?? executionModeOptions[0] ?? null; const helpMode = hoveredExecutionMode ? executionModeOptions.find((option) => option.value === hoveredExecutionMode) ?? activeExecutionMode : activeExecutionMode; return ( -
-
+
+
Advanced settings
@@ -294,7 +330,7 @@ function AdvancedSettingsPopover({
{helpMode ? ( -
+
Mode help {helpMode.label} @@ -309,10 +345,10 @@ function AdvancedSettingsPopover({ function ComputerUseSettingsModal({ open, - policy, + policy: _policy, snapshot, onClose, - onChange, + onChange: _onChange, onOpenProof, }: { open: boolean; @@ -334,8 +370,8 @@ function ComputerUseSettingsModal({ if (event.target === event.currentTarget) onClose(); }} > -
-
+
+
Computer use
@@ -502,6 +538,8 @@ export function AgentChatComposer({ const textareaRef = useRef(null); const advancedMenuRef = useRef(null); const advancedButtonRef = useRef(null); + const searchRequestIdRef = useRef(0); + const fileAddInProgressRef = useRef(false); const canAttach = !turnActive; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); @@ -580,19 +618,19 @@ export function AgentChatComposer({ setAttachmentCursor(0); return; } - let cancelled = false; + const requestId = ++searchRequestIdRef.current; const timeout = window.setTimeout(() => { setAttachmentBusy(true); onSearchAttachments(query) .then((results) => { - if (cancelled) return; + if (searchRequestIdRef.current !== requestId) return; setAttachmentResults(results.filter((r) => !attachedPaths.has(r.path))); setAttachmentCursor(0); }) - .catch(() => { if (!cancelled) setAttachmentResults([]); }) - .finally(() => { if (!cancelled) setAttachmentBusy(false); }); + .catch(() => { if (searchRequestIdRef.current === requestId) setAttachmentResults([]); }) + .finally(() => { if (searchRequestIdRef.current === requestId) setAttachmentBusy(false); }); }, 120); - return () => { cancelled = true; window.clearTimeout(timeout); }; + return () => { searchRequestIdRef.current++; window.clearTimeout(timeout); }; }, [attachmentPickerOpen, attachmentQuery, attachedPaths, onSearchAttachments]); const selectAttachment = (attachment: AgentChatFileRef) => { @@ -602,32 +640,38 @@ export function AgentChatComposer({ const addFileAttachments = async (files: FileList | null | undefined) => { if (!canAttach || !files?.length) return; - for (const file of Array.from(files)) { - const fileWithPath = file as File & { path?: string }; - const hasRealPath = typeof fileWithPath.path === "string" && fileWithPath.path.trim().length > 0; - - if (hasRealPath) { - // File from filesystem (drag-drop from Finder, native picker) - const filePath = fileWithPath.path!; - onAddAttachment({ path: filePath, type: inferAttachmentType(filePath, file.type) }); - } else { - // Clipboard paste or browser drag — no filesystem path. - // Read the blob, save to a temp file via IPC, then attach. - try { - const buf = await file.arrayBuffer(); - const bytes = new Uint8Array(buf); - let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - const base64 = btoa(binary); - const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ - data: base64, - filename: file.name || "clipboard.png", - }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); - } catch { - // Silently skip files that can't be saved + if (fileAddInProgressRef.current) return; + fileAddInProgressRef.current = true; + try { + for (const file of Array.from(files)) { + const fileWithPath = file as File & { path?: string }; + const hasRealPath = typeof fileWithPath.path === "string" && fileWithPath.path.trim().length > 0; + + if (hasRealPath) { + // File from filesystem (drag-drop from Finder, native picker) + const filePath = fileWithPath.path!; + onAddAttachment({ path: filePath, type: inferAttachmentType(filePath, file.type) }); + } else { + // Clipboard paste or browser drag — no filesystem path. + // Read the blob, save to a temp file via IPC, then attach. + try { + const buf = await file.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + const base64 = btoa(binary); + const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ + data: base64, + filename: file.name || "clipboard.png", + }); + onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); + } catch { + // Silently skip files that can't be saved + } } } + } finally { + fileAddInProgressRef.current = false; } }; @@ -642,7 +686,11 @@ export function AgentChatComposer({ }; const nativeControlsDisabled = permissionModeLocked; - const showCodexFlagControls = codexConfigSource !== "config-toml"; + const codexMode = useMemo( + () => resolveCodexComposerMode(codexConfigSource, codexApprovalPolicy, codexSandbox), + [codexApprovalPolicy, codexConfigSource, codexSandbox], + ); + const showCodexFlagControls = sessionProvider === "codex" && codexMode === "custom"; const nativeControlPanel = useMemo(() => { const renderSelect = ( label: string, @@ -651,8 +699,8 @@ export function AgentChatComposer({ onChange: ((value: T) => void) | undefined, disabled = false, ) => ( -
{!attachmentQuery.trim().length ? ( -
Type to search files...
+
Type to search files...
) : attachmentBusy ? ( -
Searching...
+
Searching...
) : attachmentResults.length ? ( attachmentResults.map((result, index) => ( )) ) : ( -
No matching files.
+
No matching files.
)}
@@ -924,56 +1016,53 @@ export function AgentChatComposer({ } footer={ -
-
+
+
- {nativeControlPanel} -
- -
- +
{nativeControlPanel}
+
+ +
-
-
-
- - - -
+
+
+ + + +
-
@@ -1090,7 +1179,7 @@ export function AgentChatComposer({ > {promptSuggestion} - + Tab @@ -1106,12 +1195,15 @@ export function AgentChatComposer({ if (val.startsWith("/")) { setSlashQuery(val.slice(1)); setSlashCursor(0); } }} className={cn( - "min-h-[40px] max-h-[160px] w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + "min-h-[40px] max-h-[160px] w-full resize-none bg-transparent px-4 py-3 font-sans text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", dragActive ? "opacity-30" : "", )} placeholder={turnActive ? "Steer the active turn..." : (promptSuggestion ? "" : (messagePlaceholder ?? "Message the assistant..."))} onKeyDown={handleKeyDown} onPaste={handlePaste} + spellCheck + autoCorrect="on" + autoCapitalize="sentences" />
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index ee7df4b91..3d70389d6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -138,7 +138,24 @@ describe("AgentChatMessageList operator navigation suggestions", () => { }); describe("AgentChatMessageList transcript rendering", () => { - it("renders memory system notices in the transcript", () => { + it("renders queued follow-ups as pending next-turn notices", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "user_message", + text: "what are you doing?", + deliveryState: "queued", + }, + }, + ]); + + expect(screen.getByText(/Queued.*will be delivered/)).toBeTruthy(); + expect(screen.getByText("what are you doing?")).toBeTruthy(); + }); + + it("renders memory system notices as compact pills in the transcript", () => { renderMessageList([ { sessionId: "session-1", @@ -146,14 +163,49 @@ describe("AgentChatMessageList transcript rendering", () => { event: { type: "system_notice", noticeKind: "memory", - message: "Checked memory: 5 hits, injected 3 relevant entries", - detail: "Policy: required\nProject hits: 4\nAgent hits: 1", + message: "Memory: 3 relevant entries injected", + detail: { + summary: "Memory: 3 relevant entries injected", + }, + }, + }, + ]); + + // Memory notices now render as a compact pill, not a collapsible card + expect(screen.getByText("Memory: 3 relevant entries injected")).toBeTruthy(); + // No collapsible detail sections + expect(screen.queryByText("Memory lookup")).toBeNull(); + expect(screen.queryByText("Policy")).toBeNull(); + }); + + it("renders provider health and thread error notices distinctly", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "system_notice", + noticeKind: "provider_health", + message: "Claude is taking longer than usual", + detail: "Streaming is still connected, but the provider is slow to respond.", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "system_notice", + noticeKind: "thread_error", + message: "Codex session is missing thread id", + detail: "The session returned a turn result without a thread identifier.", }, }, ]); - expect(screen.getByText("memory")).toBeTruthy(); - expect(screen.getByText("Checked memory: 5 hits, injected 3 relevant entries")).toBeTruthy(); + expect(screen.getByText("provider health")).toBeTruthy(); + expect(screen.getByText("thread error")).toBeTruthy(); + expect(screen.getByText("Claude is taking longer than usual")).toBeTruthy(); + expect(screen.getByText("Codex session is missing thread id")).toBeTruthy(); }); it("groups consecutive commands into one compact work log block", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 7d6d19ec8..06e9dc0a9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useLocation, useNavigate } from "react-router-dom"; @@ -29,6 +30,7 @@ import type { AgentChatApprovalDecision, AgentChatEvent, AgentChatEventEnvelope, + AgentChatNoticeDetail, ChatSurfaceChipTone, FilesWorkspace, ChatSurfaceProfile, @@ -45,8 +47,10 @@ import { getToolMeta } from "./chatToolAppearance"; import { ClaudeLogo, CodexLogo } from "../terminals/ToolLogos"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { ChatWorkLogBlock } from "./ChatWorkLogBlock"; +import { HighlightedCode } from "./CodeHighlighter"; import { collapseChatTranscriptEventsIncremental, + deriveTurnDividerData, formatStructuredValue, groupConsecutiveWorkLogRows, readRecord, @@ -55,6 +59,7 @@ import { type ChatTranscriptGroupedEnvelope as TranscriptGroupedEnvelope, type ChatTranscriptRenderEnvelope as TranscriptRenderEnvelope, } from "./chatTranscriptRows"; +import { ChatTurnDivider, type TurnDividerData } from "./ChatTurnDivider"; const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); @@ -153,13 +158,13 @@ function renderSubagentUsage(usage: { } const GLASS_CARD_CLASS = - "overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#121216]"; + "overflow-hidden rounded-[14px] border border-[color:var(--chat-card-border)] bg-[var(--chat-card-bg)] shadow-[var(--chat-card-shadow)]"; const WORK_LOG_CARD_CLASS = - "border border-white/[0.06] bg-[#111317]/70"; + "border border-[color:var(--chat-panel-border)] bg-[var(--chat-panel-bg)]/88"; const RECESSED_BLOCK_CLASS = - "overflow-auto whitespace-pre-wrap break-words rounded-[10px] border border-white/[0.05] bg-[#09090b] px-4 py-3 font-mono text-[11px] leading-[1.6] text-fg/76"; + "overflow-auto whitespace-pre-wrap break-words rounded-[10px] border border-[color:var(--chat-code-border)] bg-[var(--chat-code-bg)] px-4 py-3 font-mono text-[11px] leading-[1.6] text-[var(--chat-code-fg)]"; function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChipTone } | null { if (toolName.startsWith("mcp__")) { @@ -180,25 +185,93 @@ function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChi return null; } -function messageCardStyle(): React.CSSProperties { - return { - borderColor: "rgba(245, 158, 11, 0.16)", - background: "#171412", - }; +const MESSAGE_CARD_STYLE: React.CSSProperties = { + borderColor: "var(--chat-card-border)", + background: "var(--chat-card-bg)", +}; + +const SURFACE_INLINE_CARD_STYLE: React.CSSProperties = { + borderColor: "var(--chat-panel-border)", + background: "var(--chat-panel-bg)", +}; + +const ASSISTANT_MESSAGE_CARD_STYLE: React.CSSProperties = { + borderColor: "var(--chat-panel-border)", + background: "var(--chat-panel-bg-strong)", +}; + +function renderNoticeDetailMetric(metric: { + label: string; + value: string; + tone?: ChatSurfaceChipTone; +}) { + return ( +
+
+ {metric.label} +
+
+ {metric.value} +
+
+ ); } -function surfaceInlineCardStyle(): React.CSSProperties { - return { - borderColor: "rgba(255, 255, 255, 0.08)", - background: "#14161a", - }; +function renderNoticeDetailSectionItem(item: string | { label: string; value: string; tone?: ChatSurfaceChipTone }) { + if (typeof item === "string") { + return
{item}
; + } + return renderNoticeDetailMetric(item); } -function assistantMessageCardStyle(): React.CSSProperties { - return { - borderColor: "rgba(148, 163, 184, 0.14)", - background: "#101318", - }; +function renderNoticeDetail(detail: string | AgentChatNoticeDetail) { + if (typeof detail === "string") { + return
{detail}
; + } + + const sections = detail.sections ?? []; + const hasAdditionalDetail = Boolean(detail.metrics?.length || sections.length); + + return ( +
+ {detail.title ? ( +
+ {detail.title} +
+ ) : null} + {detail.summary && !hasAdditionalDetail ? ( +
+ {detail.summary} +
+ ) : null} + {detail.metrics?.length ? ( +
+ {detail.metrics.map((metric) => renderNoticeDetailMetric(metric))} +
+ ) : null} + {sections.length ? ( +
+ {sections.map((section) => ( +
+
+ {section.title} +
+
+ {section.items.map((item, index) => ( +
+ {renderNoticeDetailSectionItem(item)} +
+ ))} +
+
+ ))} +
+ ) : null} +
+ ); } function describeUserDeliveryState(event: Extract): { label: string; className: string } | null { @@ -403,7 +476,7 @@ function InlineDisclosureRow({
{summary}
{expandable && open ? ( -
+
{children}
) : null} @@ -479,7 +552,7 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ }, [onOpenWorkspacePath, workspaceLaneId]); return ( -
+

{children}

, h3: ({ children }) =>

{children}

, blockquote: ({ children }) => ( -
+
{children}
), @@ -496,19 +569,30 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ {children}
), - pre: ({ children }) => ( -
-              {children}
-            
- ), + pre: ({ children }) => { + // When code blocks are handled by HighlightedCode, skip the default
 wrapper
+            // since HighlightedCode provides its own styled container.
+            const child = React.Children.toArray(children)[0];
+            if (React.isValidElement(child) && (child as React.ReactElement).type === HighlightedCode) {
+              return <>{children};
+            }
+            return (
+              
+                {children}
+              
+ ); + }, code: ({ className, children }) => { const text = String(children ?? ""); - const isBlock = /\n/.test(text) || (typeof className === "string" && className.length > 0); + const langMatch = typeof className === "string" ? className.match(/language-(\S+)/) : null; + const isBlock = /\n/.test(text) || langMatch != null; const workspacePath = !isBlock ? normalizeWorkspacePathCandidate(text) : null; const pathIsClickable = Boolean(workspacePath && looksLikeWorkspacePath(workspacePath)); - return isBlock ? ( - {children} - ) : pathIsClickable ? ( + if (isBlock) { + const language = langMatch?.[1] ?? "text"; + return ; + } + return pathIsClickable ? ( ) : ( - {children} + {children} ); }, a: ({ children, href }) => { @@ -586,16 +670,16 @@ function CollapsibleCard({ const isOpen = forceOpen === true ? true : open; return ( -
+
- {isOpen ?
{children}
: null} + {isOpen ?
{children}
: null}
); } @@ -637,7 +721,9 @@ const ACTIVITY_LABELS: Record = { running_command: "Running command", searching: "Searching", reading: "Reading", - tool_calling: "Calling tool" + tool_calling: "Calling tool", + web_searching: "Web searching", + spawning_agent: "Spawning agent" }; function ThinkingDots({ toneClass = "bg-fg/30" }: { toneClass?: string }) { @@ -660,7 +746,7 @@ function ActivityIndicator({ activity, detail }: { activity: string; detail?: st const displayText = detail ? `${label}: ${replaceInternalToolNames(detail)}` : `${label}...`; return ( -
+
{displayText}
@@ -687,7 +773,7 @@ function ToolResultCard({ event }: { event: Extract +
@@ -718,7 +804,7 @@ function ToolResultCard({ event }: { event: Extract navigate(suggestion.href)} > {suggestion.label} @@ -732,7 +818,7 @@ function ToolResultCard({ event }: { event: Extract setExpanded((v) => !v)} > {expanded ? "collapse" : `show all (${resultStr.length} chars)`} @@ -747,7 +833,7 @@ function ToolResultCard({ event }: { event: Extract -
+
$ {event.command}
{hasOutput ? ( -
+        
           {event.output}
         
) : null} @@ -925,7 +1011,7 @@ function FileChangeEventCard({ {hasDiff ? ( ) : ( -
No diff payload available.
+
No diff payload available.
)} ); @@ -948,9 +1034,34 @@ function renderEvent( /* ── User message ── */ if (event.type === "user_message") { const deliveryChip = describeUserDeliveryState(event); + if (event.deliveryState === "queued" && !event.turnId) { + return ( +
+
+
+ + + + + + Queued — will be delivered after this turn + + {formatTime(envelope.timestamp)} +
+
{event.text}
+ {event.attachments?.length ? ( + + ) : null} +
+
+ ); + } return (
-
+
@@ -989,7 +1100,7 @@ function renderEvent( "group max-w-[94%] px-4 py-3 transition-[min-height] duration-300 ease-out", options?.turnActive ? "min-h-[5.5rem]" : "min-h-0", )} - style={assistantMessageCardStyle()} + style={ASSISTANT_MESSAGE_CARD_STYLE} >
@@ -1055,11 +1166,11 @@ function renderEvent(
)) ) : ( -
No plan steps yet.
+
No plan steps yet.
)}
{event.explanation ? ( -
{event.explanation}
+
{event.explanation}
) : null} ); @@ -1115,7 +1226,7 @@ function renderEvent(
)) ) : ( -
No items yet.
+
No items yet.
)}
@@ -1315,13 +1426,13 @@ function renderEvent( /* ── Structured Question ── */ if (event.type === "structured_question") { return ( -
+
- Agent Question - {formatTime(envelope.timestamp)} + Agent Question + {formatTime(envelope.timestamp)}
{event.question} @@ -1332,7 +1443,7 @@ function renderEvent(
) : null} -
or type a custom answer
+
or type a custom answer
); } @@ -1401,45 +1512,71 @@ function renderEvent( /* ── System Notice ── */ if (event.type === "system_notice") { - const kindStyles: Record = { - auth: { border: "border-amber-500/18", bg: "bg-amber-500/[0.06]", text: "text-amber-300", icon: Warning }, - rate_limit: { border: "border-red-500/18", bg: "bg-red-500/[0.06]", text: "text-red-300", icon: Warning }, - hook: { border: "border-violet-500/18", bg: "bg-violet-500/[0.06]", text: "text-violet-300", icon: Note }, - file_persist: { border: "border-emerald-500/18", bg: "bg-emerald-500/[0.06]", text: "text-emerald-300", icon: Note }, - memory: { border: "border-cyan-500/18", bg: "bg-cyan-500/[0.06]", text: "text-cyan-300", icon: MagnifyingGlass }, - info: { border: "border-border/14", bg: "bg-surface-recessed/70", text: "text-muted-fg/55", icon: Note }, + const kindStyles: Record = { + auth: { border: "border-amber-500/18", bg: "bg-amber-500/[0.08]", text: "text-amber-300", icon: Warning, label: "auth" }, + rate_limit: { border: "border-red-500/18", bg: "bg-red-500/[0.08]", text: "text-red-300", icon: Warning, label: "rate limit" }, + hook: { border: "border-violet-500/18", bg: "bg-violet-500/[0.08]", text: "text-violet-300", icon: Note, label: "hook" }, + file_persist: { border: "border-emerald-500/18", bg: "bg-emerald-500/[0.08]", text: "text-emerald-300", icon: Note, label: "saved" }, + memory: { border: "border-cyan-500/18", bg: "bg-cyan-500/[0.08]", text: "text-cyan-300", icon: MagnifyingGlass, label: "memory" }, + provider_health: { border: "border-sky-400/18", bg: "bg-sky-500/[0.08]", text: "text-sky-300", icon: Info, label: "provider health" }, + thread_error: { border: "border-red-400/18", bg: "bg-red-500/[0.08]", text: "text-red-300", icon: Warning, label: "thread error" }, + info: { border: "border-border/14", bg: "bg-surface-recessed/70", text: "text-muted-fg/55", icon: Note, label: "info" }, }; const style = kindStyles[event.noticeKind] ?? kindStyles.info!; const NoticeIcon = style.icon; - const hasDetail = event.detail != null && event.detail.length > 0; + const detailText = typeof event.detail === "string" ? event.detail.trim() : ""; + const structuredDetail = typeof event.detail === "object" && event.detail !== null ? (event.detail as AgentChatNoticeDetail) : null; + const hasDetail = detailText.length > 0 || structuredDetail != null; + const detailPreview = structuredDetail?.summary && structuredDetail.summary.trim().length > 0 + ? structuredDetail.summary.trim() + : detailText.length > 0 + ? summarizeInlineText(detailText, 120) + : null; + + if (event.noticeKind === "memory") { + // Compact memory notice — just a single-line pill + return ( +
+
+ + {event.message} +
+
+ ); + } if (hasDetail) { return ( +
- - {event.noticeKind.replace("_", " ")} - - {event.message} +
+
+ + {style.label} + + {event.message} +
+ {detailPreview ?
{detailPreview}
: null} +
} className={style.border} > -
{event.detail}
+ {structuredDetail ? renderNoticeDetail(structuredDetail as AgentChatNoticeDetail) : renderNoticeDetail(detailText)}
); } return (
- {event.noticeKind.replace("_", " ")} + {style.label} {event.message}
); @@ -1461,7 +1598,7 @@ function renderEvent( defaultOpen={false} forceOpen={isLive ? true : undefined} summary={ - + {isLive ? ( @@ -1505,7 +1642,7 @@ function renderEvent( +
{event.status === "running" ? ( ) : event.status === "failed" ? ( @@ -1524,20 +1661,20 @@ function renderEvent( >
-
Arguments
+
Arguments
{argCount ? (
                 {formatStructuredValue(args)}
               
) : ( -
+
No arguments
)}
{resultText ? (
-
Result
+
Result
                 {resultText}
               
@@ -1591,7 +1728,7 @@ function renderEvent( +
{label} @@ -1656,7 +1793,7 @@ function renderEvent( bodyText = event.description; } return ( -
+
{isAskUser ? ( Request Details} - className="border-transparent bg-surface/35" + summary={Request Details} + className="border-transparent bg-[var(--chat-panel-bg)]/35" >
                 {detailText}
@@ -1700,7 +1837,7 @@ function renderEvent(
           
) : null} {isAskUser ? ( -
+
Answer this from the question modal to keep the agent moving.
) : null} @@ -1743,16 +1880,16 @@ function renderEvent( /* ── Error ── */ if (event.type === "error") { return ( -
+
- Error + Error {event.errorInfo && typeof event.errorInfo !== "string" && event.errorInfo.category ? ( - + {event.errorInfo.category} ) : null} @@ -1762,7 +1899,7 @@ function renderEvent(
{event.message}
{event.errorInfo ? ( -
+
{typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`}
) : null} @@ -1786,11 +1923,11 @@ function renderEvent( return (
@@ -1816,9 +1953,9 @@ function renderEvent( return (
@@ -1881,8 +2018,8 @@ function renderEvent( ? "border-red-500/15 bg-red-500/[0.05] text-red-200" : "border-amber-500/15 bg-amber-500/[0.05] text-amber-200"; return ( -
-
+
+
Completion {event.report.status} {event.report.artifacts.length > 0 ? ( @@ -2047,8 +2184,8 @@ function TurnSummaryCard({ : null; return ( -
-
+
+
@@ -2059,7 +2196,7 @@ function TurnSummaryCard({ {onReviewChanges && summary.files.length > 0 ? (
{summary.tasks.length ? ( -
+
{summary.tasks.map((task, index) => (
@@ -2086,7 +2223,7 @@ function TurnSummaryCard({
) : null} -
+
{filesLabel ? ( {filesLabel} @@ -2178,6 +2315,8 @@ type EventRowProps = { envelope: TranscriptGroupedEnvelope; showTurnDivider: boolean; turnDividerLabel: string | null; + currentTurn: string | null; + turnDividerMap: Map; turnModel: { label: string; modelId?: string; model?: string } | null; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null) => void; surfaceMode?: ChatSurfaceMode; @@ -2192,6 +2331,8 @@ const EventRow = React.memo(function EventRow({ envelope, showTurnDivider, turnDividerLabel, + currentTurn, + turnDividerMap, turnModel, onApproval, surfaceMode = "standard", @@ -2203,22 +2344,31 @@ const EventRow = React.memo(function EventRow({ }: EventRowProps) { return (
- {showTurnDivider ? ( -
- - - {turnModel?.label ? ( - <> - - {turnModel.label} - · - - ) : null} - {turnDividerLabel ?? "Turn"} - - -
- ) : null} + {showTurnDivider && currentTurn ? (() => { + const dividerData = turnDividerMap.get(currentTurn); + return dividerData ? ( + + ) : ( +
+ + + {turnModel?.label ? ( + <> + + {turnModel.label} + · + + ) : null} + {turnDividerLabel ?? "Turn"} + + +
+ ); + })() : null} {envelope.event.type === "work_log_group" ? ( void }) { - const rowRef = useRef(null); - - useEffect(() => { - const el = rowRef.current; - if (!el) return; - // Report the actual rendered height (including margin from space-y-3 = 12px gap). - const height = el.offsetHeight; - if (height > 0) onMeasure(index, height); - }); - - return ( -
- -
- ); -}); - /* ── Virtualization constants ── */ /** Estimated height per message row (px) used before real measurement. */ const ESTIMATED_ROW_HEIGHT = 80; -/** Gap between rows from `space-y-3` (Tailwind 0.75rem = 12px). */ -const ROW_GAP = 12; /** Number of extra rows to render above/below the visible viewport. */ const OVERSCAN = 10; /** Minimum number of rows before virtualization kicks in. */ const VIRTUALIZATION_THRESHOLD = 60; +/** Number of recent rows rendered outside the virtualizer (non-virtualized tail). */ +const TAIL_ROW_COUNT = 8; export function AgentChatMessageList({ events, @@ -2300,10 +2424,7 @@ export function AgentChatMessageList({ const stickToBottomRef = useRef(true); const onApprovalRef = useRef(onApproval); - // Virtualization scroll tracking - const [scrollTop, setScrollTop] = useState(0); - const [containerHeight, setContainerHeight] = useState(0); - // Map of row index → measured height (filled in lazily as rows render) + // Map of row index → measured height (filled in lazily as rows render, used as estimateSize fallback) const measuredHeights = useRef>(new Map()); useEffect(() => { @@ -2328,6 +2449,7 @@ export function AgentChatMessageList({ [activeTurnId, events, showStreamingIndicator], ); const turnSummary = useMemo(() => deriveTurnSummary(events), [events]); + const turnDividerMap = useMemo(() => deriveTurnDividerData(events), [events]); const currentLaneId = typeof (location.state as { laneId?: unknown } | null)?.laneId === "string" ? (location.state as { laneId: string }).laneId : null; @@ -2353,34 +2475,38 @@ export function AgentChatMessageList({ }, []); const openWorkspacePath = useCallback(async (path: string) => { - let resolvedWorkspaces = filesWorkspaces; - let target = resolveFilesNavigationTarget({ - path, - workspaces: resolvedWorkspaces, - fallbackLaneId: currentLaneId, - }); - if (!target && normalizeWorkspacePathCandidate(path)?.startsWith("/")) { - const listWorkspaces = window.ade?.files?.listWorkspaces; - if (typeof listWorkspaces === "function") { - try { - resolvedWorkspaces = await listWorkspaces(); - setFilesWorkspaces(resolvedWorkspaces); - target = resolveFilesNavigationTarget({ - path, - workspaces: resolvedWorkspaces, - fallbackLaneId: currentLaneId, - }); - } catch { - target = null; + try { + let resolvedWorkspaces = filesWorkspaces; + let target = resolveFilesNavigationTarget({ + path, + workspaces: resolvedWorkspaces, + fallbackLaneId: currentLaneId, + }); + if (!target && normalizeWorkspacePathCandidate(path)?.startsWith("/")) { + const listWorkspaces = window.ade?.files?.listWorkspaces; + if (typeof listWorkspaces === "function") { + try { + resolvedWorkspaces = await listWorkspaces(); + setFilesWorkspaces(resolvedWorkspaces); + target = resolveFilesNavigationTarget({ + path, + workspaces: resolvedWorkspaces, + fallbackLaneId: currentLaneId, + }); + } catch { + target = null; + } } } + if (!target) return; + const state = target.laneId + ? { openFilePath: target.openFilePath, laneId: target.laneId } + : { openFilePath: target.openFilePath }; + navigate("/files", { state }); + onOpenWorkspacePath?.(target.openFilePath, target.laneId); + } catch (err) { + console.warn("[AgentChatMessageList] Failed to open workspace path:", path, err); } - if (!target) return; - const state = target.laneId - ? { openFilePath: target.openFilePath, laneId: target.laneId } - : { openFilePath: target.openFilePath }; - navigate("/files", { state }); - onOpenWorkspacePath?.(target.openFilePath, target.laneId); }, [currentLaneId, filesWorkspaces, navigate, onOpenWorkspacePath]); const handleReviewChanges = useCallback(() => { @@ -2393,12 +2519,16 @@ export function AgentChatMessageList({ navigate(suggestion.href); }, [navigate]); + const doneEvents = useMemo( + () => events.filter((envelope) => envelope.event.type === "done"), + [events], + ); const turnModelState = useMemo(() => { const map = new Map(); let lastModel: { label: string; modelId?: string; model?: string } | null = null; - for (const envelope of events) { + for (const envelope of doneEvents) { const evt = envelope.event; - if (evt.type !== "done") continue; + if (evt.type !== "done") continue; // type-narrowing guard const modelLabel = resolveModelLabel(evt.modelId, evt.model); if (!evt.turnId || !modelLabel) continue; const model = { @@ -2410,7 +2540,7 @@ export function AgentChatMessageList({ lastModel = model; } return { map, lastModel }; - }, [events]); + }, [doneEvents]); useEffect(() => { stickToBottomRef.current = stickToBottom; @@ -2434,90 +2564,59 @@ export function AgentChatMessageList({ return () => cancelAnimationFrame(raf); }, [groupedRows, stickToBottom, showStreamingIndicator]); - // Observe the scroll container's size so we know the viewport height. - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - if (typeof ResizeObserver === "undefined") { - // Fallback for test environments / old browsers - setContainerHeight(el.clientHeight); - return; - } - const ro = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerHeight(entry.contentRect.height); - } - }); - ro.observe(el); - setContainerHeight(el.clientHeight); - return () => ro.disconnect(); - }, []); - - /** Returns the best-known height for a given row index. */ - const rowHeight = useCallback((index: number) => { - return measuredHeights.current.get(index) ?? ESTIMATED_ROW_HEIGHT; - }, []); - - /** Callback from MeasuredEventRow when it measures its real DOM height. */ - const handleMeasure = useCallback((index: number, height: number) => { - const prev = measuredHeights.current.get(index); - if (prev !== height) { - measuredHeights.current.set(index, height); - } - }, []); - const shouldVirtualize = groupedRows.length >= VIRTUALIZATION_THRESHOLD; - // Compute the visible window of rows when virtualization is active. - const { startIndex, endIndex, totalHeight, offsetTop } = useMemo(() => { - if (!shouldVirtualize) { - return { startIndex: 0, endIndex: groupedRows.length, totalHeight: 0, offsetTop: 0 }; - } - - // Build cumulative offset array for each rendered grouped row's top position. - let cumulative = 0; - const offsets: number[] = new Array(groupedRows.length); - for (let i = 0; i < groupedRows.length; i++) { - offsets[i] = cumulative; - cumulative += rowHeight(i) + ROW_GAP; - } - const totalH = cumulative - (groupedRows.length > 0 ? ROW_GAP : 0); - - // Determine visible range from scrollTop / containerHeight. - const viewTop = scrollTop; - const viewBottom = scrollTop + containerHeight; - - // Binary search for the first row visible. - let lo = 0; - let hi = groupedRows.length - 1; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const rowBottom = offsets[mid]! + rowHeight(mid); - if (rowBottom < viewTop) { - lo = mid + 1; - } else { - hi = mid; + // Split rows into virtualized (old/completed) and tail (recent + active turn) sections. + // The tail section is rendered outside the virtualizer so streaming text updates + // don't trigger virtualizer measurement callbacks, eliminating scroll jank. + const { virtualizedRows, tailRows } = useMemo(() => { + if (!shouldVirtualize) return { virtualizedRows: [] as TranscriptGroupedEnvelope[], tailRows: groupedRows }; + + // Find the first row of the active turn + let activeTurnStartIndex = groupedRows.length; + if (activeTurnId) { + for (let i = 0; i < groupedRows.length; i++) { + const turnId = getGroupedTurnId(groupedRows[i]); + if (turnId === activeTurnId) { + activeTurnStartIndex = i; + break; + } } } - const firstVisible = lo; - // Walk forward to find the last visible row. - let lastVisible = firstVisible; - while (lastVisible < groupedRows.length - 1 && offsets[lastVisible + 1]! < viewBottom) { - lastVisible++; - } - - // Apply overscan - const start = Math.max(0, firstVisible - OVERSCAN); - const end = Math.min(groupedRows.length, lastVisible + 1 + OVERSCAN); + // Tail = max(last TAIL_ROW_COUNT rows, all rows from active turn start) + const tailStart = Math.min( + Math.max(0, groupedRows.length - TAIL_ROW_COUNT), + activeTurnStartIndex, + ); return { - startIndex: start, - endIndex: end, - totalHeight: totalH, - offsetTop: offsets[start] ?? 0, + virtualizedRows: groupedRows.slice(0, tailStart), + tailRows: groupedRows.slice(tailStart), }; - }, [shouldVirtualize, groupedRows.length, scrollTop, containerHeight, rowHeight]); + }, [groupedRows, activeTurnId, shouldVirtualize]); + + const virtualizedRowCount = virtualizedRows.length; + + // @tanstack/react-virtual virtualizer for the old/completed rows section. + const virtualizer = useVirtualizer({ + count: virtualizedRowCount, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => measuredHeights.current.get(index) ?? ESTIMATED_ROW_HEIGHT, + overscan: OVERSCAN, + scrollMargin: 0, + }); + + // When the virtualizer measures elements, cache their heights for future estimateSize calls. + const virtualItems = virtualizer.getVirtualItems(); + useEffect(() => { + for (const item of virtualItems) { + const prev = measuredHeights.current.get(item.index); + if (prev !== item.size) { + measuredHeights.current.set(item.index, item.size); + } + } + }, [virtualItems]); const handleScroll = useCallback((event: React.UIEvent) => { const target = event.currentTarget; @@ -2527,10 +2626,7 @@ export function AgentChatMessageList({ stickToBottomRef.current = nextStick; setStickToBottom(nextStick); } - if (shouldVirtualize) { - setScrollTop(target.scrollTop); - } - }, [shouldVirtualize]); + }, []); const jumpToLiveOutput = useCallback(() => { const el = scrollRef.current; @@ -2540,8 +2636,8 @@ export function AgentChatMessageList({ setStickToBottom(true); }, []); - /** Renders a single row with turn-divider logic. Used by both paths. */ - const renderRow = useCallback((envelope: TranscriptGroupedEnvelope, index: number, virtualized: boolean) => { + /** Renders a single row with turn-divider logic. Used by both virtualized and non-virtualized paths. */ + const renderRow = useCallback((envelope: TranscriptGroupedEnvelope, index: number) => { const currentTurn = getGroupedTurnId(envelope); const previousTurn = getGroupedTurnId(groupedRows[index - 1]); const showTurnDivider = currentTurn && currentTurn !== previousTurn; @@ -2552,33 +2648,14 @@ export function AgentChatMessageList({ ? (turnModelState.map.get(currentTurn) ?? null) : turnModelState.lastModel; - if (virtualized) { - return ( - - ); - } - return ( ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion]); - - // Compute the bottom spacer height for virtualized mode. - const bottomSpacerHeight = useMemo(() => { - if (!shouldVirtualize) return 0; - let h = 0; - for (let i = endIndex; i < groupedRows.length; i++) { - h += rowHeight(i) + ROW_GAP; - } - // Remove trailing gap - if (groupedRows.length > endIndex) h -= ROW_GAP; - return Math.max(0, h); - }, [shouldVirtualize, endIndex, groupedRows.length, rowHeight]); + }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, turnModelState, turnDividerMap, handleApproval, openWorkspacePath, handleNavigateSuggestion]); const streamingIndicator = showStreamingIndicator ? ( latestActivity ? ( @@ -2621,7 +2686,7 @@ export function AgentChatMessageList({ const stickyStreamingBanner = showStreamingIndicator && activeTurnId ? (
@@ -2630,7 +2695,7 @@ export function AgentChatMessageList({
{latestActivity?.detail ? replaceInternalToolNames(latestActivity.detail) : "Still working"}
-
+
{activeElapsedLabel ? `Working for ${activeElapsedLabel}` : "Turn running"}
@@ -2650,7 +2715,7 @@ export function AgentChatMessageList({ return (
{stickyStreamingBanner} @@ -2662,32 +2727,63 @@ export function AgentChatMessageList({
Start a chat session
- + {surfaceMode === "resolver" ? "Launch the resolver to start the transcript" : "Start a conversation"}
) : shouldVirtualize ? ( - /* ── Virtualized path: only render rows in / near the viewport ── */ + /* ── Hybrid virtualized path: virtualizer for old rows + non-virtualized tail ── */
-
- {/* Top spacer pushes rendered rows to their correct scroll position */} -
-
- {groupedRows.slice(startIndex, Math.min(endIndex, groupedRows.length)).map((envelope, i) => - renderRow(envelope, startIndex + i, true) - )} + {/* Virtualized section: old/completed rows managed by @tanstack/react-virtual */} + {virtualizedRowCount > 0 ? ( +
+ {virtualItems.map((virtualRow) => { + const envelope = virtualizedRows[virtualRow.index]; + if (!envelope) return null; + return ( +
+ {renderRow(envelope, virtualRow.index)} +
+ ); + })}
- {/* Bottom spacer fills remaining scroll area */} -
-
+ ) : null} + + {/* Non-virtualized tail: last rows + active turn rows — streaming updates only affect this section */} + {tailRows.map((envelope, i) => { + const globalIndex = virtualizedRowCount + i; + return ( + + {renderRow(envelope, globalIndex)} + + ); + })} + {streamingIndicator} {turnSummaryCard}
) : ( /* ── Non-virtualized path: render all rows (small conversation) ── */
- {groupedRows.map((envelope, index) => renderRow(envelope, index, false))} + {groupedRows.map((envelope, index) => renderRow(envelope, index))} {streamingIndicator} {turnSummaryCard}
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 013791702..8e90b447a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -25,7 +25,6 @@ function buildSession(sessionId: string): AgentChatSessionSummary { goal: null, completion: null, reasoningEffort: "xhigh", - permissionMode: "plan", computerUse: createDefaultComputerUsePolicy(), executionMode: "focused", }; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts index 8d0171824..53a959461 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import type { AgentChatSessionSummary } from "../../../shared/types"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; import { - resolveChatSessionProfile, resolveNextSelectedSessionId, shouldPromoteSessionForComputerUse, } from "./AgentChatPane"; @@ -23,7 +22,6 @@ function buildSession(sessionId: string): AgentChatSessionSummary { goal: null, completion: null, reasoningEffort: null, - permissionMode: "plan", computerUse: undefined, executionMode: "focused", }; @@ -59,13 +57,6 @@ describe("resolveNextSelectedSessionId", () => { }); }); -describe("resolveChatSessionProfile", () => { - it("always returns workflow now that off mode is removed", () => { - expect(resolveChatSessionProfile(createDefaultComputerUsePolicy())).toBe("workflow"); - expect(resolveChatSessionProfile({ ...createDefaultComputerUsePolicy(), mode: "enabled" })).toBe("workflow"); - }); -}); - describe("shouldPromoteSessionForComputerUse", () => { it("promotes older light sessions when computer use is on", () => { const policy = createDefaultComputerUsePolicy(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 2debbf855..5d14d171f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -4,53 +4,57 @@ import { createDefaultComputerUsePolicy, inferAttachmentType, type AgentChatApprovalDecision, - type AgentChatClaudePermissionMode, - type AgentChatCodexApprovalPolicy, - type AgentChatCodexConfigSource, - type AgentChatCodexSandbox, type AgentChatExecutionMode, type AgentChatEventEnvelope, type AgentChatFileRef, - type AiProviderConnectionStatus, - type AgentChatUnifiedPermissionMode, type AgentChatSessionProfile, type ChatSurfaceChip, - type ChatSurfaceProfile, type ChatSurfacePresentation, type AgentChatSessionSummary, type ComputerUseOwnerSnapshot, type ComputerUsePolicy, } from "../../../shared/types"; -import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import { MODEL_REGISTRY, getModelById, type ModelDescriptor } from "../../../shared/modelRegistry"; +import { + getModelById, + getRuntimeModelRefForDescriptor, + isModelProviderGroup, + MODEL_REGISTRY, + resolveModelDescriptorForProvider, + type ModelDescriptor, +} from "../../../shared/modelRegistry"; import { filterChatModelIdsForSession } from "../../../shared/chatModelSwitching"; import { cn } from "../ui/cn"; import { AgentChatComposer } from "./AgentChatComposer"; import { AgentChatMessageList } from "./AgentChatMessageList"; import { AgentQuestionModal } from "./AgentQuestionModal"; -import { isChatToolType } from "../../lib/sessions"; import { ToolLogo } from "../terminals/ToolLogos"; -import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { ChatSurfaceShell } from "./ChatSurfaceShell"; import { chatChipToneClass } from "./chatSurfaceTheme"; import { ChatComputerUsePanel } from "./ChatComputerUsePanel"; +import { ChatContextMeter } from "./ChatContextMeter"; import { deriveChatSubagentSnapshots } from "./chatExecutionSummary"; -import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; import { useClickOutside } from "../../hooks/useClickOutside"; -const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; -const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; - -const LEGACY_PROVIDER_KEY = "ade.chat.lastProvider"; -const LEGACY_MODEL_KEY_PREFIX = "ade.chat.lastModel"; +// Hooks +import { useAgentChatEvents } from "./hooks/useAgentChatEvents"; +import { + useAgentChatSessions, + resolveNextSelectedSessionId, +} from "./hooks/useAgentChatSessions"; +import { + useAgentChatComposerState, + summarizeNativeControls, + readLastUsedModelId, + writeLastUsedModelId, + readLastUsedReasoningEffort, + writeLastUsedReasoningEffort, + selectReasoningEffort, + type NativeControlState, +} from "./hooks/useAgentChatComposerState"; const COMPUTER_USE_SNAPSHOT_COOLDOWN_MS = 750; -export function resolveChatSessionProfile(_computerUsePolicy: ComputerUsePolicy): AgentChatSessionProfile { - return "workflow"; -} - export function shouldPromoteSessionForComputerUse( session: Pick | null | undefined, _computerUsePolicy: ComputerUsePolicy, @@ -58,6 +62,9 @@ export function shouldPromoteSessionForComputerUse( return session?.sessionProfile !== "workflow"; } +// Re-export for tests +export { resolveNextSelectedSessionId }; + type ExecutionModeOption = { value: AgentChatExecutionMode; label: string; @@ -89,267 +96,6 @@ function getExecutionModeOptions(model: ModelDescriptor | null | undefined): Exe return []; } -function deriveRuntimeState(events: AgentChatEventEnvelope[]): { - turnActive: boolean; - pendingInputs: DerivedPendingInput[]; -} { - let turnActive = false; - - for (const envelope of events) { - const event = envelope.event; - - if (event.type === "status") { - turnActive = event.turnStatus === "started"; - continue; - } - - if (event.type === "done") { - turnActive = false; - continue; - } - } - - return { - turnActive, - pendingInputs: derivePendingInputRequests(events), - }; -} - -type NativeControlState = { - claudePermissionMode: AgentChatClaudePermissionMode; - codexApprovalPolicy: AgentChatCodexApprovalPolicy; - codexSandbox: AgentChatCodexSandbox; - codexConfigSource: AgentChatCodexConfigSource; - unifiedPermissionMode: AgentChatUnifiedPermissionMode; -}; - -function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState { - if (profile === "persistent_identity") { - return { - claudePermissionMode: "bypassPermissions", - codexApprovalPolicy: "never", - codexSandbox: "danger-full-access", - codexConfigSource: "flags", - unifiedPermissionMode: "full-auto", - }; - } - return { - claudePermissionMode: "default", - codexApprovalPolicy: "on-request", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - unifiedPermissionMode: "edit", - }; -} - -function summarizeNativeControls( - provider: AgentChatSessionSummary["provider"] | "claude" | "codex" | "unified", - controls: NativeControlState, -): Pick< - AgentChatSessionSummary, - "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" | "permissionMode" -> { - if (provider === "claude") { - let permissionMode: AgentChatSessionSummary["permissionMode"]; - if (controls.claudePermissionMode === "bypassPermissions") { - permissionMode = "full-auto"; - } else if (controls.claudePermissionMode === "acceptEdits") { - permissionMode = "edit"; - } else { - permissionMode = controls.claudePermissionMode; - } - return { - claudePermissionMode: controls.claudePermissionMode, - permissionMode, - }; - } - if (provider === "codex") { - let permissionMode: AgentChatSessionSummary["permissionMode"]; - if (controls.codexConfigSource === "config-toml") { - permissionMode = "config-toml"; - } else if (controls.codexApprovalPolicy === "never" && controls.codexSandbox === "danger-full-access") { - permissionMode = "full-auto"; - } else if (controls.codexApprovalPolicy === "on-failure" && controls.codexSandbox === "workspace-write") { - permissionMode = "edit"; - } else if (controls.codexApprovalPolicy === "untrusted" && controls.codexSandbox === "read-only") { - permissionMode = "plan"; - } - return { - codexApprovalPolicy: controls.codexApprovalPolicy, - codexSandbox: controls.codexSandbox, - codexConfigSource: controls.codexConfigSource, - ...(permissionMode ? { permissionMode } : {}), - }; - } - return { - unifiedPermissionMode: controls.unifiedPermissionMode, - permissionMode: controls.unifiedPermissionMode, - }; -} - -function migrateOldPrefs(): string | null { - try { - const oldProvider = window.localStorage.getItem(LEGACY_PROVIDER_KEY); - const oldModel = oldProvider ? window.localStorage.getItem(`${LEGACY_MODEL_KEY_PREFIX}:${oldProvider}`) : null; - if (oldProvider && oldModel) { - const match = MODEL_REGISTRY.find((m) => m.shortId === oldModel || m.sdkModelId === oldModel); - if (match) { - window.localStorage.setItem(LAST_MODEL_ID_KEY, match.id); - window.localStorage.removeItem(LEGACY_PROVIDER_KEY); - window.localStorage.removeItem(`${LEGACY_MODEL_KEY_PREFIX}:codex`); - window.localStorage.removeItem(`${LEGACY_MODEL_KEY_PREFIX}:claude`); - return match.id; - } - } - } catch { - // ignore - } - return null; -} - -function readLastUsedModelId(): string | null { - try { - const raw = window.localStorage.getItem(LAST_MODEL_ID_KEY); - if (raw && raw.trim().length) return raw.trim(); - } catch { - // ignore - } - return migrateOldPrefs(); -} - -function writeLastUsedModelId(modelId: string) { - try { - window.localStorage.setItem(LAST_MODEL_ID_KEY, modelId); - } catch { - // ignore - } -} - -function readLastUsedReasoningEffort(args: { - laneId: string | null; - modelId: string; -}): string | null { - if (!args.laneId) return null; - try { - const raw = window.localStorage.getItem(`${LAST_REASONING_KEY_PREFIX}:${args.laneId}:${args.modelId}`); - return raw && raw.trim().length ? raw.trim() : null; - } catch { - return null; - } -} - -function writeLastUsedReasoningEffort(args: { - laneId: string | null; - modelId: string; - effort: string | null; -}) { - if (!args.laneId || !args.modelId.trim().length) return; - try { - const key = `${LAST_REASONING_KEY_PREFIX}:${args.laneId}:${args.modelId}`; - if (!args.effort || !args.effort.trim().length) { - window.localStorage.removeItem(key); - return; - } - window.localStorage.setItem(key, args.effort.trim()); - } catch { - // ignore - } -} - -function selectReasoningEffort(args: { - tiers: string[]; - preferred: string | null; -}): string | null { - if (!args.tiers.length) return null; - if (args.preferred && args.tiers.includes(args.preferred)) { - return args.preferred; - } - return args.tiers.includes("medium") ? "medium" : args.tiers[0]!; -} - -function resolveAssistantLabel( - model: ModelDescriptor | null | undefined, - sessionProvider: string | null | undefined, -): string { - if (model?.family === "anthropic" || model?.cliCommand === "claude") return "Claude"; - if (model?.family === "openai" || model?.cliCommand === "codex") return "Codex"; - if (sessionProvider === "claude") return "Claude"; - if (sessionProvider === "codex") return "Codex"; - return "Assistant"; -} - -function byStartedDesc(a: AgentChatSessionSummary, b: AgentChatSessionSummary): number { - return Date.parse(b.startedAt) - Date.parse(a.startedAt); -} - -export function resolveNextSelectedSessionId(args: { - rows: AgentChatSessionSummary[]; - current: string | null; - pendingSelectedSessionId: string | null; - optimisticSessionIds: Set; - draftSelectionLocked: boolean; - forceDraft: boolean; - preferDraftStart: boolean; -}): string | null { - const { - rows, - current, - pendingSelectedSessionId, - optimisticSessionIds, - draftSelectionLocked, - forceDraft, - preferDraftStart, - } = args; - - if (pendingSelectedSessionId) { - const pendingIsPersisted = rows.some((row) => row.sessionId === pendingSelectedSessionId); - if (pendingIsPersisted) return pendingSelectedSessionId; - if (current === pendingSelectedSessionId || optimisticSessionIds.has(pendingSelectedSessionId)) { - return pendingSelectedSessionId; - } - } - - if (!current && (draftSelectionLocked || forceDraft || preferDraftStart)) { - return null; - } - if (current && rows.some((row) => row.sessionId === current)) { - return current; - } - if (current && optimisticSessionIds.has(current)) { - return current; - } - return rows[0]?.sessionId ?? null; -} - -function resolveRegistryModelId(value: string | null | undefined): string | null { - const normalized = (value ?? "").trim().toLowerCase(); - if (!normalized.length) return null; - const match = MODEL_REGISTRY.find( - (model) => - model.id.toLowerCase() === normalized - || model.shortId.toLowerCase() === normalized - || model.sdkModelId.toLowerCase() === normalized - ); - return match?.id ?? null; -} - -function resolveCliRegistryModelId(provider: "codex" | "claude", value: string | null | undefined): string | null { - const normalized = (value ?? "").trim().toLowerCase(); - if (!normalized.length) return null; - const family = provider === "codex" ? "openai" : "anthropic"; - const match = MODEL_REGISTRY.find( - (model) => - model.isCliWrapped - && model.family === family - && ( - model.id.toLowerCase() === normalized - || model.shortId.toLowerCase() === normalized - || model.sdkModelId.toLowerCase() === normalized - ) - ); - return match?.id ?? null; -} - function chatToolTypeForProvider(provider: string | null | undefined): "codex-chat" | "claude-chat" | "ai-chat" { switch (provider) { case "codex": return "codex-chat"; @@ -379,7 +125,7 @@ function isLowSignalChatLabel(raw: string | null | undefined): boolean { .toLowerCase(); if (!collapsed.length) return true; - if (collapsed.includes("ai apicallerror")) return true; + if (/\b(error|exception|apicall|traceback|stack\s*trace)\b/i.test(collapsed)) return true; if (/^(session closed|chat completed)\b/u.test(collapsed)) { return true; @@ -388,7 +134,7 @@ function isLowSignalChatLabel(raw: string | null | undefined): boolean { if (/^(completed?|done|finished|resolved|success)\b/u.test(collapsed)) { const remainder = collapsed.replace(/^(completed?|done|finished|resolved|success)\b/u, "").trim(); const remainderTokens = remainder.length ? remainder.split(/\s+/).filter(Boolean) : []; - const genericRemainder = remainderTokens.every((token) => + const genericRemainder = remainderTokens.every((token: string) => /^(ok|okay|ready|hello|hi|test|yes|no|true|false|response|reply|result|output|pass|passed)$/u.test(token) ); return !remainderTokens.length || remainderTokens.length <= 2 || genericRemainder; @@ -428,6 +174,17 @@ function completionBadgeClass(status: NonNullable defaultNativeControls(surfaceProfile), [surfaceProfile]); - const [sessions, setSessions] = useState([]); - const [selectedSessionId, setSelectedSessionId] = useState(lockSessionId ?? initialSessionId ?? null); - const [eventsBySession, setEventsBySession] = useState>({}); - const [turnActiveBySession, setTurnActiveBySession] = useState>({}); - const [pendingInputsBySession, setPendingInputsBySession] = useState>({}); - const [modelId, setModelId] = useState(""); - const [reasoningEffort, setReasoningEffort] = useState(null); - const [executionMode, setExecutionMode] = useState("focused"); - const [availableModelIds, setAvailableModelIds] = useState([]); - const [claudePermissionMode, setClaudePermissionMode] = useState(initialNativeControls.claudePermissionMode); - const [codexApprovalPolicy, setCodexApprovalPolicy] = useState(initialNativeControls.codexApprovalPolicy); - const [codexSandbox, setCodexSandbox] = useState(initialNativeControls.codexSandbox); - const [codexConfigSource, setCodexConfigSource] = useState(initialNativeControls.codexConfigSource); - const [unifiedPermissionMode, setUnifiedPermissionMode] = useState(initialNativeControls.unifiedPermissionMode); - const [computerUsePolicy, setComputerUsePolicy] = useState(createDefaultComputerUsePolicy()); - const [providerConnections, setProviderConnections] = useState<{ - claude: AiProviderConnectionStatus | null; - codex: AiProviderConnectionStatus | null; - } | null>(null); - const [attachments, setAttachments] = useState([]); - const [includeProjectDocs, setIncludeProjectDocs] = useState(false); - const [sdkSlashCommands, setSdkSlashCommands] = useState([]); - const [sendOnEnter, setSendOnEnter] = useState(true); - const [draft, setDraft] = useState(""); + const surfaceMode = presentation?.mode ?? "standard"; + + // ── Events hook ─────────────────────────────────────────────────── + const eventsHook = useAgentChatEvents({ selectedSessionId: null }); + + // ── Sessions hook ───────────────────────────────────────────────── + const sessionsHook = useAgentChatSessions({ + laneId, + lockSessionId, + initialSessionId, + initialSessionSummary, + forceNewSession, + forceDraftMode, + lockedSingleSessionMode, + eventsBySessionRef: eventsHook.eventsBySessionRef, + setEventsBySession: eventsHook.setEventsBySession, + setTurnActiveBySession: eventsHook.setTurnActiveBySession, + setPendingInputsBySession: eventsHook.setPendingInputsBySession, + }); + + const { + sessions, + setSessions, + selectedSessionId, + setSelectedSessionId, + selectedSession, + selectedSessionModelId, + refreshSessions, + loadHistory, + optimisticSessionIdsRef, + pendingSelectedSessionIdRef, + draftSelectionLockedRef, + knownSessionIdsRef, + loadedHistoryRef, + scheduleSessionsRefresh, + } = sessionsHook; + + // ── Derive events for real selectedSessionId ────────────────────── + const selectedEvents = selectedSessionId ? eventsHook.eventsBySession[selectedSessionId] ?? [] : []; + const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); + const turnActive = selectedSessionId ? (eventsHook.turnActiveBySession[selectedSessionId] ?? false) : false; + const pendingInput = selectedSessionId ? (eventsHook.pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; + + const { + flushQueuedEvents, + scheduleQueuedEventFlush, + eventsBySessionRef, + pendingEventQueueRef, + eventFlushTimerRef, + setEventsBySession, + setPendingInputsBySession, + } = eventsHook; + + // ── Composer state hook ─────────────────────────────────────────── + const composerHook = useAgentChatComposerState({ + surfaceProfile, + selectedSession, + selectedSessionId, + selectedSessionModelId, + selectedEvents, + laneId, + availableModelIdsOverride, + }); + + const { + modelId, setModelId, + reasoningEffort, setReasoningEffort, + executionMode, setExecutionMode, + claudePermissionMode, setClaudePermissionMode, + codexApprovalPolicy, setCodexApprovalPolicy, + codexSandbox, setCodexSandbox, + codexConfigSource, setCodexConfigSource, + unifiedPermissionMode, setUnifiedPermissionMode, + computerUsePolicy, setComputerUsePolicy, + attachments, setAttachments, + draft, setDraft, clearDraft, + includeProjectDocs, setIncludeProjectDocs, + sendOnEnter, setSendOnEnter, + sdkSlashCommands, setSdkSlashCommands, + promptSuggestion, setPromptSuggestion, + availableModelIds, + providerConnections, + preferencesReady, setPreferencesReady, + currentNativeControls, + syncComposerToSession, + refreshAvailableModels, + refreshProviderConnections, + buildNativeControlPayload, + } = composerHook; + + // ── Remaining local state ───────────────────────────────────────── const [busy, setBusy] = useState(false); const [loading, setLoading] = useState(false); - const [preferencesReady, setPreferencesReady] = useState(false); const [error, setError] = useState(null); const [computerUseSnapshot, setComputerUseSnapshot] = useState(null); const [proofDrawerOpen, setProofDrawerOpen] = useState(false); const [sessionDelta, setSessionDelta] = useState<{ insertions: number; deletions: number } | null>(null); const [sessionMutationKind, setSessionMutationKind] = useState<"model" | "permission" | "computer-use" | null>(null); - const [promptSuggestion, setPromptSuggestion] = useState(null); const [handoffOpen, setHandoffOpen] = useState(false); const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); - const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); - const loadedHistoryRef = useRef>(new Set()); - const draftSelectionLockedRef = useRef(false); - const optimisticSessionIdsRef = useRef>(new Set()); - const pendingSelectedSessionIdRef = useRef(null); const submitInFlightRef = useRef(false); const createSessionPromiseRef = useRef | null>(null); - const pendingEventQueueRef = useRef([]); - const eventsBySessionRef = useRef>({}); - const eventFlushTimerRef = useRef(null); - const refreshSessionsTimerRef = useRef(null); const selectedSessionIdRef = useRef(selectedSessionId); const computerUseSnapshotInFlightRef = useRef<{ sessionId: string; promise: Promise } | null>(null); const lastComputerUseSnapshotRef = useRef<{ sessionId: string; fetchedAt: number } | null>(null); - const knownSessionIdsRef = useRef>(new Set()); const handoffRef = useRef(null); - const selectedSession = useMemo( - () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), - [sessions, selectedSessionId] - ); + + // ── Derived values ──────────────────────────────────────────────── const laneDisplayLabel = useMemo(() => { const normalized = laneLabel?.trim(); return normalized?.length ? normalized : laneId; }, [laneId, laneLabel]); - const selectedSessionModelId = useMemo(() => { - if (!selectedSession) return null; - return selectedSession.modelId ?? resolveRegistryModelId(selectedSession.model); - }, [selectedSession]); - const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? [] : []; - const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); - const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; - const activeProviderConnection = selectedSession?.provider === "claude" - ? (providerConnections?.claude ?? null) - : selectedSession?.provider === "codex" - ? (providerConnections?.codex ?? null) - : null; - const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; + const activeProviderConnection = (() => { + if (selectedSession?.provider === "claude") return providerConnections?.claude ?? null; + if (selectedSession?.provider === "codex") return providerConnections?.codex ?? null; + return null; + })(); + const selectedModelDesc = getModelById(modelId); const reasoningTiers = selectedModelDesc?.reasoningTiers ?? []; - const surfaceMode = presentation?.mode ?? "standard"; const identitySessionSettingsBusy = isPersistentIdentitySurface && sessionMutationKind !== null; - const modelSelectionDiffersFromSession = Boolean(selectedSession && selectedSessionModelId && selectedSessionModelId !== modelId); const sessionProvider = useMemo(() => { @@ -555,32 +354,7 @@ export function AgentChatPane({ return "unified"; }, [selectedSession, modelSelectionDiffersFromSession, modelId]); - const syncComposerToSession = useCallback((session: AgentChatSessionSummary | null) => { - if (!session) { - setClaudePermissionMode(initialNativeControls.claudePermissionMode); - setCodexApprovalPolicy(initialNativeControls.codexApprovalPolicy); - setCodexSandbox(initialNativeControls.codexSandbox); - setCodexConfigSource(initialNativeControls.codexConfigSource); - setUnifiedPermissionMode(initialNativeControls.unifiedPermissionMode); - return; - } - const nextModelId = session.modelId ?? resolveRegistryModelId(session.model); - if (nextModelId) { - setModelId(nextModelId); - } - setReasoningEffort(session.reasoningEffort ?? null); - setExecutionMode(session.executionMode ?? "focused"); - setClaudePermissionMode(session.claudePermissionMode ?? initialNativeControls.claudePermissionMode); - setCodexApprovalPolicy(session.codexApprovalPolicy ?? initialNativeControls.codexApprovalPolicy); - setCodexSandbox(session.codexSandbox ?? initialNativeControls.codexSandbox); - setCodexConfigSource(session.codexConfigSource ?? initialNativeControls.codexConfigSource); - setUnifiedPermissionMode(session.unifiedPermissionMode ?? initialNativeControls.unifiedPermissionMode); - setComputerUsePolicy(session.computerUse ?? createDefaultComputerUsePolicy()); - }, [initialNativeControls]); - const executionModeOptions = useMemo( - () => getExecutionModeOptions(selectedModelDesc), - [selectedModelDesc], - ); + const executionModeOptions = useMemo(() => getExecutionModeOptions(selectedModelDesc), [selectedModelDesc]); const selectedExecutionMode = useMemo( () => executionModeOptions.find((option) => option.value === executionMode) ?? executionModeOptions[0] ?? null, [executionMode, executionModeOptions], @@ -599,9 +373,6 @@ export function AgentChatPane({ const chipsJson = JSON.stringify(presentation?.chips ?? []); const resolvedChips = useMemo(() => JSON.parse(chipsJson) as ChatSurfaceChip[], [chipsJson]); - // Keep all configured models selectable, and always include the active session model. - // Most launched chats stay in the same family; special surfaces such as CTO - // can opt into cross-family switching after the conversation has started. const effectiveAvailableModelIds = useMemo(() => { return filterChatModelIdsForSession({ availableModelIds: availableModelIdsOverride?.length ? availableModelIdsOverride : availableModelIds, @@ -612,127 +383,20 @@ export function AgentChatPane({ }, [availableModelIds, availableModelIdsOverride, modelSwitchPolicy, selectedSessionModelId, selectedEvents.length]); const handoffAvailableModelIds = useMemo(() => { const merged = new Set(availableModelIdsOverride?.length ? availableModelIdsOverride : availableModelIds); - if (selectedSessionModelId) { - merged.add(selectedSessionModelId); - } - return MODEL_REGISTRY - .filter((model) => !model.deprecated && merged.has(model.id)) - .map((model) => model.id); + if (selectedSessionModelId) merged.add(selectedSessionModelId); + return MODEL_REGISTRY.filter((model) => !model.deprecated && merged.has(model.id)).map((model) => model.id); }, [availableModelIds, availableModelIdsOverride, selectedSessionModelId]); const canShowHandoff = Boolean( - lockSessionId - && selectedSessionId - && selectedSession - && handoffAvailableModelIds.length > 0 - && surfaceMode === "standard" - && !isPersistentIdentitySurface - && (selectedSession.surface ?? "work") === "work", + lockSessionId && selectedSessionId && selectedSession + && handoffAvailableModelIds.length > 0 && surfaceMode === "standard" + && !isPersistentIdentitySurface && (selectedSession.surface ?? "work") === "work", ); const handoffBlocked = turnActive || Boolean(pendingInput) || handoffBusy; const handoffButtonTitle = handoffBlocked ? "Wait for the current output or approval to finish before handing off this chat." : "Create a new work chat on another model and seed it with a summary of this chat."; - const refreshAvailableModels = useCallback(async () => { - try { - const status = await window.ade.ai.getStatus(); - const available = deriveConfiguredModelIds(status); - setAvailableModelIds(available); - return available; - } catch { - // Fall back to direct model discovery probes below. - } - - try { - const [codexModels, claudeModels, unifiedModels] = await Promise.all([ - window.ade.agentChat.models({ provider: "codex" }).catch(() => []), - window.ade.agentChat.models({ provider: "claude" }).catch(() => []), - window.ade.agentChat.models({ provider: "unified" }).catch(() => []), - ]); - const available = new Set(); - - for (const model of codexModels) { - const resolved = resolveCliRegistryModelId("codex", model.id); - if (resolved) available.add(resolved); - } - for (const model of claudeModels) { - const resolved = resolveCliRegistryModelId("claude", model.id); - if (resolved) available.add(resolved); - } - for (const model of unifiedModels) { - const resolved = resolveRegistryModelId(model.id); - if (resolved) available.add(resolved); - } - - const ordered = MODEL_REGISTRY.filter((model) => !model.deprecated && available.has(model.id)).map((model) => model.id); - setAvailableModelIds(ordered); - return ordered; - } catch { - setAvailableModelIds([]); - return []; - } - }, []); - - const refreshProviderConnections = useCallback(async () => { - try { - const status = await window.ade.ai.getStatus(); - setProviderConnections({ - claude: status.providerConnections?.claude ?? null, - codex: status.providerConnections?.codex ?? null, - }); - } catch { - setProviderConnections(null); - } - }, []); - - const refreshSessions = useCallback(async () => { - if (!laneId) { - setSessions([]); - return; - } - - const rows = await window.ade.agentChat.list({ laneId }); - rows.sort(byStartedDesc); - setSessions(rows); - for (const row of rows) { - optimisticSessionIdsRef.current.delete(row.sessionId); - } - - if (lockSessionId) { - draftSelectionLockedRef.current = false; - setSelectedSessionId(lockSessionId); - return; - } - - setSelectedSessionId((current) => { - const pendingSelectedSessionId = pendingSelectedSessionIdRef.current; - const nextSelectedSessionId = resolveNextSelectedSessionId({ - rows, - current, - pendingSelectedSessionId, - optimisticSessionIds: optimisticSessionIdsRef.current, - draftSelectionLocked: draftSelectionLockedRef.current, - forceDraft, - preferDraftStart, - }); - if (pendingSelectedSessionId && rows.some((row) => row.sessionId === pendingSelectedSessionId)) { - pendingSelectedSessionIdRef.current = null; - } - return nextSelectedSessionId; - }); - }, [forceDraft, laneId, lockSessionId, preferDraftStart]); - - useEffect(() => { - void refreshProviderConnections(); - }, [refreshProviderConnections, selectedSession?.provider]); - - useEffect(() => { - if (!turnActive || !selectedSession?.provider) return; - const timer = window.setInterval(() => { - void refreshProviderConnections(); - }, 5000); - return () => window.clearInterval(timer); - }, [refreshProviderConnections, selectedSession?.provider, turnActive]); + // ── Callbacks ───────────────────────────────────────────────────── const refreshComputerUseSnapshot = useCallback(async ( sessionId: string | null, @@ -746,431 +410,320 @@ export function AgentChatPane({ } if (!options?.force) { const inFlight = computerUseSnapshotInFlightRef.current; - if (inFlight?.sessionId === sessionId) { - return inFlight.promise; - } + if (inFlight?.sessionId === sessionId) return inFlight.promise; const previous = lastComputerUseSnapshotRef.current; - if (previous?.sessionId === sessionId && Date.now() - previous.fetchedAt < COMPUTER_USE_SNAPSHOT_COOLDOWN_MS) { - return; - } + if (previous?.sessionId === sessionId && Date.now() - previous.fetchedAt < COMPUTER_USE_SNAPSHOT_COOLDOWN_MS) return; } - let request: Promise | null = null; request = (async () => { try { - const snapshot = await window.ade.computerUse.getOwnerSnapshot({ - owner: { kind: "chat_session", id: sessionId }, - }); - lastComputerUseSnapshotRef.current = { - sessionId, - fetchedAt: Date.now(), - }; - if (selectedSessionIdRef.current === sessionId) { - setComputerUseSnapshot(snapshot); - } + const snapshot = await window.ade.computerUse.getOwnerSnapshot({ owner: { kind: "chat_session", id: sessionId } }); + lastComputerUseSnapshotRef.current = { sessionId, fetchedAt: Date.now() }; + if (selectedSessionIdRef.current === sessionId) setComputerUseSnapshot(snapshot); } catch { - if (selectedSessionIdRef.current === sessionId) { - setComputerUseSnapshot(null); - } + if (selectedSessionIdRef.current === sessionId) setComputerUseSnapshot(null); } finally { - if (request && computerUseSnapshotInFlightRef.current?.promise === request) { - computerUseSnapshotInFlightRef.current = null; - } + if (request && computerUseSnapshotInFlightRef.current?.promise === request) computerUseSnapshotInFlightRef.current = null; } })(); computerUseSnapshotInFlightRef.current = { sessionId, promise: request }; - try { - await request; - } catch { - // Errors are reflected by clearing the visible snapshot for the active session. - } + try { await request; } catch { /* Errors reflected by clearing visible snapshot */ } }, []); - const loadHistory = useCallback(async (sessionId: string) => { - if (loadedHistoryRef.current.has(sessionId)) return; - loadedHistoryRef.current.add(sessionId); + const patchSessionSummary = useCallback((sessionId: string, patch: Partial) => { + setSessions((prev) => prev.map((session) => (session.sessionId === sessionId ? { ...session, ...patch } : session))); + }, [setSessions]); - try { - const summary = await window.ade.sessions.get(sessionId); - if (!summary || !isChatToolType(summary.toolType)) return; - const raw = await window.ade.sessions.readTranscriptTail({ - sessionId, - maxBytes: 1_800_000, - raw: true + const createSession = useCallback(async (): Promise => { + if (createSessionPromiseRef.current) return createSessionPromiseRef.current; + if (!laneId) return null; + const createPromise = (async () => { + const desc = getModelById(modelId); + const provider = desc?.isCliWrapped ? (desc.family === "openai" ? "codex" : "claude") : "unified"; + const model = desc ? getRuntimeModelRefForDescriptor(desc, provider) : modelId; + const sessionProfile: AgentChatSessionProfile = "workflow"; + const created = await window.ade.agentChat.create({ + laneId, provider, model, modelId, sessionProfile, reasoningEffort, + ...buildNativeControlPayload(provider), + computerUse: computerUsePolicy, }); - const parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); - - // If real-time events have already been received for this session - // (via flushQueuedEvents), the on-disk transcript may be stale. - // Merge: use the loaded history as a base but keep any real-time - // events that arrived after the last event in the transcript. - const existing = eventsBySessionRef.current[sessionId] ?? []; - let merged: AgentChatEventEnvelope[]; - if (existing.length && parsed.length) { - // Find real-time events that are newer than the last transcript entry. - const lastParsedTs = parsed[parsed.length - 1]!.timestamp; - const tail = existing.filter((e) => e.timestamp > lastParsedTs); - merged = tail.length ? [...parsed, ...tail] : parsed; - } else if (existing.length) { - // No transcript on disk — keep the real-time events as-is. - merged = existing; - } else { - merged = parsed; - } - - const derived = deriveRuntimeState(merged); - eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; - setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); - setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: derived.turnActive })); - setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); - } catch { - // Ignore transcript history failures. - } - }, []); - - useEffect(() => { - if (lockSessionId) { - pendingSelectedSessionIdRef.current = null; + loadedHistoryRef.current.delete(created.id); + optimisticSessionIdsRef.current.add(created.id); + pendingSelectedSessionIdRef.current = created.id; draftSelectionLockedRef.current = false; - setSelectedSessionId(lockSessionId); + setSelectedSessionId(created.id); + await onSessionCreated?.(created.id); + void refreshSessions().catch(() => {}); + return created.id; + })(); + createSessionPromiseRef.current = createPromise; + try { return await createPromise; } finally { + if (createSessionPromiseRef.current === createPromise) createSessionPromiseRef.current = null; } - }, [lockSessionId]); + }, [buildNativeControlPayload, computerUsePolicy, draftSelectionLockedRef, laneId, loadedHistoryRef, modelId, onSessionCreated, optimisticSessionIdsRef, pendingSelectedSessionIdRef, reasoningEffort, refreshSessions, setSelectedSessionId]); - useEffect(() => { - if (!lockedSingleSessionMode || !lockSessionId || !initialSessionSummary) return; - setSessions([initialSessionSummary]); - draftSelectionLockedRef.current = false; - setSelectedSessionId(lockSessionId); - }, [initialSessionSummary, lockSessionId, lockedSingleSessionMode]); + const handoffSession = useCallback(async () => { + if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; + setError(null); + setHandoffBusy(true); + try { + const result = await window.ade.agentChat.handoff({ sourceSessionId: selectedSessionId, targetModelId: handoffModelId }); + setHandoffOpen(false); + await onSessionCreated?.(result.session.id); + void refreshSessions().catch(() => {}); + } catch (handoffError) { + setError(handoffError instanceof Error ? handoffError.message : String(handoffError)); + } finally { setHandoffBusy(false); } + }, [canShowHandoff, handoffBlocked, handoffModelId, onSessionCreated, refreshSessions, selectedSessionId]); - useEffect(() => { - const nextInitialSessionId = initialSessionId ?? null; - if (!nextInitialSessionId) { - appliedInitialSessionIdRef.current = null; - return; + const searchAttachments = useCallback(async (query: string): Promise => { + if (!laneId) return []; + const trimmed = query.trim(); + if (!trimmed.length) return []; + if (selectedSessionId && sessionProvider === "codex") { + try { + const codexHits = await window.ade.agentChat.fileSearch({ sessionId: selectedSessionId, query: trimmed }); + if (codexHits.length > 0) return codexHits.map((hit) => ({ path: hit.path, type: inferAttachmentType(hit.path) })); + } catch { /* Fall through */ } } - if (lockSessionId) return; - if (appliedInitialSessionIdRef.current === nextInitialSessionId) return; - appliedInitialSessionIdRef.current = nextInitialSessionId; - pendingSelectedSessionIdRef.current = null; - draftSelectionLockedRef.current = false; - setSelectedSessionId(nextInitialSessionId); - }, [initialSessionId, lockSessionId]); + const hits = await window.ade.files.quickOpen({ workspaceId: laneId, query: trimmed, limit: 60 }); + return hits.map((hit) => ({ path: hit.path, type: inferAttachmentType(hit.path) })); + }, [laneId, selectedSessionId, sessionProvider]); - useEffect(() => { - draftSelectionLockedRef.current = false; - optimisticSessionIdsRef.current.clear(); - pendingSelectedSessionIdRef.current = null; - appliedInitialSessionIdRef.current = initialSessionId ?? null; - if (forceDraft && !lockSessionId) { - draftSelectionLockedRef.current = true; - setSelectedSessionId(null); - } - }, [forceDraft, laneId, lockSessionId]); + const addAttachment = useCallback((attachment: AgentChatFileRef) => { + setAttachments((prev) => { if (prev.some((e) => e.path === attachment.path)) return prev; return [...prev, attachment]; }); + }, [setAttachments]); - useEffect(() => { - if (!forceDraft || lockSessionId) return; - pendingSelectedSessionIdRef.current = null; - draftSelectionLockedRef.current = true; - setSelectedSessionId(null); - }, [forceDraft, lockSessionId]); + const removeAttachment = useCallback((attachmentPath: string) => { + setAttachments((prev) => prev.filter((e) => e.path !== attachmentPath)); + }, [setAttachments]); - useEffect(() => { - syncComposerToSession(selectedSession); - }, [selectedSession?.sessionId, selectedSessionModelId, syncComposerToSession]); + const updateNativeControls = useCallback(async (patch: Partial) => { + if (isPersistentIdentitySurface && sessionMutationKind) return; + const nextControls: NativeControlState = { ...currentNativeControls, ...patch }; + setClaudePermissionMode(nextControls.claudePermissionMode); + setCodexApprovalPolicy(nextControls.codexApprovalPolicy); + setCodexSandbox(nextControls.codexSandbox); + setCodexConfigSource(nextControls.codexConfigSource); + setUnifiedPermissionMode(nextControls.unifiedPermissionMode); + if (!selectedSessionId) return; + const provider = selectedSession?.provider ?? sessionProvider; + const nextSummary = summarizeNativeControls(provider, nextControls); + patchSessionSummary(selectedSessionId, nextSummary); + if (isPersistentIdentitySurface) setSessionMutationKind("permission"); + try { + await window.ade.agentChat.updateSession({ sessionId: selectedSessionId, ...nextSummary }); + void refreshSessions().catch(() => {}); + } catch (err) { + void refreshSessions().catch(() => {}); + setError(err instanceof Error ? err.message : String(err)); + } finally { if (isPersistentIdentitySurface) setSessionMutationKind(null); } + }, [currentNativeControls, isPersistentIdentitySurface, patchSessionSummary, refreshSessions, selectedSession, selectedSessionId, sessionMutationKind, sessionProvider, setClaudePermissionMode, setCodexApprovalPolicy, setCodexSandbox, setCodexConfigSource, setUnifiedPermissionMode]); - useEffect(() => { - let cancelled = false; + const handleComputerUsePolicyChange = useCallback(async (nextPolicy: ComputerUsePolicy) => { + if (isPersistentIdentitySurface && sessionMutationKind) return; + setComputerUsePolicy(nextPolicy); + if (!selectedSessionId) return; + patchSessionSummary(selectedSessionId, { computerUse: nextPolicy }); + if (isPersistentIdentitySurface) setSessionMutationKind("computer-use"); + try { + await window.ade.agentChat.updateSession({ sessionId: selectedSessionId, computerUse: nextPolicy }); + await refreshSessions(); + await refreshComputerUseSnapshot(selectedSessionId, { force: true }); + } catch (err) { setError(err instanceof Error ? err.message : String(err)); } + finally { if (isPersistentIdentitySurface) setSessionMutationKind(null); } + }, [isPersistentIdentitySurface, patchSessionSummary, refreshComputerUseSnapshot, refreshSessions, selectedSessionId, sessionMutationKind, setComputerUsePolicy]); - const boot = async () => { - setLoading(true); - setPreferencesReady(false); - try { - const snapshot = await window.ade.projectConfig.get(); - const chat = snapshot.effective.ai?.chat; - if (!cancelled) { - // Don't auto-restore model — user must pick one explicitly each session - setSendOnEnter(chat?.sendOnEnter ?? true); - } - } catch { - // fall back to defaults. - } + const submit = useCallback(async () => { + if (submitInFlightRef.current || busy) return; + if (!modelId) return; + const text = draft.trim(); + if (!text.length || !laneId) return; + const draftSnapshot = draft; + const attachmentsSnapshot = attachments; + const isLiteralSlashCommand = text.startsWith("/"); + submitInFlightRef.current = true; + setBusy(true); + setError(null); + clearDraft(); + setAttachments([]); + try { + let finalText = text; + if (!isLiteralSlashCommand && includeProjectDocs) { + const docPaths = [".ade/context/PRD.ade.md", ".ade/context/ARCHITECTURE.ade.md"]; + const docNote = ["[Project Context — generated from main branch, may not reflect in-progress lane work]", "The following project-level docs are available for reference. Read them with read_file if you need project context:", ...docPaths.map((p) => `- ${p}`)].join("\n"); + finalText = `${docNote}\n\n---\n\n${finalText}`; + setIncludeProjectDocs(false); + } + let sessionId = selectedSessionId; + const shouldPromoteLightSession = shouldPromoteSessionForComputerUse(selectedSession, computerUsePolicy); + const selectedModelChanged = Boolean(selectedSessionId) && Boolean(selectedSessionModelId) && selectedSessionModelId !== modelId; + if (sessionId && !turnActive && (selectedModelChanged || hasComputerUseSelectionChanged || shouldPromoteLightSession)) { + const desc = getModelById(modelId); + const provider = desc?.isCliWrapped ? (desc.family === "openai" ? "codex" : "claude") : "unified"; + await window.ade.agentChat.updateSession({ sessionId, modelId, reasoningEffort, ...buildNativeControlPayload(provider), computerUse: computerUsePolicy }); + await refreshSessions(); + } else if (!sessionId) { + sessionId = await createSession(); + } + if (!sessionId) throw new Error("Unable to create chat session."); + const selectedAttachments = isLiteralSlashCommand ? [] : attachmentsSnapshot; + if (eventsHook.turnActiveBySession[sessionId]) { + const steerText = selectedAttachments.length ? `${finalText}\n\nAttached context:\n${selectedAttachments.map((e) => `- ${e.type}: ${e.path}`).join("\n")}` : finalText; + await window.ade.agentChat.steer({ sessionId, text: steerText }); + } else { + await window.ade.agentChat.send({ sessionId, text: finalText, displayText: text, attachments: selectedAttachments, reasoningEffort, executionMode: launchModeEditable ? executionMode : null }); + } + await refreshSessions().catch(() => {}); + } catch (submitError) { + const message = submitError instanceof Error ? submitError.message : String(submitError); + setDraft(draftSnapshot); + setAttachments((current) => (current.length ? current : attachmentsSnapshot)); + setError(message); + if (/ade chat could not authenticate/i.test(message) || /not authenticated/i.test(message) || /login required/i.test(message)) { + void refreshAvailableModels().catch(() => {}); + } + } finally { submitInFlightRef.current = false; setBusy(false); } + }, [attachments, buildNativeControlPayload, busy, createSession, computerUsePolicy, draft, executionMode, hasComputerUseSelectionChanged, includeProjectDocs, laneId, launchModeEditable, modelId, reasoningEffort, refreshSessions, selectedEvents.length, selectedSessionId, selectedSessionModelId, turnActive, eventsHook.turnActiveBySession, refreshAvailableModels, selectedSession, setAttachments, setDraft, setIncludeProjectDocs]); + const interrupt = useCallback(async () => { + if (!selectedSessionId) return; + try { await window.ade.agentChat.interrupt({ sessionId: selectedSessionId }); } + catch (interruptError) { setError(interruptError instanceof Error ? interruptError.message : String(interruptError)); } + }, [selectedSessionId]); + + const approve = useCallback(async (decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => { + if (!selectedSessionId) return; + const request = eventsHook.pendingInputsBySession[selectedSessionId]?.[0]; + if (!request) return; + try { + await window.ade.agentChat.respondToInput({ sessionId: selectedSessionId, itemId: request.itemId, decision, responseText, ...(answers ? { answers } : {}) }); + setPendingInputsBySession((prev) => ({ ...prev, [selectedSessionId]: (prev[selectedSessionId] ?? []).filter((e) => e.itemId !== request.itemId) })); + } catch (approvalError) { setError(approvalError instanceof Error ? approvalError.message : String(approvalError)); } + }, [eventsHook.pendingInputsBySession, selectedSessionId, setPendingInputsBySession]); + + // ── Effects ─────────────────────────────────────────────────────── + + useEffect(() => { selectedSessionIdRef.current = selectedSessionId; }, [selectedSessionId]); + + useEffect(() => { syncComposerToSession(selectedSession); }, [selectedSession?.sessionId, selectedSessionModelId, syncComposerToSession]); + + useEffect(() => { + if (!turnActive || !selectedSession?.provider) return; + const timer = window.setInterval(() => { void refreshProviderConnections(); }, 5000); + return () => window.clearInterval(timer); + }, [refreshProviderConnections, selectedSession?.provider, turnActive]); + + useEffect(() => { + let cancelled = false; + const boot = async () => { + setLoading(true); setPreferencesReady(false); + try { + const snapshot = await window.ade.projectConfig.get(); + const chat = snapshot.effective.ai?.chat; + if (!cancelled) setSendOnEnter(chat?.sendOnEnter ?? true); + } catch { /* defaults */ } try { if (lockedSingleSessionMode) { - if (!cancelled && initialSessionSummary) { - setSessions([initialSessionSummary]); - setSelectedSessionId(lockSessionId ?? initialSessionSummary.sessionId); - } + if (!cancelled && initialSessionSummary) { setSessions([initialSessionSummary]); setSelectedSessionId(lockSessionId ?? initialSessionSummary.sessionId); } await refreshAvailableModels(); - } else { - await Promise.all([refreshAvailableModels(), refreshSessions()]); - } - } finally { - if (!cancelled) { - setLoading(false); - setPreferencesReady(true); - } - } + } else { await Promise.all([refreshAvailableModels(), refreshSessions()]); } + } finally { if (!cancelled) { setLoading(false); setPreferencesReady(true); } } }; - void boot(); - return () => { - cancelled = true; - }; - }, [initialSessionSummary, lockSessionId, lockedSingleSessionMode, refreshAvailableModels, refreshSessions]); + return () => { cancelled = true; }; + }, [initialSessionSummary, lockSessionId, lockedSingleSessionMode, refreshAvailableModels, refreshSessions, setSendOnEnter, setSessions, setSelectedSessionId, setPreferencesReady]); useEffect(() => { if (loading || !availableModelIds.length) return; - // If the user hasn't picked a model yet, don't auto-select one. if (!modelId) return; if (availableModelIds.includes(modelId)) return; - if (selectedSessionModelId) { - setModelId(selectedSessionModelId); - return; - } + if (selectedSessionModelId) { setModelId(selectedSessionModelId); return; } const preferred = readLastUsedModelId(); - if (preferred && availableModelIds.includes(preferred)) { - setModelId(preferred); - } else { - setModelId(availableModelIds[0]!); - } - }, [loading, availableModelIds, modelId, selectedSessionModelId]); + if (preferred && availableModelIds.includes(preferred)) { setModelId(preferred); } else { setModelId(availableModelIds[0]!); } + }, [loading, availableModelIds, modelId, selectedSessionModelId, setModelId]); useEffect(() => { - if (!reasoningTiers.length) { - if (reasoningEffort !== null) setReasoningEffort(null); - return; - } + if (!reasoningTiers.length) { if (reasoningEffort !== null) setReasoningEffort(null); return; } if (reasoningEffort && reasoningTiers.includes(reasoningEffort)) return; const preferred = readLastUsedReasoningEffort({ laneId, modelId }); setReasoningEffort(selectReasoningEffort({ tiers: reasoningTiers, preferred })); - }, [laneId, modelId, reasoningEffort, reasoningTiers]); + }, [laneId, modelId, reasoningEffort, reasoningTiers, setReasoningEffort]); useEffect(() => { - if (!executionModeOptions.length) { - if (executionMode !== "focused") setExecutionMode("focused"); - return; - } - if (executionModeOptions.some((option) => option.value === executionMode)) return; + if (!executionModeOptions.length) { if (executionMode !== "focused") setExecutionMode("focused"); return; } + if (executionModeOptions.some((o) => o.value === executionMode)) return; setExecutionMode(executionModeOptions[0]!.value); - }, [executionMode, executionModeOptions]); - - useEffect(() => { - selectedSessionIdRef.current = selectedSessionId; - }, [selectedSessionId]); - - useEffect(() => { - const next = new Set(); - for (const session of sessions) next.add(session.sessionId); - if (selectedSessionId) next.add(selectedSessionId); - if (lockSessionId) next.add(lockSessionId); - if (initialSessionId) next.add(initialSessionId); - for (const sessionId of optimisticSessionIdsRef.current) next.add(sessionId); - knownSessionIdsRef.current = next; - }, [initialSessionId, lockSessionId, selectedSessionId, sessions]); + }, [executionMode, executionModeOptions, setExecutionMode]); useClickOutside(handoffRef, () => setHandoffOpen(false), handoffOpen); useEffect(() => { if (!handoffOpen) return; const preferredTargetId = handoffAvailableModelIds.find((id) => id !== selectedSessionModelId) ?? handoffAvailableModelIds[0] ?? ""; - setHandoffModelId((current) => { - if (current && handoffAvailableModelIds.includes(current)) { - return current; - } - return preferredTargetId; - }); + setHandoffModelId((current) => (current && handoffAvailableModelIds.includes(current)) ? current : preferredTargetId); }, [handoffAvailableModelIds, handoffOpen, selectedSessionModelId]); useEffect(() => { if (!selectedSessionId) return; - if (!lockedSingleSessionMode) { - void loadHistory(selectedSessionId); - return; - } - const handle = window.setTimeout(() => { - void loadHistory(selectedSessionId); - }, 120); + if (!lockedSingleSessionMode) { void loadHistory(selectedSessionId); return; } + const handle = window.setTimeout(() => { void loadHistory(selectedSessionId); }, 120); return () => window.clearTimeout(handle); }, [loadHistory, lockedSingleSessionMode, selectedSessionId]); useEffect(() => { - if (!lockedSingleSessionMode) { - void refreshComputerUseSnapshot(selectedSessionId); - return; - } - const handle = window.setTimeout(() => { - void refreshComputerUseSnapshot(selectedSessionId); - }, 180); + if (!lockedSingleSessionMode) { void refreshComputerUseSnapshot(selectedSessionId); return; } + const handle = window.setTimeout(() => { void refreshComputerUseSnapshot(selectedSessionId); }, 180); return () => window.clearTimeout(handle); }, [lockedSingleSessionMode, refreshComputerUseSnapshot, selectedSessionId]); - useEffect(() => { - setAttachments([]); - setPromptSuggestion(null); - setHandoffOpen(false); - setHandoffBusy(false); - }, [selectedSessionId]); + useEffect(() => { setAttachments([]); setPromptSuggestion(null); setHandoffOpen(false); setHandoffBusy(false); }, [selectedSessionId, setAttachments, setPromptSuggestion]); - // Fetch SDK slash commands when session changes useEffect(() => { if (!selectedSessionId) { setSdkSlashCommands([]); return; } let cancelled = false; - window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) - .then((cmds) => { if (!cancelled) setSdkSlashCommands(cmds); }) - .catch(() => { if (!cancelled) setSdkSlashCommands([]); }); + window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }).then((cmds) => { if (!cancelled) setSdkSlashCommands(cmds); }).catch(() => { if (!cancelled) setSdkSlashCommands([]); }); return () => { cancelled = true; }; - }, [selectedSessionId]); + }, [selectedSessionId, setSdkSlashCommands]); - // Fetch git diff stats when the session changes or a turn completes useEffect(() => { if (!selectedSessionId) { setSessionDelta(null); return; } let cancelled = false; - const fetchDelta = () => { - window.ade.sessions.getDelta(selectedSessionId) - .then((delta) => { - if (cancelled) return; - if (delta && (delta.insertions > 0 || delta.deletions > 0)) { - setSessionDelta({ insertions: delta.insertions, deletions: delta.deletions }); - } else { - setSessionDelta(null); - } - }) - .catch(() => { if (!cancelled) setSessionDelta(null); }); - }; - fetchDelta(); + window.ade.sessions.getDelta(selectedSessionId).then((delta) => { + if (cancelled) return; + if (delta && (delta.insertions > 0 || delta.deletions > 0)) { setSessionDelta({ insertions: delta.insertions, deletions: delta.deletions }); } else { setSessionDelta(null); } + }).catch(() => { if (!cancelled) setSessionDelta(null); }); return () => { cancelled = true; }; }, [selectedSessionId, turnActive]); - const flushQueuedEvents = useCallback(() => { - const queued = pendingEventQueueRef.current; - if (!queued.length) return; - pendingEventQueueRef.current = []; - - // Build the next events map from the ref (latest committed state) so - // that derived state (turnActive, approvals) can be computed and applied - // as sibling setState calls in the same synchronous scope. React 18 - // batches all three updates into a single render, ensuring turnActive - // never lags behind the events — which previously left the spinner stuck - // after a "done" event. - let next = eventsBySessionRef.current; - const touchedSessionIds = new Set(); - - for (const envelope of queued) { - const sessionId = envelope.sessionId; - const sessionEvents = next === eventsBySessionRef.current - ? (eventsBySessionRef.current[sessionId] ?? []) - : (next[sessionId] ?? []); - const updated = [...sessionEvents, envelope]; - if (next === eventsBySessionRef.current) { - next = { ...eventsBySessionRef.current }; - } - next[sessionId] = updated; - touchedSessionIds.add(sessionId); - } - - if (!touchedSessionIds.size) return; - - // Commit the ref immediately so subsequent flushes see the latest events. - eventsBySessionRef.current = next; - - // Derive turnActive and approvals from the fully-updated event lists. - const activePatch: Record = {}; - const pendingInputPatch: Record = {}; - for (const sessionId of touchedSessionIds) { - const derived = deriveRuntimeState(next[sessionId] ?? []); - activePatch[sessionId] = derived.turnActive; - pendingInputPatch[sessionId] = derived.pendingInputs; - } - - // All three setters fire synchronously — React 18 batches them into one render. - setEventsBySession(next); - setTurnActiveBySession((activePrev) => ({ ...activePrev, ...activePatch })); - setPendingInputsBySession((pendingPrev) => ({ ...pendingPrev, ...pendingInputPatch })); - }, []); - - const scheduleQueuedEventFlush = useCallback(() => { - if (eventFlushTimerRef.current != null) return; - eventFlushTimerRef.current = window.setTimeout(() => { - eventFlushTimerRef.current = null; - flushQueuedEvents(); - }, 16); - }, [flushQueuedEvents]); - - const scheduleSessionsRefresh = useCallback(() => { - if (refreshSessionsTimerRef.current != null) return; - refreshSessionsTimerRef.current = window.setTimeout(() => { - refreshSessionsTimerRef.current = null; - void refreshSessions().catch(() => {}); - }, 120); - }, [refreshSessions]); - useEffect(() => { - const unsubscribe = window.ade.agentChat.onEvent((envelope) => { + const unsubscribe = window.ade.agentChat.onEvent((envelope: AgentChatEventEnvelope) => { if (!knownSessionIdsRef.current.has(envelope.sessionId)) return; pendingEventQueueRef.current.push(envelope); - - // "done" events must flush immediately so turnActive clears and the - // spinner stops. Other events can use the debounced 16ms schedule. if (envelope.event.type === "done") { - if (eventFlushTimerRef.current != null) { - window.clearTimeout(eventFlushTimerRef.current); - eventFlushTimerRef.current = null; - } + if (eventFlushTimerRef.current != null) { window.clearTimeout(eventFlushTimerRef.current); eventFlushTimerRef.current = null; } flushQueuedEvents(); - } else { - scheduleQueuedEventFlush(); - } - - if (lockSessionId && envelope.sessionId === lockSessionId) { - draftSelectionLockedRef.current = false; - setSelectedSessionId(lockSessionId); - } - - // Wire prompt_suggestion events to state + } else { scheduleQueuedEventFlush(); } + if (lockSessionId && envelope.sessionId === lockSessionId) { draftSelectionLockedRef.current = false; setSelectedSessionId(lockSessionId); } if (envelope.event.type === "prompt_suggestion" && "suggestion" in envelope.event) { - if (envelope.sessionId === selectedSessionIdRef.current) { - setPromptSuggestion((envelope.event as any).suggestion); - } + if (envelope.sessionId === selectedSessionIdRef.current) setPromptSuggestion((envelope.event as any).suggestion); } - - // Clear prompt suggestion when a new turn starts if (envelope.event.type === "status" && envelope.event.turnStatus === "started") { - if (envelope.sessionId === selectedSessionIdRef.current) { - setPromptSuggestion(null); - } + if (envelope.sessionId === selectedSessionIdRef.current) setPromptSuggestion(null); } - - const shouldRefreshSlashCommands = - envelope.event.type === "done" - || ( - envelope.event.type === "system_notice" - && ( - envelope.event.noticeKind === "auth" - || envelope.event.message === "Session ready" - ) - ); - + const shouldRefreshSlashCommands = envelope.event.type === "done" || (envelope.event.type === "system_notice" && (envelope.event.noticeKind === "auth" || envelope.event.message === "Session ready")); if (shouldRefreshSlashCommands) { scheduleSessionsRefresh(); - if (envelope.sessionId === selectedSessionIdRef.current) { - window.ade.agentChat.slashCommands({ sessionId: envelope.sessionId }) - .then(setSdkSlashCommands) - .catch(() => {}); - } + if (envelope.sessionId === selectedSessionIdRef.current) { window.ade.agentChat.slashCommands({ sessionId: envelope.sessionId }).then(setSdkSlashCommands).catch(() => {}); } } }); return unsubscribe; - }, [lockSessionId, flushQueuedEvents, scheduleQueuedEventFlush, scheduleSessionsRefresh]); + }, [lockSessionId, flushQueuedEvents, scheduleQueuedEventFlush, scheduleSessionsRefresh, knownSessionIdsRef, pendingEventQueueRef, eventFlushTimerRef, draftSelectionLockedRef, setSelectedSessionId, setPromptSuggestion, setSdkSlashCommands]); useEffect(() => { const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; - if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { - setProofDrawerOpen(true); - void refreshComputerUseSnapshot(selectedSessionId, { force: true }); - } + if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { setProofDrawerOpen(true); void refreshComputerUseSnapshot(selectedSessionId, { force: true }); } }); return unsubscribe; }, [refreshComputerUseSnapshot, selectedSessionId]); @@ -1187,169 +740,13 @@ export function AgentChatPane({ return unsubscribe; }, [refreshComputerUseSnapshot, selectedSessionId]); - useEffect(() => { - if (!selectedSessionId) { - setProofDrawerOpen(false); - } - }, [selectedSessionId]); - - useEffect(() => () => { - if (eventFlushTimerRef.current != null) { - window.clearTimeout(eventFlushTimerRef.current); - } - if (refreshSessionsTimerRef.current != null) { - window.clearTimeout(refreshSessionsTimerRef.current); - } - pendingEventQueueRef.current = []; - }, []); + useEffect(() => { if (!selectedSessionId) setProofDrawerOpen(false); }, [selectedSessionId]); - useEffect(() => { - if (!preferencesReady) return; - if (!modelId.trim().length) return; - writeLastUsedModelId(modelId); - }, [modelId, preferencesReady]); + useEffect(() => { if (!preferencesReady || !modelId.trim().length) return; writeLastUsedModelId(modelId); }, [modelId, preferencesReady]); - useEffect(() => { - if (!preferencesReady) return; - writeLastUsedReasoningEffort({ - laneId, - modelId, - effort: reasoningEffort - }); - }, [laneId, modelId, preferencesReady, reasoningEffort]); - - const searchAttachments = useCallback(async (query: string): Promise => { - if (!laneId) return []; - const trimmed = query.trim(); - if (!trimmed.length) return []; - - // Try Codex fuzzy file search if we have an active Codex session - if (selectedSessionId && sessionProvider === "codex") { - try { - const codexHits = await window.ade.agentChat.fileSearch({ sessionId: selectedSessionId, query: trimmed }); - if (codexHits.length > 0) { - return codexHits.map((hit) => ({ - path: hit.path, - type: inferAttachmentType(hit.path), - })); - } - } catch { - // Fall through to default search - } - } - - const hits = await window.ade.files.quickOpen({ - workspaceId: laneId, - query: trimmed, - limit: 60 - }); - return hits.map((hit) => ({ - path: hit.path, - type: inferAttachmentType(hit.path) - })); - }, [laneId, selectedSessionId, sessionProvider]); - - const addAttachment = useCallback((attachment: AgentChatFileRef) => { - setAttachments((prev) => { - if (prev.some((entry) => entry.path === attachment.path)) return prev; - return [...prev, attachment]; - }); - }, []); - - const removeAttachment = useCallback((attachmentPath: string) => { - setAttachments((prev) => prev.filter((entry) => entry.path !== attachmentPath)); - }, []); - - const patchSessionSummary = useCallback((sessionId: string, patch: Partial) => { - setSessions((prev) => prev.map((session) => ( - session.sessionId === sessionId ? { ...session, ...patch } : session - ))); - }, []); - - const currentNativeControls = useMemo(() => ({ - claudePermissionMode, - codexApprovalPolicy, - codexSandbox, - codexConfigSource, - unifiedPermissionMode, - }), [ - claudePermissionMode, - codexApprovalPolicy, - codexSandbox, - codexConfigSource, - unifiedPermissionMode, - ]); - - const buildNativeControlPayload = useCallback((provider: "claude" | "codex" | "unified") => { - return summarizeNativeControls(provider, currentNativeControls); - }, [currentNativeControls]); - - const createSession = useCallback(async (): Promise => { - if (createSessionPromiseRef.current) { - return createSessionPromiseRef.current; - } - if (!laneId) return null; - const createPromise = (async () => { - const desc = getModelById(modelId); - const provider = desc?.isCliWrapped - ? (desc.family === "openai" ? "codex" : "claude") - : "unified"; - const model = provider === "unified" ? modelId : (desc?.shortId ?? modelId); - const sessionProfile = resolveChatSessionProfile(computerUsePolicy); - const created = await window.ade.agentChat.create({ - laneId, - provider, - model, - modelId, - sessionProfile, - reasoningEffort, - ...buildNativeControlPayload(provider), - computerUse: computerUsePolicy, - }); - loadedHistoryRef.current.delete(created.id); - optimisticSessionIdsRef.current.add(created.id); - pendingSelectedSessionIdRef.current = created.id; - draftSelectionLockedRef.current = false; - setSelectedSessionId(created.id); - await onSessionCreated?.(created.id); - void refreshSessions().catch(() => {}); - return created.id; - })(); - createSessionPromiseRef.current = createPromise; - try { - return await createPromise; - } finally { - if (createSessionPromiseRef.current === createPromise) { - createSessionPromiseRef.current = null; - } - } - }, [buildNativeControlPayload, computerUsePolicy, laneId, modelId, onSessionCreated, reasoningEffort, refreshSessions]); - - const handoffSession = useCallback(async () => { - if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; - setError(null); - setHandoffBusy(true); - try { - const result = await window.ade.agentChat.handoff({ - sourceSessionId: selectedSessionId, - targetModelId: handoffModelId, - }); - setHandoffOpen(false); - await onSessionCreated?.(result.session.id); - void refreshSessions().catch(() => {}); - } catch (handoffError) { - setError(handoffError instanceof Error ? handoffError.message : String(handoffError)); - } finally { - setHandoffBusy(false); - } - }, [canShowHandoff, handoffBlocked, handoffModelId, onSessionCreated, refreshSessions, selectedSessionId]); + useEffect(() => { if (!preferencesReady) return; writeLastUsedReasoningEffort({ laneId, modelId, effort: reasoningEffort }); }, [laneId, modelId, preferencesReady, reasoningEffort]); // ── Eager session creation ── - // Create a session as soon as we have a model + lane, so slash commands, - // MCP status, and other pre-chat metadata are available immediately. - // Computer-use-capable chats start as workflow sessions so ADE can wire the - // Ghost/proof harness before the first turn. - // Skip when the pane is locked to an existing session or in forced-draft mode. const eagerCreateFiredRef = useRef(false); useEffect(() => { if (eagerCreateFiredRef.current) return; @@ -1361,11 +758,6 @@ export function AgentChatPane({ }, [preferencesReady, laneId, modelId, selectedSessionId, lockSessionId, initialSessionId, forceDraft, createSession]); // ── Model-switch on empty session ── - // When the user changes the model before sending any messages, update the - // existing (empty) session in place. If the provider changed (e.g. Claude → - // Codex), dispose the stale session and create a fresh one for the new model. - // The `userChangedModelRef` flag ensures this only fires on explicit user - // model-picker interactions, NOT during boot when the saved model is loaded. const userChangedModelRef = useRef(false); useEffect(() => { if (!userChangedModelRef.current) return; @@ -1375,247 +767,20 @@ export function AgentChatPane({ void (async () => { try { const desc = getModelById(modelId); - const provider = desc?.isCliWrapped - ? (desc.family === "openai" ? "codex" : "claude") - : "unified"; - await window.ade.agentChat.updateSession({ - sessionId: selectedSessionId, - modelId, - ...buildNativeControlPayload(provider), - }); + const provider = desc?.isCliWrapped ? (desc.family === "openai" ? "codex" : "claude") : "unified"; + await window.ade.agentChat.updateSession({ sessionId: selectedSessionId, modelId, ...buildNativeControlPayload(provider) }); await refreshSessions(); - window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) - .then(setSdkSlashCommands) - .catch(() => {}); + window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }).then(setSdkSlashCommands).catch(() => {}); } catch { - // Provider-incompatible switch — dispose old empty session and create fresh - try { - await window.ade.agentChat.dispose({ sessionId: selectedSessionId }); - } catch { /* ignore */ } + try { await window.ade.agentChat.dispose({ sessionId: selectedSessionId }); } catch { /* ignore */ } pendingSelectedSessionIdRef.current = null; setSelectedSessionId(null); - eagerCreateFiredRef.current = false; // allow eager effect to re-fire + eagerCreateFiredRef.current = false; } })(); - }, [buildNativeControlPayload, isPersistentIdentitySurface, modelId, selectedSessionId, selectedEvents.length, turnActive, refreshSessions]); - - const submit = useCallback(async () => { - if (submitInFlightRef.current || busy) return; - if (!modelId) return; - const text = draft.trim(); - if (!text.length || !laneId) return; - const draftSnapshot = draft; - const attachmentsSnapshot = attachments; - const isLiteralSlashCommand = text.startsWith("/"); + }, [buildNativeControlPayload, isPersistentIdentitySurface, modelId, selectedSessionId, selectedEvents.length, turnActive, refreshSessions, setSdkSlashCommands, pendingSelectedSessionIdRef, setSelectedSessionId]); - submitInFlightRef.current = true; - setBusy(true); - setError(null); - setDraft(""); - setAttachments([]); - try { - let finalText = text; - - // Prepend project context docs if the user toggled the checkbox - if (!isLiteralSlashCommand && includeProjectDocs) { - const docPaths = [".ade/context/PRD.ade.md", ".ade/context/ARCHITECTURE.ade.md"]; - const docNote = [ - "[Project Context — generated from main branch, may not reflect in-progress lane work]", - "The following project-level docs are available for reference. Read them with read_file if you need project context:", - ...docPaths.map((p) => `- ${p}`), - ].join("\n"); - finalText = `${docNote}\n\n---\n\n${finalText}`; - setIncludeProjectDocs(false); - } - - let sessionId = selectedSessionId; - const shouldPromoteLightSession = shouldPromoteSessionForComputerUse(selectedSession, computerUsePolicy); - const selectedModelChanged = - Boolean(selectedSessionId) - && Boolean(selectedSessionModelId) - && selectedSessionModelId !== modelId; - - if (sessionId && !turnActive && (selectedModelChanged || hasComputerUseSelectionChanged || shouldPromoteLightSession)) { - const desc = getModelById(modelId); - const provider = desc?.isCliWrapped - ? (desc.family === "openai" ? "codex" : "claude") - : "unified"; - await window.ade.agentChat.updateSession({ - sessionId, - modelId, - reasoningEffort, - ...buildNativeControlPayload(provider), - computerUse: computerUsePolicy, - }); - await refreshSessions(); - } else if (!sessionId) { - // No session yet — create one - sessionId = await createSession(); - } - if (!sessionId) { - throw new Error("Unable to create chat session."); - } - - const selectedAttachments = isLiteralSlashCommand ? [] : attachmentsSnapshot; - if (turnActiveBySession[sessionId]) { - const steerText = selectedAttachments.length - ? `${finalText}\n\nAttached context:\n${selectedAttachments.map((entry) => `- ${entry.type}: ${entry.path}`).join("\n")}` - : finalText; - await window.ade.agentChat.steer({ sessionId, text: steerText }); - } else { - await window.ade.agentChat.send({ - sessionId, - text: finalText, - displayText: text, - attachments: selectedAttachments, - reasoningEffort, - executionMode: launchModeEditable ? executionMode : null, - }); - } - await refreshSessions().catch(() => {}); - } catch (submitError) { - const message = submitError instanceof Error ? submitError.message : String(submitError); - setDraft((current) => (current.trim().length ? current : draftSnapshot)); - setAttachments((current) => (current.length ? current : attachmentsSnapshot)); - setError(message); - if ( - /ade chat could not authenticate/i.test(message) - || /not authenticated/i.test(message) - || /login required/i.test(message) - ) { - void refreshAvailableModels().catch(() => {}); - } - } finally { - submitInFlightRef.current = false; - setBusy(false); - } - }, [ - attachments, - buildNativeControlPayload, - busy, - createSession, - computerUsePolicy, - draft, - executionMode, - hasComputerUseSelectionChanged, - includeProjectDocs, - laneId, - launchModeEditable, - modelId, - reasoningEffort, - refreshSessions, - selectedEvents.length, - selectedSessionId, - selectedSessionModelId, - turnActive, - turnActiveBySession - ]); - - const interrupt = useCallback(async () => { - if (!selectedSessionId) return; - try { - await window.ade.agentChat.interrupt({ sessionId: selectedSessionId }); - } catch (interruptError) { - setError(interruptError instanceof Error ? interruptError.message : String(interruptError)); - } - }, [selectedSessionId]); - - const approve = useCallback(async ( - decision: AgentChatApprovalDecision, - responseText?: string | null, - answers?: Record, - ) => { - if (!selectedSessionId) return; - const request = pendingInputsBySession[selectedSessionId]?.[0]; - if (!request) return; - try { - await window.ade.agentChat.respondToInput({ - sessionId: selectedSessionId, - itemId: request.itemId, - decision, - responseText, - ...(answers ? { answers } : {}), - }); - setPendingInputsBySession((prev) => ({ - ...prev, - [selectedSessionId]: (prev[selectedSessionId] ?? []).filter((entry) => entry.itemId !== request.itemId) - })); - } catch (approvalError) { - setError(approvalError instanceof Error ? approvalError.message : String(approvalError)); - } - }, [pendingInputsBySession, selectedSessionId]); - - const updateNativeControls = useCallback(async (patch: Partial) => { - if (isPersistentIdentitySurface && sessionMutationKind) return; - - const nextControls: NativeControlState = { - ...currentNativeControls, - ...patch, - }; - - setClaudePermissionMode(nextControls.claudePermissionMode); - setCodexApprovalPolicy(nextControls.codexApprovalPolicy); - setCodexSandbox(nextControls.codexSandbox); - setCodexConfigSource(nextControls.codexConfigSource); - setUnifiedPermissionMode(nextControls.unifiedPermissionMode); - - if (!selectedSessionId) return; - - const provider = selectedSession?.provider ?? sessionProvider; - const nextSummary = summarizeNativeControls(provider, nextControls); - patchSessionSummary(selectedSessionId, nextSummary); - if (isPersistentIdentitySurface) { - setSessionMutationKind("permission"); - } - - try { - await window.ade.agentChat.updateSession({ - sessionId: selectedSessionId, - ...nextSummary, - }); - void refreshSessions().catch(() => {}); - } catch (err) { - void refreshSessions().catch(() => {}); - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (isPersistentIdentitySurface) { - setSessionMutationKind(null); - } - } - }, [ - currentNativeControls, - isPersistentIdentitySurface, - patchSessionSummary, - refreshSessions, - selectedSession, - selectedSessionId, - sessionMutationKind, - sessionProvider, - ]); - - const handleComputerUsePolicyChange = useCallback(async (nextPolicy: ComputerUsePolicy) => { - if (isPersistentIdentitySurface && sessionMutationKind) return; - setComputerUsePolicy(nextPolicy); - if (!selectedSessionId) return; - patchSessionSummary(selectedSessionId, { computerUse: nextPolicy }); - if (isPersistentIdentitySurface) { - setSessionMutationKind("computer-use"); - } - try { - await window.ade.agentChat.updateSession({ - sessionId: selectedSessionId, - computerUse: nextPolicy, - }); - await refreshSessions(); - await refreshComputerUseSnapshot(selectedSessionId, { force: true }); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (isPersistentIdentitySurface) { - setSessionMutationKind(null); - } - } - }, [isPersistentIdentitySurface, patchSessionSummary, refreshComputerUseSnapshot, refreshSessions, selectedSessionId, sessionMutationKind]); + // ── Render ──────────────────────────────────────────────────────── if (!laneId) { return ( @@ -1631,149 +796,54 @@ export function AgentChatPane({
-
- {resolvedTitle} -
+
{resolvedTitle}
{canShowHandoff ? (
- + {handoffOpen ? (
Start a sibling chat on another model
-
- ADE will create a new work chat, inject a handoff summary from this session, and route you into the new tab. -
-
-
- +
ADE will create a new work chat, inject a handoff summary from this session, and route you into the new tab.
+
- - + +
) : null}
) : null} {isPersistentIdentitySurface && selectedSessionId ? ( - + ) : null} {resolvedChips.map((chip) => ( - - {chip.label} - + {chip.label} ))}
- {!lockSessionId && !hideSessionTabs ? (
{sessions.map((session) => { - const desc = session.modelId ? getModelById(session.modelId) : MODEL_REGISTRY.find((m) => m.shortId === session.model); + const desc = session.modelId ? getModelById(session.modelId) : resolveModelDescriptorForProvider(session.model, isModelProviderGroup(session.provider) ? session.provider : undefined); const title = chatSessionTitle(session); const isActive = session.sessionId === selectedSessionId; - const isRunning = turnActiveBySession[session.sessionId] ?? false; + const isRunning = eventsHook.turnActiveBySession[session.sessionId] ?? false; return ( - ); })}
-
@@ -1783,38 +853,9 @@ export function AgentChatPane({ return ( <> - { void updateNativeControls({ claudePermissionMode: value }); }} onCodexApprovalPolicyChange={(value) => { void updateNativeControls({ codexApprovalPolicy: value }); }} @@ -1822,14 +863,10 @@ export function AgentChatPane({ onCodexConfigSourceChange={(value) => { void updateNativeControls({ codexConfigSource: value }); }} onUnifiedPermissionModeChange={(value) => { void updateNativeControls({ unifiedPermissionMode: value }); }} onComputerUsePolicyChange={handleComputerUsePolicyChange} - onToggleProof={() => setProofDrawerOpen((current) => !current)} + onToggleProof={() => setProofDrawerOpen((c) => !c)} onModelChange={(nextModelId) => { - if (selectedSessionModelId && effectiveAvailableModelIds.length && !effectiveAvailableModelIds.includes(nextModelId)) { - return; - } - if (isPersistentIdentitySurface && sessionMutationKind) { - return; - } + if (selectedSessionModelId && effectiveAvailableModelIds.length && !effectiveAvailableModelIds.includes(nextModelId)) return; + if (isPersistentIdentitySurface && sessionMutationKind) return; userChangedModelRef.current = true; const previousModelId = modelId; const previousReasoningEffort = reasoningEffort; @@ -1839,100 +876,43 @@ export function AgentChatPane({ const preferred = readLastUsedReasoningEffort({ laneId, modelId: nextModelId }); const nextReasoningEffort = selectReasoningEffort({ tiers, preferred }); setReasoningEffort(nextReasoningEffort); - if (selectedSessionId && isPersistentIdentitySurface && !turnActive) { - const nextProvider = nextDesc?.isCliWrapped - ? (nextDesc.family === "openai" ? "codex" : "claude") - : "unified"; - const nextModel = nextProvider === "unified" ? nextModelId : (nextDesc?.shortId ?? nextModelId); + const nextProvider = nextDesc?.isCliWrapped ? (nextDesc.family === "openai" ? "codex" : "claude") : "unified"; + const nextModel = nextDesc ? getRuntimeModelRefForDescriptor(nextDesc, nextProvider) : nextModelId; setSessionMutationKind("model"); - patchSessionSummary(selectedSessionId, { - provider: nextProvider, - model: nextModel, - modelId: nextModelId, - reasoningEffort: nextReasoningEffort, - ...buildNativeControlPayload(nextProvider), - }); - void window.ade.agentChat.updateSession({ - sessionId: selectedSessionId, - modelId: nextModelId, - reasoningEffort: nextReasoningEffort, - ...buildNativeControlPayload(nextProvider), - computerUse: computerUsePolicy, - }).then(() => { - window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) - .then(setSdkSlashCommands) - .catch(() => {}); - void refreshSessions().catch(() => {}); - }).catch((err) => { - setModelId(previousModelId); - setReasoningEffort(previousReasoningEffort); + patchSessionSummary(selectedSessionId, { provider: nextProvider, model: nextModel, modelId: nextModelId, reasoningEffort: nextReasoningEffort, ...buildNativeControlPayload(nextProvider) }); + void window.ade.agentChat.updateSession({ sessionId: selectedSessionId, modelId: nextModelId, reasoningEffort: nextReasoningEffort, ...buildNativeControlPayload(nextProvider), computerUse: computerUsePolicy }).then(() => { + window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }).then(setSdkSlashCommands).catch(() => {}); void refreshSessions().catch(() => {}); - setError(err instanceof Error ? err.message : String(err)); - }).finally(() => { - setSessionMutationKind(null); - }); + }).catch((err) => { setModelId(previousModelId); setReasoningEffort(previousReasoningEffort); void refreshSessions().catch(() => {}); setError(err instanceof Error ? err.message : String(err)); }).finally(() => { setSessionMutationKind(null); }); } - - // Trigger early warmup when the user selects a Claude/Anthropic - // model so the ~30s subprocess cold-start happens while they type. if (selectedSessionId && nextDesc?.family === "anthropic" && nextDesc?.isCliWrapped) { - window.ade.agentChat.warmupModel({ - sessionId: selectedSessionId, - modelId: nextModelId, - }).catch(() => { /* warmup is best-effort */ }); + window.ade.agentChat.warmupModel({ sessionId: selectedSessionId, modelId: nextModelId }).catch(() => {}); } }} onReasoningEffortChange={setReasoningEffort} - onDraftChange={(value) => { - setDraft(value); - if (value.length > 0) setPromptSuggestion(null); - }} - onClearDraft={() => setDraft("")} - onSubmit={() => { - setPromptSuggestion(null); - void submit(); - }} - onInterrupt={() => { - void interrupt(); - }} - onApproval={(decision) => { - void approve(decision); - }} + onDraftChange={(value) => { setDraft(value); if (value.length > 0) setPromptSuggestion(null); }} + onClearDraft={() => clearDraft()} + onSubmit={() => { setPromptSuggestion(null); void submit(); }} + onInterrupt={() => { void interrupt(); }} + onApproval={(decision) => { void approve(decision); }} onAddAttachment={addAttachment} onRemoveAttachment={removeAttachment} onSearchAttachments={searchAttachments} includeProjectDocs={includeProjectDocs} onIncludeProjectDocsChange={setIncludeProjectDocs} - onClearEvents={() => { - if (selectedSessionId) { - eventsBySessionRef.current = { ...eventsBySessionRef.current, [selectedSessionId]: [] }; - setEventsBySession((prev) => ({ ...prev, [selectedSessionId]: [] })); - setPendingInputsBySession((prev) => ({ ...prev, [selectedSessionId]: [] })); - } - }} + onClearEvents={() => { if (selectedSessionId) { eventsBySessionRef.current = { ...eventsBySessionRef.current, [selectedSessionId]: [] }; setEventsBySession((prev) => ({ ...prev, [selectedSessionId]: [] })); setPendingInputsBySession((prev) => ({ ...prev, [selectedSessionId]: [] })); } }} promptSuggestion={promptSuggestion} subagentSnapshots={selectedSubagentSnapshots} /> - } - bodyClassName="flex min-h-0 flex-col overflow-hidden" - > - {error ? ( -
- {error} -
- ) : null} + } bodyClassName="flex min-h-0 flex-col overflow-hidden"> + {error ? (
{error}
) : null} {selectedSessionId && activeProviderConnection?.blocker && !activeProviderConnection.runtimeAvailable ? (
-
- {activeProviderConnection.provider === "claude" ? "Claude runtime" : "Codex runtime"} -
-
- {activeProviderConnection.blocker} -
+
{activeProviderConnection.provider === "claude" ? "Claude runtime" : "Codex runtime"}
+
{activeProviderConnection.blocker}
) : null} -
{loading ? (
@@ -1952,50 +932,27 @@ export function AgentChatPane({
Proof drawer
-
- Inspect retained screenshots, traces, logs, and verification output for this chat. -
+
Inspect retained screenshots, traces, logs, and verification output for this chat.
- +
- refreshComputerUseSnapshot(selectedSessionId, { force: true })} - /> + refreshComputerUseSnapshot(selectedSessionId, { force: true })} />
) : null} { if (!selectedSessionId) return; window.ade.agentChat.respondToInput({ sessionId: selectedSessionId, itemId, decision, responseText }).then(() => { - setPendingInputsBySession((prev) => ({ - ...prev, - [selectedSessionId]: (prev[selectedSessionId] ?? []).filter((e) => e.itemId !== itemId) - })); - }).catch((err) => { - setError(err instanceof Error ? err.message : String(err)); - }); + setPendingInputsBySession((prev) => ({ ...prev, [selectedSessionId]: (prev[selectedSessionId] ?? []).filter((e) => e.itemId !== itemId) })); + }).catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); }} /> + {selectedEvents.length > 0 ? ( + + ) : null} {sessionDelta ? (
+{sessionDelta.insertions} @@ -2008,28 +965,12 @@ export function AgentChatPane({
- - {laneDisplayLabel} - -
-
- Start typing below + {laneDisplayLabel}
+
Start typing below
- {[ - "Explain the project structure", - "Review recent changes", - "Plan the next feature", - "Find bugs and propose fixes", - ].map((prompt) => ( - + {["Explain the project structure", "Review recent changes", "Plan the next feature", "Find bugs and propose fixes"].map((prompt) => ( + ))}
@@ -2038,18 +979,7 @@ export function AgentChatPane({
{pendingInput && selectedSessionId && (pendingInput.request.kind === "question" || pendingInput.request.kind === "structured_question") ? ( - { - void approve("cancel"); - }} - onSubmit={({ answers, responseText }) => { - void approve("accept", responseText, answers); - }} - onDecline={() => { - void approve("decline"); - }} - /> + { void approve("cancel"); }} onSubmit={({ answers, responseText }) => { void approve("accept", responseText, answers); }} onDecline={() => { void approve("decline"); }} /> ) : null} ); diff --git a/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx b/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx index 759db9a86..f7b8547bf 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx @@ -22,18 +22,18 @@ export function ChatComposerShell({ return (
- {pendingBanner ?
{pendingBanner}
: null} - {trays ?
{trays}
: null} + {pendingBanner ?
{pendingBanner}
: null} + {trays ?
{trays}
: null}
{pickerLayer} {children}
- {footer ?
{footer}
: null} + {footer ?
{footer}
: null}
); } diff --git a/apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx b/apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx new file mode 100644 index 000000000..bc91d28c3 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx @@ -0,0 +1,91 @@ +import React, { useMemo } from "react"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; + +type SessionTokenUsage = { + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + turnCount: number; +}; + +export function deriveSessionTokenUsage(events: AgentChatEventEnvelope[]): SessionTokenUsage { + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreationTokens = 0; + let totalCostUsd = 0; + let turnCount = 0; + + for (const envelope of events) { + const event = envelope.event; + if (event.type !== "done") continue; + turnCount++; + if (event.usage) { + totalInputTokens += event.usage.inputTokens ?? 0; + totalOutputTokens += event.usage.outputTokens ?? 0; + totalCacheReadTokens += event.usage.cacheReadTokens ?? 0; + totalCacheCreationTokens += event.usage.cacheCreationTokens ?? 0; + } + if (typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) { + totalCostUsd += event.costUsd; + } + } + + return { totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreationTokens, totalCostUsd, turnCount }; +} + +function formatTokenCount(value: number): string { + if (value <= 0) return "0"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + +export const ChatContextMeter = React.memo(function ChatContextMeter({ + events, + contextWindow, +}: { + events: AgentChatEventEnvelope[]; + contextWindow?: number; +}) { + const usage = useMemo(() => deriveSessionTokenUsage(events), [events]); + + if (usage.turnCount === 0) return null; + + const totalTokens = usage.totalInputTokens + usage.totalOutputTokens; + const fillPercent = contextWindow && contextWindow > 0 + ? Math.min(100, Math.round((usage.totalInputTokens / contextWindow) * 100)) + : null; + + const costStr = usage.totalCostUsd > 0 + ? usage.totalCostUsd < 0.01 + ? "<$0.01" + : `$${usage.totalCostUsd.toFixed(2)}` + : null; + + return ( +
+ {formatTokenCount(totalTokens)} tokens + {usage.totalCacheReadTokens > 0 ? ( + ({formatTokenCount(usage.totalCacheReadTokens)} cached) + ) : null} + {costStr ? {costStr} : null} + {fillPercent !== null ? ( +
+
+
80 ? "bg-amber-400/60" : fillPercent > 50 ? "bg-sky-400/40" : "bg-emerald-400/30" + }`} + style={{ width: `${fillPercent}%` }} + /> +
+ {fillPercent}% +
+ ) : null} + {usage.turnCount} turn{usage.turnCount !== 1 ? "s" : ""} +
+ ); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx index 26ab461e3..c6928953b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx @@ -23,21 +23,21 @@ export function ChatSurfaceShell({ return (
{header ? ( -
+
{header}
) : null} -
+
{children}
{footer ? ( -
+
{footer}
) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx b/apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx new file mode 100644 index 000000000..37ae807a2 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { cn } from "../ui/cn"; + +export type TurnDividerData = { + turnId: string; + timestamp: string; + endTimestamp?: string; + model?: string; + filesChanged?: number; + insertions?: number; + deletions?: number; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + costUsd?: number; + status?: "completed" | "interrupted" | "failed"; +}; + +function formatDuration(startIso: string, endIso?: string): string | null { + if (!endIso) return null; + const ms = Date.parse(endIso) - Date.parse(startIso); + if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 1000) return "<1s"; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; +} + +function formatTokens(count: number | undefined | null): string | null { + if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return null; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`; + return String(Math.round(count)); +} + +function formatCost(usd: number | undefined | null): string | null { + if (typeof usd !== "number" || !Number.isFinite(usd) || usd <= 0) return null; + if (usd < 0.01) return "<$0.01"; + return `$${usd.toFixed(2)}`; +} + +export const ChatTurnDivider = React.memo(function ChatTurnDivider({ + data, +}: { + data: TurnDividerData; +}) { + const duration = formatDuration(data.timestamp, data.endTimestamp); + const inputTok = formatTokens(data.inputTokens); + const outputTok = formatTokens(data.outputTokens); + const cacheTok = formatTokens(data.cacheReadTokens); + const cost = formatCost(data.costUsd); + const hasStats = duration || data.filesChanged || inputTok || outputTok || cost; + + const statusDotColor = data.status === "failed" + ? "bg-red-400/50" + : data.status === "interrupted" + ? "bg-amber-400/50" + : "bg-emerald-400/30"; + + if (!hasStats) return null; + + return ( +
+
+
+ + {duration ? {duration} : null} + {data.filesChanged ? ( + + {data.filesChanged} file{data.filesChanged !== 1 ? "s" : ""} + {data.insertions ? +{data.insertions} : null} + {data.deletions ? -{data.deletions} : null} + + ) : null} + {inputTok || outputTok ? ( + + {inputTok ? `${inputTok} in` : ""} + {inputTok && outputTok ? " / " : ""} + {outputTok ? `${outputTok} out` : ""} + {cacheTok ? ` (${cacheTok} cached)` : ""} + + ) : null} + {cost ? {cost} : null} +
+
+
+ ); +}); diff --git a/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx new file mode 100644 index 000000000..6e9f472f2 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx @@ -0,0 +1,245 @@ +import React, { Suspense, useCallback, useEffect, useState, useRef } from "react"; +import { CopySimple, Checks } from "@phosphor-icons/react"; + +/* ── LRU cache for highlighted HTML ── */ + +class LRUCache { + private map = new Map(); + constructor(private maxSize: number) {} + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value !== undefined) { + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, value); + } + return value; + } + + set(key: K, value: V): void { + if (this.map.has(key)) { + this.map.delete(key); + } else if (this.map.size >= this.maxSize) { + // Delete the oldest (first) entry + const firstKey = this.map.keys().next().value; + if (firstKey !== undefined) this.map.delete(firstKey); + } + this.map.set(key, value); + } +} + +const highlightCache = new LRUCache(300); + +/* ── Shiki highlighter (lazy singleton) ── */ + +const SUPPORTED_LANGUAGES = [ + "typescript", "javascript", "jsx", "tsx", "python", "rust", "go", + "java", "bash", "shell", "json", "yaml", "html", "css", "sql", + "markdown", "diff", "c", "cpp", "ruby", "php", "swift", "kotlin", +]; + +const THEME = "github-dark-dimmed"; + +type ShikiHighlighter = { + codeToHtml(code: string, options: { lang: string; theme: string }): string; +}; + +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = import("shiki").then((shiki) => + shiki.createHighlighter({ + themes: [THEME], + langs: SUPPORTED_LANGUAGES, + }), + ); + } + return highlighterPromise; +} + +/* ── Highlight function ── */ + +async function highlightCode(code: string, language: string): Promise { + const cacheKey = `${language}::${code}`; + const cached = highlightCache.get(cacheKey); + if (cached !== undefined) return cached; + + const highlighter = await getHighlighter(); + const lang = SUPPORTED_LANGUAGES.includes(language) ? language : "text"; + + let html: string; + try { + html = highlighter.codeToHtml(code, { lang, theme: THEME }); + } catch { + // If highlighting fails for the language, render as plain text + html = ""; + } + + if (html) { + highlightCache.set(cacheKey, html); + } + return html; +} + +/* ── Diff preview (inline, for language-diff blocks) ── */ + +function DiffCodeBlock({ code }: { code: string }) { + const lines = code.split(/\r?\n/); + return ( +
+ {lines.map((line, index) => { + let tone = "text-[var(--chat-code-fg)]/70"; + let bg = ""; + if (line.startsWith("+")) { + tone = "text-emerald-400/90"; + bg = "bg-emerald-500/[0.06]"; + } else if (line.startsWith("-")) { + tone = "text-red-400/90"; + bg = "bg-rose-500/[0.06]"; + } else if (line.startsWith("@@")) { + tone = "text-accent/60"; + } + return ( +
+ {line} +
+ ); + })} +
+ ); +} + +/* ── Copy button ── */ + +function CodeCopyButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return; + void navigator.clipboard.writeText(code) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + }) + .catch(() => { + setCopied(false); + }); + }, [code]); + + return ( + + ); +} + +/* ── Error boundary ── */ + +class CodeErrorBoundary extends React.Component< + { fallback: React.ReactNode; children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { fallback: React.ReactNode; children: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + render() { + if (this.state.hasError) return this.props.fallback; + return this.props.children; + } +} + +/* ── Inner highlighted code (async state) ── */ + +function HighlightedCodeInner({ code, language }: { code: string; language: string }) { + const [html, setHtml] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + useEffect(() => { + let cancelled = false; + setHtml(null); + + highlightCode(code, language).then((result) => { + if (!cancelled && mountedRef.current) { + setHtml(result); + } + }); + + return () => { cancelled = true; }; + }, [code, language]); + + if (!html) { + // Loading / no highlight available — show plain code + return ( + + {code} + + ); + } + + return ( +
+ ); +} + +/* ── Plain code fallback ── */ + +function PlainCodeFallback({ code }: { code: string }) { + return ( + + {code} + + ); +} + +/* ── Exported component ── */ + +export const HighlightedCode = React.memo(function HighlightedCode({ + code, + language, +}: { + code: string; + language: string; +}) { + const trimmedCode = code.replace(/\n$/, ""); + const isDiff = language === "diff"; + + return ( +
+ +
+ {isDiff ? ( + + ) : ( + }> + }> + + + + )} +
+
+ ); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts new file mode 100644 index 000000000..0e3396791 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; +import type { ChatSurfaceChipTone, ChatSurfaceMode } from "../../../shared/types"; +import { + CHAT_SURFACE_ACCENTS, + chatChipToneClass, + chatSurfaceVars, + colorToRgba, + resolveChatSurfaceAccent, +} from "./chatSurfaceTheme"; + +// --------------------------------------------------------------------------- +// colorToRgba +// --------------------------------------------------------------------------- + +describe("colorToRgba", () => { + it("converts a 6-digit hex color to rgba", () => { + expect(colorToRgba("#FF8800", 1)).toBe("rgba(255, 136, 0, 1)"); + }); + + it("converts a 6-digit hex color with fractional alpha", () => { + expect(colorToRgba("#FF8800", 0.5)).toBe("rgba(255, 136, 0, 0.5)"); + }); + + it("converts a 3-digit hex color by expanding digits", () => { + // #F80 -> #FF8800 + expect(colorToRgba("#F80", 1)).toBe("rgba(255, 136, 0, 1)"); + }); + + it("handles lowercase hex values", () => { + expect(colorToRgba("#ff8800", 0.14)).toBe("rgba(255, 136, 0, 0.14)"); + }); + + it("handles mixed case hex values", () => { + expect(colorToRgba("#Ff8800", 0.28)).toBe("rgba(255, 136, 0, 0.28)"); + }); + + it("handles alpha = 0", () => { + expect(colorToRgba("#000000", 0)).toBe("rgba(0, 0, 0, 0)"); + }); + + it("handles all-black hex", () => { + expect(colorToRgba("#000000", 1)).toBe("rgba(0, 0, 0, 1)"); + }); + + it("handles all-white hex", () => { + expect(colorToRgba("#FFFFFF", 1)).toBe("rgba(255, 255, 255, 1)"); + }); + + it("falls back to default color for invalid hex", () => { + // normalizeHex defaults to "#71717A" for invalid input + // #71717A => r=113, g=113, b=122 + expect(colorToRgba("not-a-color", 0.5)).toBe("rgba(113, 113, 122, 0.5)"); + }); + + it("falls back for empty string", () => { + expect(colorToRgba("", 1)).toBe("rgba(113, 113, 122, 1)"); + }); + + it("handles hex with leading/trailing whitespace", () => { + expect(colorToRgba(" #FF0000 ", 1)).toBe("rgba(255, 0, 0, 1)"); + }); + + it("falls back for 4-digit hex (not valid shorthand)", () => { + // #1234 is neither 3 nor 6 hex digits + expect(colorToRgba("#1234", 1)).toBe("rgba(113, 113, 122, 1)"); + }); + + it("falls back for 5-digit hex", () => { + expect(colorToRgba("#12345", 1)).toBe("rgba(113, 113, 122, 1)"); + }); + + it("correctly expands 3-digit shorthand #000", () => { + expect(colorToRgba("#000", 1)).toBe("rgba(0, 0, 0, 1)"); + }); + + it("correctly expands 3-digit shorthand #FFF", () => { + expect(colorToRgba("#FFF", 1)).toBe("rgba(255, 255, 255, 1)"); + }); + + it("correctly expands 3-digit shorthand #abc", () => { + // #abc -> #aabbcc => r=170, g=187, b=204 + expect(colorToRgba("#abc", 1)).toBe("rgba(170, 187, 204, 1)"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveChatSurfaceAccent +// --------------------------------------------------------------------------- + +describe("resolveChatSurfaceAccent", () => { + it("returns mode-default accent when no custom color is provided", () => { + expect(resolveChatSurfaceAccent("standard")).toBe("#71717A"); + expect(resolveChatSurfaceAccent("resolver")).toBe("#F97316"); + expect(resolveChatSurfaceAccent("mission-thread")).toBe("#38BDF8"); + expect(resolveChatSurfaceAccent("mission-feed")).toBe("#22C55E"); + }); + + it("returns mode-default accent when accentColor is null", () => { + expect(resolveChatSurfaceAccent("resolver", null)).toBe("#F97316"); + }); + + it("returns mode-default accent when accentColor is undefined", () => { + expect(resolveChatSurfaceAccent("resolver", undefined)).toBe("#F97316"); + }); + + it("returns mode-default accent when accentColor is empty string", () => { + expect(resolveChatSurfaceAccent("resolver", "")).toBe("#F97316"); + }); + + it("returns mode-default accent when accentColor is whitespace only", () => { + expect(resolveChatSurfaceAccent("resolver", " ")).toBe("#F97316"); + }); + + it("returns the custom color normalized when it is a valid 6-digit hex", () => { + expect(resolveChatSurfaceAccent("standard", "#FF0000")).toBe("#FF0000"); + }); + + it("expands a valid 3-digit hex custom color", () => { + expect(resolveChatSurfaceAccent("standard", "#F00")).toBe("#FF0000"); + }); + + it("normalizes invalid custom color to the default fallback hex", () => { + expect(resolveChatSurfaceAccent("standard", "garbage")).toBe("#71717A"); + }); + + it("trims custom color before normalizing", () => { + expect(resolveChatSurfaceAccent("standard", " #00FF00 ")).toBe("#00FF00"); + }); +}); + +// --------------------------------------------------------------------------- +// chatSurfaceVars +// --------------------------------------------------------------------------- + +describe("chatSurfaceVars", () => { + it("returns an object with all expected CSS custom properties", () => { + const vars = chatSurfaceVars("standard"); + const keys = Object.keys(vars); + expect(keys).toContain("--chat-accent"); + expect(keys).toContain("--chat-accent-soft"); + expect(keys).toContain("--chat-accent-faint"); + expect(keys).toContain("--chat-accent-glow"); + expect(keys).toContain("--chat-surface-bg"); + expect(keys).toContain("--chat-surface-raised"); + expect(keys).toContain("--chat-panel-bg"); + expect(keys).toContain("--chat-panel-bg-strong"); + expect(keys).toContain("--chat-card-bg"); + expect(keys).toContain("--chat-card-bg-strong"); + expect(keys).toContain("--chat-panel-border"); + expect(keys).toContain("--chat-card-border"); + expect(keys).toContain("--chat-code-bg"); + expect(keys).toContain("--chat-code-border"); + expect(keys).toContain("--chat-code-fg"); + expect(keys).toContain("--chat-notice-bg"); + expect(keys).toContain("--chat-notice-border"); + }); + + it("uses the mode-default accent color when no custom color is provided", () => { + const vars = chatSurfaceVars("resolver"); + expect(vars["--chat-accent" as keyof typeof vars]).toBe("#F97316"); + }); + + it("uses custom accent color when provided", () => { + const vars = chatSurfaceVars("standard", "#FF0000"); + expect(vars["--chat-accent" as keyof typeof vars]).toBe("#FF0000"); + }); + + it("derives soft/faint/glow from the resolved accent color", () => { + const vars = chatSurfaceVars("standard", "#FF0000"); + expect(vars["--chat-accent-soft" as keyof typeof vars]).toBe("rgba(255, 0, 0, 0.14)"); + expect(vars["--chat-accent-faint" as keyof typeof vars]).toBe("rgba(255, 0, 0, 0.08)"); + expect(vars["--chat-accent-glow" as keyof typeof vars]).toBe("rgba(255, 0, 0, 0.28)"); + }); + + it("has color-mix expressions for layout vars", () => { + const vars = chatSurfaceVars("standard"); + const surfaceBg = vars["--chat-surface-bg" as keyof typeof vars] as string; + expect(surfaceBg).toContain("color-mix"); + }); + + it("produces different accent for different modes", () => { + const standard = chatSurfaceVars("standard"); + const resolver = chatSurfaceVars("resolver"); + expect(standard["--chat-accent" as keyof typeof standard]).not.toBe( + resolver["--chat-accent" as keyof typeof resolver], + ); + }); + + it("passes through null accentColor without error", () => { + const vars = chatSurfaceVars("mission-feed", null); + expect(vars["--chat-accent" as keyof typeof vars]).toBe("#22C55E"); + }); +}); + +// --------------------------------------------------------------------------- +// chatChipToneClass +// --------------------------------------------------------------------------- + +describe("chatChipToneClass", () => { + it("returns a non-empty class string for every tone", () => { + const tones: ChatSurfaceChipTone[] = ["accent", "success", "warning", "danger", "info", "muted"]; + for (const tone of tones) { + const result = chatChipToneClass(tone); + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + } + }); + + it("defaults to 'accent' tone when called with no argument", () => { + const defaultResult = chatChipToneClass(); + const accentResult = chatChipToneClass("accent"); + expect(defaultResult).toBe(accentResult); + }); + + it("returns different class strings for different tones", () => { + const success = chatChipToneClass("success"); + const danger = chatChipToneClass("danger"); + expect(success).not.toBe(danger); + }); + + it("accent tone references --chat-accent CSS variable", () => { + const result = chatChipToneClass("accent"); + expect(result).toContain("--chat-accent"); + }); + + it("success tone includes emerald classes", () => { + const result = chatChipToneClass("success"); + expect(result).toContain("emerald"); + }); + + it("warning tone includes amber classes", () => { + const result = chatChipToneClass("warning"); + expect(result).toContain("amber"); + }); + + it("danger tone includes red classes", () => { + const result = chatChipToneClass("danger"); + expect(result).toContain("red"); + }); + + it("info tone includes sky classes", () => { + const result = chatChipToneClass("info"); + expect(result).toContain("sky"); + }); + + it("muted tone includes white opacity classes", () => { + const result = chatChipToneClass("muted"); + expect(result).toContain("white"); + }); +}); + +// --------------------------------------------------------------------------- +// CHAT_SURFACE_ACCENTS +// --------------------------------------------------------------------------- + +describe("CHAT_SURFACE_ACCENTS", () => { + it("has entries for all four chat surface modes", () => { + const modes: ChatSurfaceMode[] = ["standard", "resolver", "mission-thread", "mission-feed"]; + for (const mode of modes) { + expect(CHAT_SURFACE_ACCENTS[mode]).toBeTruthy(); + expect(CHAT_SURFACE_ACCENTS[mode]).toMatch(/^#[0-9A-Fa-f]{6}$/); + } + }); + + it("all accent values are distinct", () => { + const values = Object.values(CHAT_SURFACE_ACCENTS); + const unique = new Set(values); + expect(unique.size).toBe(values.length); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts index e31d29973..5ad50b166 100644 --- a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts +++ b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts @@ -48,6 +48,19 @@ export function chatSurfaceVars(mode: ChatSurfaceMode, accentColor?: string | nu ["--chat-accent-soft" as string]: colorToRgba(accent, 0.14), ["--chat-accent-faint" as string]: colorToRgba(accent, 0.08), ["--chat-accent-glow" as string]: colorToRgba(accent, 0.28), + ["--chat-surface-bg" as string]: "color-mix(in srgb, var(--color-card) 84%, var(--color-bg) 16%)", + ["--chat-surface-raised" as string]: "color-mix(in srgb, var(--color-card) 92%, var(--color-bg) 8%)", + ["--chat-panel-bg" as string]: "color-mix(in srgb, var(--color-surface-raised) 78%, var(--color-card) 22%)", + ["--chat-panel-bg-strong" as string]: "color-mix(in srgb, var(--color-surface-raised) 88%, var(--color-card) 12%)", + ["--chat-card-bg" as string]: "color-mix(in srgb, var(--color-surface-raised) 70%, var(--color-card) 30%)", + ["--chat-card-bg-strong" as string]: "color-mix(in srgb, var(--color-surface-raised) 84%, var(--color-card) 16%)", + ["--chat-panel-border" as string]: "color-mix(in srgb, var(--color-border) 72%, transparent)", + ["--chat-card-border" as string]: "color-mix(in srgb, var(--color-border) 82%, transparent)", + ["--chat-code-bg" as string]: "color-mix(in srgb, var(--color-surface-recessed) 88%, var(--color-bg) 12%)", + ["--chat-code-border" as string]: "color-mix(in srgb, var(--color-border) 72%, transparent)", + ["--chat-code-fg" as string]: "color-mix(in srgb, var(--color-fg) 86%, var(--color-muted-fg) 14%)", + ["--chat-notice-bg" as string]: "color-mix(in srgb, var(--color-surface-recessed) 84%, var(--color-card) 16%)", + ["--chat-notice-border" as string]: "color-mix(in srgb, var(--color-border) 78%, transparent)", }; } diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 0f8bb36f3..a36d8ba60 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -612,3 +612,62 @@ export function groupConsecutiveWorkLogRows( return grouped; } + +export type TurnDividerDataEntry = { + turnId: string; + startTimestamp: string; + endTimestamp?: string; + model?: string; + modelId?: string; + filesChanged: number; + insertions: number; + deletions: number; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + costUsd?: number; + status?: "completed" | "interrupted" | "failed"; +}; + +export function deriveTurnDividerData(events: AgentChatEventEnvelope[]): Map { + const turns = new Map(); + + for (const envelope of events) { + const event = envelope.event; + const turnId = ("turnId" in event && typeof event.turnId === "string") ? event.turnId.trim() : ""; + if (!turnId) continue; + + if (!turns.has(turnId)) { + turns.set(turnId, { + turnId, + startTimestamp: envelope.timestamp, + filesChanged: 0, + insertions: 0, + deletions: 0, + }); + } + const entry = turns.get(turnId)!; + + if (event.type === "file_change" && event.status !== "running") { + entry.filesChanged++; + const stats = summarizeDiffStats(event.diff); + entry.insertions += stats.additions; + entry.deletions += stats.deletions; + } + + if (event.type === "done") { + entry.endTimestamp = envelope.timestamp; + entry.status = event.status; + entry.model = event.model; + entry.modelId = event.modelId; + if (event.usage) { + entry.inputTokens = event.usage.inputTokens ?? undefined; + entry.outputTokens = event.usage.outputTokens ?? undefined; + entry.cacheReadTokens = event.usage.cacheReadTokens ?? undefined; + } + if (event.costUsd != null) entry.costUsd = event.costUsd; + } + } + + return turns; +} diff --git a/apps/desktop/src/renderer/components/chat/hooks/useAgentChatComposerState.ts b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatComposerState.ts new file mode 100644 index 000000000..5cce111d9 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatComposerState.ts @@ -0,0 +1,426 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useChatDraft } from "./useChatDraft"; +import { + createDefaultComputerUsePolicy, + type AgentChatClaudePermissionMode, + type AgentChatCodexApprovalPolicy, + type AgentChatCodexConfigSource, + type AgentChatCodexSandbox, + type AgentChatEventEnvelope, + type AgentChatExecutionMode, + type AgentChatFileRef, + type AgentChatSessionSummary, + type AgentChatUnifiedPermissionMode, + type AiProviderConnectionStatus, + type ChatSurfaceProfile, + type ComputerUsePolicy, +} from "../../../../shared/types"; +import { + getModelById, + isModelProviderGroup, + MODEL_REGISTRY, + resolveModelIdForProvider, +} from "../../../../shared/modelRegistry"; +import { deriveConfiguredModelIds } from "../../../lib/modelOptions"; + +// ── Constants ─────────────────────────────────────────────────────── + +const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; +const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; +const LEGACY_PROVIDER_KEY = "ade.chat.lastProvider"; +const LEGACY_MODEL_KEY_PREFIX = "ade.chat.lastModel"; + +// ── Local helpers ─────────────────────────────────────────────────── + +export type NativeControlState = { + claudePermissionMode: AgentChatClaudePermissionMode; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + unifiedPermissionMode: AgentChatUnifiedPermissionMode; +}; + +export function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState { + if (profile === "persistent_identity") { + return { + claudePermissionMode: "bypassPermissions", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + unifiedPermissionMode: "full-auto", + }; + } + return { + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + unifiedPermissionMode: "edit", + }; +} + +export function summarizeNativeControls( + provider: AgentChatSessionSummary["provider"] | "claude" | "codex" | "unified", + controls: NativeControlState, +): Pick< + AgentChatSessionSummary, + "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "unifiedPermissionMode" +> { + if (provider === "claude") { + return { + claudePermissionMode: controls.claudePermissionMode, + }; + } + if (provider === "codex") { + return { + codexApprovalPolicy: controls.codexApprovalPolicy, + codexSandbox: controls.codexSandbox, + codexConfigSource: controls.codexConfigSource, + }; + } + return { + unifiedPermissionMode: controls.unifiedPermissionMode, + }; +} + +function migrateOldPrefs(): string | null { + try { + const oldProvider = window.localStorage.getItem(LEGACY_PROVIDER_KEY); + const oldModel = oldProvider ? window.localStorage.getItem(`${LEGACY_MODEL_KEY_PREFIX}:${oldProvider}`) : null; + if (oldProvider && oldModel) { + const provider = oldProvider === "codex" || oldProvider === "claude" || oldProvider === "unified" + ? oldProvider + : undefined; + const matchId = resolveModelIdForProvider(oldModel, provider); + const match = matchId ? getModelById(matchId) : undefined; + if (match) { + window.localStorage.setItem(LAST_MODEL_ID_KEY, match.id); + window.localStorage.removeItem(LEGACY_PROVIDER_KEY); + window.localStorage.removeItem(`${LEGACY_MODEL_KEY_PREFIX}:codex`); + window.localStorage.removeItem(`${LEGACY_MODEL_KEY_PREFIX}:claude`); + return match.id; + } + } + } catch { + // ignore + } + return null; +} + +export function readLastUsedModelId(): string | null { + try { + const raw = window.localStorage.getItem(LAST_MODEL_ID_KEY); + if (raw && raw.trim().length) return raw.trim(); + } catch { + // ignore + } + return migrateOldPrefs(); +} + +export function writeLastUsedModelId(modelId: string) { + try { + window.localStorage.setItem(LAST_MODEL_ID_KEY, modelId); + } catch { + // ignore + } +} + +export function readLastUsedReasoningEffort(args: { + laneId: string | null; + modelId: string; +}): string | null { + if (!args.laneId) return null; + try { + const raw = window.localStorage.getItem(`${LAST_REASONING_KEY_PREFIX}:${args.laneId}:${args.modelId}`); + return raw && raw.trim().length ? raw.trim() : null; + } catch { + return null; + } +} + +export function writeLastUsedReasoningEffort(args: { + laneId: string | null; + modelId: string; + effort: string | null; +}) { + if (!args.laneId || !args.modelId.trim().length) return; + try { + const key = `${LAST_REASONING_KEY_PREFIX}:${args.laneId}:${args.modelId}`; + if (!args.effort || !args.effort.trim().length) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, args.effort.trim()); + } catch { + // ignore + } +} + +export function selectReasoningEffort(args: { + tiers: string[]; + preferred: string | null; +}): string | null { + if (!args.tiers.length) return null; + if (args.preferred && args.tiers.includes(args.preferred)) { + return args.preferred; + } + return args.tiers.includes("medium") ? "medium" : args.tiers[0]!; +} + +function resolveRegistryModelId( + value: string | null | undefined, + provider?: "codex" | "claude" | "unified", +): string | null { + return resolveModelIdForProvider(value, provider) ?? null; +} + +function resolveCliRegistryModelId(provider: "codex" | "claude", value: string | null | undefined): string | null { + return resolveModelIdForProvider(value, provider) ?? null; +} + +// ── Hook ──────────────────────────────────────────────────────────── + +export interface UseAgentChatComposerStateArgs { + surfaceProfile: ChatSurfaceProfile; + selectedSession: AgentChatSessionSummary | null; + selectedSessionId: string | null; + selectedSessionModelId: string | null; + selectedEvents: AgentChatEventEnvelope[]; + laneId: string | null; + availableModelIdsOverride?: string[]; +} + +export interface UseAgentChatComposerStateReturn { + modelId: string; + setModelId: React.Dispatch>; + reasoningEffort: string | null; + setReasoningEffort: React.Dispatch>; + executionMode: AgentChatExecutionMode; + setExecutionMode: React.Dispatch>; + claudePermissionMode: AgentChatClaudePermissionMode; + setClaudePermissionMode: React.Dispatch>; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + setCodexApprovalPolicy: React.Dispatch>; + codexSandbox: AgentChatCodexSandbox; + setCodexSandbox: React.Dispatch>; + codexConfigSource: AgentChatCodexConfigSource; + setCodexConfigSource: React.Dispatch>; + unifiedPermissionMode: AgentChatUnifiedPermissionMode; + setUnifiedPermissionMode: React.Dispatch>; + computerUsePolicy: ComputerUsePolicy; + setComputerUsePolicy: React.Dispatch>; + attachments: AgentChatFileRef[]; + setAttachments: React.Dispatch>; + draft: string; + setDraft: (text: string) => void; + clearDraft: () => void; + includeProjectDocs: boolean; + setIncludeProjectDocs: React.Dispatch>; + sendOnEnter: boolean; + setSendOnEnter: React.Dispatch>; + sdkSlashCommands: import("../../../../shared/types").AgentChatSlashCommand[]; + setSdkSlashCommands: React.Dispatch>; + promptSuggestion: string | null; + setPromptSuggestion: React.Dispatch>; + availableModelIds: string[]; + setAvailableModelIds: React.Dispatch>; + providerConnections: { + claude: AiProviderConnectionStatus | null; + codex: AiProviderConnectionStatus | null; + } | null; + preferencesReady: boolean; + setPreferencesReady: React.Dispatch>; + initialNativeControls: NativeControlState; + currentNativeControls: NativeControlState; + syncComposerToSession: (session: AgentChatSessionSummary | null) => void; + refreshAvailableModels: () => Promise; + refreshProviderConnections: () => Promise; + buildNativeControlPayload: (provider: "claude" | "codex" | "unified") => ReturnType; +} + +export function useAgentChatComposerState({ + surfaceProfile, + selectedSession, + selectedSessionId, + selectedSessionModelId, + selectedEvents, + laneId, + availableModelIdsOverride, +}: UseAgentChatComposerStateArgs): UseAgentChatComposerStateReturn { + const initialNativeControls = useMemo(() => defaultNativeControls(surfaceProfile), [surfaceProfile]); + + const [modelId, setModelId] = useState(""); + const [reasoningEffort, setReasoningEffort] = useState(null); + const [executionMode, setExecutionMode] = useState("focused"); + const [availableModelIds, setAvailableModelIds] = useState([]); + const [claudePermissionMode, setClaudePermissionMode] = useState(initialNativeControls.claudePermissionMode); + const [codexApprovalPolicy, setCodexApprovalPolicy] = useState(initialNativeControls.codexApprovalPolicy); + const [codexSandbox, setCodexSandbox] = useState(initialNativeControls.codexSandbox); + const [codexConfigSource, setCodexConfigSource] = useState(initialNativeControls.codexConfigSource); + const [unifiedPermissionMode, setUnifiedPermissionMode] = useState(initialNativeControls.unifiedPermissionMode); + const [computerUsePolicy, setComputerUsePolicy] = useState(createDefaultComputerUsePolicy()); + const [providerConnections, setProviderConnections] = useState<{ + claude: AiProviderConnectionStatus | null; + codex: AiProviderConnectionStatus | null; + } | null>(null); + const [attachments, setAttachments] = useState([]); + const [includeProjectDocs, setIncludeProjectDocs] = useState(false); + const [sdkSlashCommands, setSdkSlashCommands] = useState([]); + const [sendOnEnter, setSendOnEnter] = useState(true); + const { draft, setDraft, clearDraft } = useChatDraft({ sessionId: selectedSessionId, laneId, modelId }); + const [preferencesReady, setPreferencesReady] = useState(false); + const [promptSuggestion, setPromptSuggestion] = useState(null); + + // ── syncComposerToSession ───────────────────────────────────────── + + const syncComposerToSession = useCallback((session: AgentChatSessionSummary | null) => { + if (!session) { + setClaudePermissionMode(initialNativeControls.claudePermissionMode); + setCodexApprovalPolicy(initialNativeControls.codexApprovalPolicy); + setCodexSandbox(initialNativeControls.codexSandbox); + setCodexConfigSource(initialNativeControls.codexConfigSource); + setUnifiedPermissionMode(initialNativeControls.unifiedPermissionMode); + return; + } + const nextModelId = session.modelId + ?? resolveRegistryModelId(session.model, isModelProviderGroup(session.provider) ? session.provider : undefined); + if (nextModelId) { + setModelId(nextModelId); + } + setReasoningEffort(session.reasoningEffort ?? null); + setExecutionMode(session.executionMode ?? "focused"); + setClaudePermissionMode(session.claudePermissionMode ?? initialNativeControls.claudePermissionMode); + setCodexApprovalPolicy(session.codexApprovalPolicy ?? initialNativeControls.codexApprovalPolicy); + setCodexSandbox(session.codexSandbox ?? initialNativeControls.codexSandbox); + setCodexConfigSource(session.codexConfigSource ?? initialNativeControls.codexConfigSource); + setUnifiedPermissionMode(session.unifiedPermissionMode ?? initialNativeControls.unifiedPermissionMode); + setComputerUsePolicy(session.computerUse ?? createDefaultComputerUsePolicy()); + }, [initialNativeControls]); + + // ── refreshAvailableModels ──────────────────────────────────────── + + const refreshAvailableModels = useCallback(async () => { + try { + const status = await window.ade.ai.getStatus(); + const available = deriveConfiguredModelIds(status); + setAvailableModelIds(available); + return available; + } catch { + // Fall back to direct model discovery probes below. + } + + try { + const [codexModels, claudeModels, unifiedModels] = await Promise.all([ + window.ade.agentChat.models({ provider: "codex" }).catch(() => []), + window.ade.agentChat.models({ provider: "claude" }).catch(() => []), + window.ade.agentChat.models({ provider: "unified" }).catch(() => []), + ]); + const available = new Set(); + + for (const model of codexModels) { + const resolved = resolveCliRegistryModelId("codex", model.id); + if (resolved) available.add(resolved); + } + for (const model of claudeModels) { + const resolved = resolveCliRegistryModelId("claude", model.id); + if (resolved) available.add(resolved); + } + for (const model of unifiedModels) { + const resolved = resolveRegistryModelId(model.id, "unified"); + if (resolved) available.add(resolved); + } + + const ordered = MODEL_REGISTRY.filter((model) => !model.deprecated && available.has(model.id)).map((model) => model.id); + setAvailableModelIds(ordered); + return ordered; + } catch { + setAvailableModelIds([]); + return []; + } + }, []); + + // ── refreshProviderConnections ──────────────────────────────────── + + const refreshProviderConnections = useCallback(async () => { + try { + const status = await window.ade.ai.getStatus(); + setProviderConnections({ + claude: status.providerConnections?.claude ?? null, + codex: status.providerConnections?.codex ?? null, + }); + } catch { + setProviderConnections(null); + } + }, []); + + // ── currentNativeControls ───────────────────────────────────────── + + const currentNativeControls = useMemo(() => ({ + claudePermissionMode, + codexApprovalPolicy, + codexSandbox, + codexConfigSource, + unifiedPermissionMode, + }), [ + claudePermissionMode, + codexApprovalPolicy, + codexSandbox, + codexConfigSource, + unifiedPermissionMode, + ]); + + const buildNativeControlPayload = useCallback((provider: "claude" | "codex" | "unified") => { + return summarizeNativeControls(provider, currentNativeControls); + }, [currentNativeControls]); + + // ── Provider connection refresh on session / turn changes ───────── + + useEffect(() => { + void refreshProviderConnections(); + }, [refreshProviderConnections, selectedSession?.provider]); + + return { + modelId, + setModelId, + reasoningEffort, + setReasoningEffort, + executionMode, + setExecutionMode, + claudePermissionMode, + setClaudePermissionMode, + codexApprovalPolicy, + setCodexApprovalPolicy, + codexSandbox, + setCodexSandbox, + codexConfigSource, + setCodexConfigSource, + unifiedPermissionMode, + setUnifiedPermissionMode, + computerUsePolicy, + setComputerUsePolicy, + attachments, + setAttachments, + draft, + setDraft, + clearDraft, + includeProjectDocs, + setIncludeProjectDocs, + sendOnEnter, + setSendOnEnter, + sdkSlashCommands, + setSdkSlashCommands, + promptSuggestion, + setPromptSuggestion, + availableModelIds, + setAvailableModelIds, + providerConnections, + preferencesReady, + setPreferencesReady, + initialNativeControls, + currentNativeControls, + syncComposerToSession, + refreshAvailableModels, + refreshProviderConnections, + buildNativeControlPayload, + }; +} diff --git a/apps/desktop/src/renderer/components/chat/hooks/useAgentChatEvents.ts b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatEvents.ts new file mode 100644 index 000000000..3f8b42624 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatEvents.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { AgentChatEventEnvelope } from "../../../../shared/types"; +import { deriveChatSubagentSnapshots } from "../chatExecutionSummary"; +import { deriveRuntimeState } from "./useDeriveRuntimeState"; +import type { DerivedPendingInput } from "../pendingInput"; + +// ── Hook ──────────────────────────────────────────────────────────── + +export interface UseAgentChatEventsArgs { + selectedSessionId: string | null; +} + +export interface UseAgentChatEventsReturn { + selectedEvents: AgentChatEventEnvelope[]; + turnActive: boolean; + pendingInput: DerivedPendingInput | null; + selectedSubagentSnapshots: ReturnType; + eventsBySession: Record; + turnActiveBySession: Record; + pendingInputsBySession: Record; + flushQueuedEvents: () => void; + scheduleQueuedEventFlush: () => void; + setEventsBySession: React.Dispatch>>; + setTurnActiveBySession: React.Dispatch>>; + setPendingInputsBySession: React.Dispatch>>; + eventsBySessionRef: React.MutableRefObject>; + pendingEventQueueRef: React.MutableRefObject; + eventFlushTimerRef: React.MutableRefObject; +} + +export function useAgentChatEvents({ + selectedSessionId, +}: UseAgentChatEventsArgs): UseAgentChatEventsReturn { + const [eventsBySession, setEventsBySession] = useState>({}); + const [turnActiveBySession, setTurnActiveBySession] = useState>({}); + const [pendingInputsBySession, setPendingInputsBySession] = useState>({}); + + const eventsBySessionRef = useRef>({}); + const pendingEventQueueRef = useRef([]); + const eventFlushTimerRef = useRef(null); + + // ── Derived values ──────────────────────────────────────────────── + + const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? [] : []; + const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); + const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; + const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; + + // ── Flush queued events ─────────────────────────────────────────── + + const flushQueuedEvents = useCallback(() => { + const queued = pendingEventQueueRef.current; + if (!queued.length) return; + pendingEventQueueRef.current = []; + + let next = eventsBySessionRef.current; + const touchedSessionIds = new Set(); + + for (const envelope of queued) { + const sessionId = envelope.sessionId; + const sessionEvents = next === eventsBySessionRef.current + ? (eventsBySessionRef.current[sessionId] ?? []) + : (next[sessionId] ?? []); + const updated = [...sessionEvents, envelope]; + if (next === eventsBySessionRef.current) { + next = { ...eventsBySessionRef.current }; + } + next[sessionId] = updated; + touchedSessionIds.add(sessionId); + } + + if (!touchedSessionIds.size) return; + + eventsBySessionRef.current = next; + + const activePatch: Record = {}; + const pendingInputPatch: Record = {}; + for (const sessionId of touchedSessionIds) { + const derived = deriveRuntimeState(next[sessionId] ?? []); + activePatch[sessionId] = derived.turnActive; + pendingInputPatch[sessionId] = derived.pendingInputs; + } + + setEventsBySession(next); + setTurnActiveBySession((activePrev) => ({ ...activePrev, ...activePatch })); + setPendingInputsBySession((pendingPrev) => ({ ...pendingPrev, ...pendingInputPatch })); + }, []); + + const scheduleQueuedEventFlush = useCallback(() => { + if (eventFlushTimerRef.current != null) return; + eventFlushTimerRef.current = window.setTimeout(() => { + eventFlushTimerRef.current = null; + flushQueuedEvents(); + }, 16); + }, [flushQueuedEvents]); + + // ── Timer cleanup on unmount ────────────────────────────────────── + + useEffect(() => { + return () => { + if (eventFlushTimerRef.current !== null) { + window.clearTimeout(eventFlushTimerRef.current); + eventFlushTimerRef.current = null; + } + pendingEventQueueRef.current = []; + }; + }, []); + + return { + selectedEvents, + turnActive, + pendingInput, + selectedSubagentSnapshots, + eventsBySession, + turnActiveBySession, + pendingInputsBySession, + flushQueuedEvents, + scheduleQueuedEventFlush, + setEventsBySession, + setTurnActiveBySession, + setPendingInputsBySession, + eventsBySessionRef, + pendingEventQueueRef, + eventFlushTimerRef, + }; +} diff --git a/apps/desktop/src/renderer/components/chat/hooks/useAgentChatSessions.ts b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatSessions.ts new file mode 100644 index 000000000..b6fc22a15 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatSessions.ts @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { AgentChatSessionSummary } from "../../../../shared/types"; +import { + getModelById, + isModelProviderGroup, + resolveModelIdForProvider, +} from "../../../../shared/modelRegistry"; +import { isChatToolType } from "../../../lib/sessions"; +import { parseAgentChatTranscript } from "../../../../shared/chatTranscript"; +import type { AgentChatEventEnvelope } from "../../../../shared/types"; +import { deriveRuntimeState } from "./useDeriveRuntimeState"; +import type { DerivedPendingInput } from "../pendingInput"; + +// ── Helpers ───────────────────────────────────────────────────────── + +export function byStartedDesc(a: AgentChatSessionSummary, b: AgentChatSessionSummary): number { + return Date.parse(b.startedAt) - Date.parse(a.startedAt); +} + +export function resolveNextSelectedSessionId(args: { + rows: AgentChatSessionSummary[]; + current: string | null; + pendingSelectedSessionId: string | null; + optimisticSessionIds: Set; + draftSelectionLocked: boolean; + forceDraft: boolean; + preferDraftStart: boolean; +}): string | null { + const { + rows, + current, + pendingSelectedSessionId, + optimisticSessionIds, + draftSelectionLocked, + forceDraft, + preferDraftStart, + } = args; + + if (pendingSelectedSessionId) { + const pendingIsPersisted = rows.some((row) => row.sessionId === pendingSelectedSessionId); + if (pendingIsPersisted) return pendingSelectedSessionId; + if (current === pendingSelectedSessionId || optimisticSessionIds.has(pendingSelectedSessionId)) { + return pendingSelectedSessionId; + } + } + + if (!current && (draftSelectionLocked || forceDraft || preferDraftStart)) { + return null; + } + if (current && rows.some((row) => row.sessionId === current)) { + return current; + } + if (current && optimisticSessionIds.has(current)) { + return current; + } + return rows[0]?.sessionId ?? null; +} + +function resolveRegistryModelId( + value: string | null | undefined, + provider?: "codex" | "claude" | "unified", +): string | null { + return resolveModelIdForProvider(value, provider) ?? null; +} + +// ── Hook ──────────────────────────────────────────────────────────── + +export interface UseAgentChatSessionsArgs { + laneId: string | null; + lockSessionId?: string | null; + initialSessionId?: string | null; + initialSessionSummary?: AgentChatSessionSummary | null; + forceNewSession?: boolean; + forceDraftMode?: boolean; + lockedSingleSessionMode: boolean; + /** Refs / setters for event state that loadHistory needs to update */ + eventsBySessionRef: React.MutableRefObject>; + setEventsBySession: React.Dispatch>>; + setTurnActiveBySession: React.Dispatch>>; + setPendingInputsBySession: React.Dispatch>>; +} + +export interface UseAgentChatSessionsReturn { + sessions: AgentChatSessionSummary[]; + setSessions: React.Dispatch>; + selectedSessionId: string | null; + setSelectedSessionId: React.Dispatch>; + selectedSession: AgentChatSessionSummary | null; + selectedSessionModelId: string | null; + refreshSessions: () => Promise; + loadHistory: (sessionId: string) => Promise; + optimisticSessionIdsRef: React.MutableRefObject>; + pendingSelectedSessionIdRef: React.MutableRefObject; + draftSelectionLockedRef: React.MutableRefObject; + knownSessionIdsRef: React.MutableRefObject>; + loadedHistoryRef: React.MutableRefObject>; + refreshSessionsTimerRef: React.MutableRefObject; + scheduleSessionsRefresh: () => void; +} + +export function useAgentChatSessions({ + laneId, + lockSessionId, + initialSessionId, + initialSessionSummary, + forceNewSession = false, + forceDraftMode = false, + lockedSingleSessionMode, + eventsBySessionRef, + setEventsBySession, + setTurnActiveBySession, + setPendingInputsBySession, +}: UseAgentChatSessionsArgs): UseAgentChatSessionsReturn { + const forceDraft = forceDraftMode || forceNewSession; + const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession; + + const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(lockSessionId ?? initialSessionId ?? null); + + const optimisticSessionIdsRef = useRef>(new Set()); + const pendingSelectedSessionIdRef = useRef(null); + const draftSelectionLockedRef = useRef(false); + const knownSessionIdsRef = useRef>(new Set()); + const loadedHistoryRef = useRef>(new Set()); + const refreshSessionsTimerRef = useRef(null); + const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); + const selectedSessionIdRef = useRef(selectedSessionId); + + const selectedSession = useMemo( + () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), + [sessions, selectedSessionId], + ); + + const selectedSessionModelId = useMemo(() => { + if (!selectedSession) return null; + return selectedSession.modelId + ?? resolveRegistryModelId(selectedSession.model, isModelProviderGroup(selectedSession.provider) ? selectedSession.provider : undefined); + }, [selectedSession]); + + // ── refreshSessions ─────────────────────────────────────────────── + + const refreshSessions = useCallback(async () => { + if (!laneId) { + setSessions([]); + return; + } + + const rows = await window.ade.agentChat.list({ laneId }); + rows.sort(byStartedDesc); + setSessions(rows); + for (const row of rows) { + optimisticSessionIdsRef.current.delete(row.sessionId); + } + + if (lockSessionId) { + draftSelectionLockedRef.current = false; + setSelectedSessionId(lockSessionId); + return; + } + + setSelectedSessionId((current) => { + const pendingSelectedSessionId = pendingSelectedSessionIdRef.current; + const nextSelectedSessionId = resolveNextSelectedSessionId({ + rows, + current, + pendingSelectedSessionId, + optimisticSessionIds: optimisticSessionIdsRef.current, + draftSelectionLocked: draftSelectionLockedRef.current, + forceDraft, + preferDraftStart, + }); + if (pendingSelectedSessionId && rows.some((row) => row.sessionId === pendingSelectedSessionId)) { + pendingSelectedSessionIdRef.current = null; + } + return nextSelectedSessionId; + }); + }, [forceDraft, laneId, lockSessionId, preferDraftStart]); + + // ── scheduleSessionsRefresh ─────────────────────────────────────── + + const scheduleSessionsRefresh = useCallback(() => { + if (refreshSessionsTimerRef.current != null) return; + refreshSessionsTimerRef.current = window.setTimeout(() => { + refreshSessionsTimerRef.current = null; + void refreshSessions().catch(() => {}); + }, 120); + }, [refreshSessions]); + + // ── loadHistory ─────────────────────────────────────────────────── + + const loadHistory = useCallback(async (sessionId: string) => { + if (loadedHistoryRef.current.has(sessionId)) return; + loadedHistoryRef.current.add(sessionId); + + try { + const summary = await window.ade.sessions.get(sessionId); + if (!summary || !isChatToolType(summary.toolType)) return; + const raw = await window.ade.sessions.readTranscriptTail({ + sessionId, + maxBytes: 1_800_000, + raw: true, + }); + const parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + + const existing = eventsBySessionRef.current[sessionId] ?? []; + let merged: AgentChatEventEnvelope[]; + if (existing.length && parsed.length) { + const lastParsedTs = parsed[parsed.length - 1]!.timestamp; + const tail = existing.filter((e) => e.timestamp > lastParsedTs); + merged = tail.length ? [...parsed, ...tail] : parsed; + } else if (existing.length) { + merged = existing; + } else { + merged = parsed; + } + + const derived = deriveRuntimeState(merged); + eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; + setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: derived.turnActive })); + setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); + } catch { + // Ignore transcript history failures. + } + }, [eventsBySessionRef, setEventsBySession, setTurnActiveBySession, setPendingInputsBySession]); + + // ── Side effects ────────────────────────────────────────────────── + + // Keep selectedSessionIdRef in sync + useEffect(() => { + selectedSessionIdRef.current = selectedSessionId; + }, [selectedSessionId]); + + // Track known session IDs + useEffect(() => { + const next = new Set(); + for (const session of sessions) next.add(session.sessionId); + if (selectedSessionId) next.add(selectedSessionId); + if (lockSessionId) next.add(lockSessionId); + if (initialSessionId) next.add(initialSessionId); + for (const sessionId of optimisticSessionIdsRef.current) next.add(sessionId); + knownSessionIdsRef.current = next; + }, [initialSessionId, lockSessionId, selectedSessionId, sessions]); + + // Lock session when lockSessionId changes + useEffect(() => { + if (lockSessionId) { + pendingSelectedSessionIdRef.current = null; + draftSelectionLockedRef.current = false; + setSelectedSessionId(lockSessionId); + } + }, [lockSessionId]); + + // Locked single session mode initialization + useEffect(() => { + if (!lockedSingleSessionMode || !lockSessionId || !initialSessionSummary) return; + setSessions([initialSessionSummary]); + draftSelectionLockedRef.current = false; + setSelectedSessionId(lockSessionId); + }, [initialSessionSummary, lockSessionId, lockedSingleSessionMode]); + + // Apply new initialSessionId + useEffect(() => { + const nextInitialSessionId = initialSessionId ?? null; + if (!nextInitialSessionId) { + appliedInitialSessionIdRef.current = null; + return; + } + if (lockSessionId) return; + if (appliedInitialSessionIdRef.current === nextInitialSessionId) return; + appliedInitialSessionIdRef.current = nextInitialSessionId; + pendingSelectedSessionIdRef.current = null; + draftSelectionLockedRef.current = false; + setSelectedSessionId(nextInitialSessionId); + }, [initialSessionId, lockSessionId]); + + // Reset on laneId / force changes + useEffect(() => { + draftSelectionLockedRef.current = false; + optimisticSessionIdsRef.current.clear(); + pendingSelectedSessionIdRef.current = null; + appliedInitialSessionIdRef.current = initialSessionId ?? null; + if (forceDraft && !lockSessionId) { + draftSelectionLockedRef.current = true; + setSelectedSessionId(null); + } + }, [forceDraft, laneId, lockSessionId]); + + // Force draft mode + useEffect(() => { + if (!forceDraft || lockSessionId) return; + pendingSelectedSessionIdRef.current = null; + draftSelectionLockedRef.current = true; + setSelectedSessionId(null); + }, [forceDraft, lockSessionId]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (refreshSessionsTimerRef.current !== null) { + window.clearTimeout(refreshSessionsTimerRef.current); + refreshSessionsTimerRef.current = null; + } + }; + }, []); + + return { + sessions, + setSessions, + selectedSessionId, + setSelectedSessionId, + selectedSession, + selectedSessionModelId, + refreshSessions, + loadHistory, + optimisticSessionIdsRef, + pendingSelectedSessionIdRef, + draftSelectionLockedRef, + knownSessionIdsRef, + loadedHistoryRef, + refreshSessionsTimerRef, + scheduleSessionsRefresh, + }; +} diff --git a/apps/desktop/src/renderer/components/chat/hooks/useChatDraft.ts b/apps/desktop/src/renderer/components/chat/hooks/useChatDraft.ts new file mode 100644 index 000000000..70e95fe0e --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useChatDraft.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { loadDraft, saveDraft, removeDraft } from "./useChatDraftStore"; + +/** + * Manages a persisted draft for a chat session. + * Debounces writes to localStorage to avoid thrashing. + */ +export function useChatDraft(args: { + sessionId: string | null; + laneId: string | null; + modelId?: string; +}) { + const { sessionId, laneId, modelId } = args; + // Draft key: use sessionId if we have an active session, otherwise "draft:" + const draftKey = sessionId ?? (laneId ? `draft:${laneId}` : ""); + + const [draft, setDraftState] = useState(""); + const saveTimerRef = useRef(null); + const prevKeyRef = useRef(draftKey); + + // Load draft when key changes + useEffect(() => { + if (prevKeyRef.current !== draftKey) { + // Save the old draft before switching + // (the debounce timer might not have fired yet) + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + // Load new draft + const entry = loadDraft(draftKey); + setDraftState(entry?.text ?? ""); + prevKeyRef.current = draftKey; + } + }, [draftKey]); + + const setDraft = useCallback( + (text: string) => { + setDraftState(text); + // Debounced save + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = window.setTimeout(() => { + saveDraft(draftKey, text, modelId); + saveTimerRef.current = null; + }, 300); + }, + [draftKey, modelId], + ); + + const clearDraft = useCallback(() => { + setDraftState(""); + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + removeDraft(draftKey); + }, [draftKey]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + } + }; + }, []); + + return { draft, setDraft, clearDraft }; +} diff --git a/apps/desktop/src/renderer/components/chat/hooks/useChatDraftStore.ts b/apps/desktop/src/renderer/components/chat/hooks/useChatDraftStore.ts new file mode 100644 index 000000000..02c6cd51b --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useChatDraftStore.ts @@ -0,0 +1,60 @@ +const DRAFT_STORAGE_KEY = "ade.chat.drafts"; +const MAX_DRAFTS = 50; +const MAX_DRAFT_LENGTH = 10_000; + +type DraftEntry = { + text: string; + modelId?: string; + updatedAt: number; +}; + +type DraftStore = Record; // keyed by sessionId or "draft:" + +function readDrafts(): DraftStore { + try { + const raw = localStorage.getItem(DRAFT_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function writeDrafts(store: DraftStore) { + try { + // Evict oldest entries if over MAX_DRAFTS + const entries = Object.entries(store).sort( + (a, b) => b[1].updatedAt - a[1].updatedAt, + ); + const trimmed = Object.fromEntries(entries.slice(0, MAX_DRAFTS)); + localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(trimmed)); + } catch { + // Ignore quota errors + } +} + +export function saveDraft(key: string, text: string, modelId?: string) { + if (!key || !text.trim()) { + removeDraft(key); + return; + } + const store = readDrafts(); + store[key] = { + text: text.slice(0, MAX_DRAFT_LENGTH), + modelId, + updatedAt: Date.now(), + }; + writeDrafts(store); +} + +export function loadDraft(key: string): DraftEntry | null { + if (!key) return null; + const store = readDrafts(); + return store[key] ?? null; +} + +export function removeDraft(key: string) { + if (!key) return; + const store = readDrafts(); + delete store[key]; + writeDrafts(store); +} diff --git a/apps/desktop/src/renderer/components/chat/hooks/useDeriveRuntimeState.ts b/apps/desktop/src/renderer/components/chat/hooks/useDeriveRuntimeState.ts new file mode 100644 index 000000000..5609d875e --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useDeriveRuntimeState.ts @@ -0,0 +1,86 @@ +import { useRef } from "react"; +import type { AgentChatEventEnvelope } from "../../../../shared/types"; +import { derivePendingInputRequests, type DerivedPendingInput } from "../pendingInput"; + +export interface DerivedRuntimeState { + turnActive: boolean; + pendingInputs: DerivedPendingInput[]; +} + +/** + * Incrementally derives runtime state (turnActive + pendingInputs) from an + * event list. Instead of re-scanning every event on each call, we track the + * last processed index and only walk new events for the `turnActive` flag. + * + * `pendingInputs` still delegates to `derivePendingInputRequests` because that + * function maintains a Map with deletions (tool_result clearing a prior + * approval_request) which isn't easily incrementalizable without duplicating + * the full logic. + */ +export function useDeriveRuntimeState() { + const lastIndexRef = useRef(0); + const stateRef = useRef<{ turnActive: boolean }>({ turnActive: false }); + + function deriveRuntimeState(events: AgentChatEventEnvelope[]): DerivedRuntimeState { + // Walk only the new events for turnActive + const start = lastIndexRef.current; + let { turnActive } = stateRef.current; + + for (let i = start; i < events.length; i++) { + const event = events[i]!.event; + + if (event.type === "status") { + turnActive = event.turnStatus === "started"; + continue; + } + + if (event.type === "done") { + turnActive = false; + continue; + } + } + + lastIndexRef.current = events.length; + stateRef.current = { turnActive }; + + return { + turnActive, + pendingInputs: derivePendingInputRequests(events), + }; + } + + /** Reset tracking so the next call re-scans from scratch. */ + function resetDeriveState() { + lastIndexRef.current = 0; + stateRef.current = { turnActive: false }; + } + + return { deriveRuntimeState, resetDeriveState }; +} + +/** + * Standalone (non-hook) version used by flushQueuedEvents where we need to + * derive state for arbitrary session event lists without React hook rules. + */ +export function deriveRuntimeState(events: AgentChatEventEnvelope[]): DerivedRuntimeState { + let turnActive = false; + + for (const envelope of events) { + const event = envelope.event; + + if (event.type === "status") { + turnActive = event.turnStatus === "started"; + continue; + } + + if (event.type === "done") { + turnActive = false; + continue; + } + } + + return { + turnActive, + pendingInputs: derivePendingInputRequests(events), + }; +} diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index f29228ded..db5b3908b 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -304,8 +304,8 @@ export function CtoPage() { let cancelled = false; setLoading(true); setError(null); const promise = selectedAgentId - ? window.ade.cto.ensureAgentSession({ agentId: selectedAgentId, laneId, permissionMode: "full-auto" }) - : window.ade.cto.ensureSession({ laneId, permissionMode: "full-auto" }); + ? window.ade.cto.ensureAgentSession({ agentId: selectedAgentId, laneId }) + : window.ade.cto.ensureSession({ laneId }); void promise .then((next) => { if (!cancelled) setSession(next); }) .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); setSession(null); } }) @@ -342,7 +342,7 @@ export function CtoPage() { if (!window.ade?.cto || !laneId || showOnboarding || needsOnboarding) { return null; } - const next = await window.ade.cto.ensureSession({ laneId, permissionMode: "full-auto" }); + const next = await window.ade.cto.ensureSession({ laneId }); if (!selectedAgentId) { setSession(next); } @@ -541,7 +541,6 @@ export function CtoPage() { goal: null, reasoningEffort: session.reasoningEffort ?? null, executionMode: session.executionMode ?? null, - permissionMode: session.permissionMode, identityKey: session.identityKey, capabilityMode: session.capabilityMode, computerUse: session.computerUse, diff --git a/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx index 40f25d1ee..73f09240e 100644 --- a/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx @@ -66,7 +66,10 @@ export function AttachLaneDialog({ disabled={busy} /> -
+

+ Enter the absolute path to an existing Git worktree directory. +

+
Example: /Users/you/repo-worktrees/feature-auth
diff --git a/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx b/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx index e788d76f0..f74f14be7 100644 --- a/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx +++ b/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx @@ -84,10 +84,17 @@ export function CommitTimeline({ const el = scrollRef.current; if (!el) return; if (!didInitialScrollRef.current && commits.length > 0) { - el.scrollTop = el.scrollHeight; + // Scroll to the selected commit if present, otherwise show most recent + const selectedIdx = selectedSha ? commits.findIndex((c) => c.sha === selectedSha) : -1; + if (selectedIdx >= 0) { + const rows = el.querySelectorAll("button"); + rows[selectedIdx]?.scrollIntoView({ block: "center" }); + } else { + el.scrollTop = 0; + } didInitialScrollRef.current = true; } - }, [commits]); + }, [commits, selectedSha]); const ensureMeta = React.useCallback( async (sha: string) => { @@ -167,7 +174,6 @@ export function CommitTimeline({ const isNewest = idx === commits.length - 1; const isSelected = selectedSha === commit.sha; const isMerge = commit.parents.length > 1; - const isLast = idx === commits.length - 1; const dotColor = isNewest ? COLORS.success : isMerge ? COLORS.info : COLORS.outlineBorder; const dotBg = isNewest ? COLORS.success : isMerge ? "transparent" : COLORS.pageBg; @@ -246,7 +252,7 @@ export function CommitTimeline({
{/* Arrow connector */} - {!isLast ? ( + {!isNewest ? (
diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx index 722686acc..ea502c240 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx @@ -30,6 +30,7 @@ const menuHeaderStyle: React.CSSProperties = { function HoverButton({ style, children, onClick }: { style: React.CSSProperties; children: React.ReactNode; onClick: () => void }) { return (
e.stopPropagation()} > diff --git a/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx index cb188c4c4..6fe801be9 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx @@ -12,6 +12,21 @@ function normalizePath(pathValue: string): string { return pathValue.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); } +function DiffFailedRetry({ onRetry }: { onRetry: () => void }) { + return ( +
+ Failed to load diff + +
+ ); +} + export function LaneDiffPane({ laneId, selectedPath, @@ -29,17 +44,21 @@ export function LaneDiffPane({ const diffRef = useRef(null); const [diff, setDiff] = useState(null); + const [diffFailed, setDiffFailed] = useState(false); const [commitFiles, setCommitFiles] = useState([]); const [selectedCommitFilePath, setSelectedCommitFilePath] = useState(null); const [commitDiff, setCommitDiff] = useState(null); + const [commitDiffFailed, setCommitDiffFailed] = useState(false); const [busyAction, setBusyAction] = useState(null); const refreshWorkingDiff = React.useCallback(() => { if (!laneId || !selectedPath || !selectedFileMode) { setDiff(null); + setDiffFailed(false); return Promise.resolve(); } + setDiffFailed(false); return window.ade.diff .getFile({ laneId, path: selectedPath, mode: selectedFileMode }) .then((value) => { @@ -47,11 +66,13 @@ export function LaneDiffPane({ }) .catch(() => { setDiff(null); + setDiffFailed(true); }); }, [laneId, selectedPath, selectedFileMode]); useEffect(() => { setDiff(null); + setDiffFailed(false); if (!laneId || !selectedPath || !selectedFileMode) return; void refreshWorkingDiff(); }, [laneId, selectedPath, selectedFileMode, refreshWorkingDiff]); @@ -140,10 +161,10 @@ export function LaneDiffPane({ }; }, [laneId, selectedCommit]); - useEffect(() => { + const refreshCommitDiff = React.useCallback(() => { setCommitDiff(null); + setCommitDiffFailed(false); if (!laneId || !selectedCommit || !selectedCommitFilePath) return; - let cancelled = false; window.ade.diff .getFile({ laneId, @@ -153,16 +174,18 @@ export function LaneDiffPane({ compareTo: "parent" }) .then((value) => { - if (!cancelled) setCommitDiff(value); + setCommitDiff(value); }) .catch(() => { - if (!cancelled) setCommitDiff(null); + setCommitDiff(null); + setCommitDiffFailed(true); }); - return () => { - cancelled = true; - }; }, [laneId, selectedCommit, selectedCommitFilePath]); + useEffect(() => { + refreshCommitDiff(); + }, [refreshCommitDiff]); + // Commit diff view if (selectedCommit && laneId) { return ( @@ -243,6 +266,8 @@ export function LaneDiffPane({
+ ) : commitDiffFailed ? ( + ) : !commitDiff ? (
Loading diff...
) : ( @@ -292,6 +317,7 @@ export function LaneDiffPane({ {selectedFileMode === "unstaged" ? (
- Rebase this lane onto {s.baseLabel?.trim() || "its parent"} to pick up new commits. + Rebase this lane onto {s.baseLabel?.trim() || "parent branch"} to pick up new commits.
-
+
diff --git a/apps/desktop/src/renderer/components/lanes/LaneRow.tsx b/apps/desktop/src/renderer/components/lanes/LaneRow.tsx index 864e8d012..166c64ec0 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneRow.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneRow.tsx @@ -7,14 +7,7 @@ import { cn } from "../ui/cn"; import { useNavigate } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; import { MergeSimulationPanel } from "./mergeSimulation/MergeSimulationPanel"; - -function conflictDotClass(status: ConflictStatus["status"] | null | undefined): string { - if (status === "conflict-active") return "bg-red-600"; - if (status === "conflict-predicted") return "bg-orange-500"; - if (status === "behind-base") return "bg-amber-500"; - if (status === "merge-ready") return "bg-emerald-500"; - return "bg-muted-fg"; -} +import { conflictDotClass } from "./laneUtils"; function conflictSeverity(status: ConflictStatus["status"] | null | undefined): number { if (status === "conflict-active") return 5; diff --git a/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx index 0a25c71f1..11c1df6ae 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx @@ -8,7 +8,7 @@ import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton } from "./lane const TREE_ROW_H = 28; const TREE_INDENT = 22; const TREE_LEFT_PAD = 16; -const TREE_DOT_R = 4; +const TREE_DOT_R = 5; type TreeNodeLayout = { lane: LaneSummary; @@ -168,7 +168,7 @@ function StackGraph({ y1={parent.dotY + TREE_DOT_R + 2} x2={parent.dotX} y2={lastChild.dotY} - stroke="rgba(167,139,250,0.18)" + stroke="rgba(167,139,250,0.35)" strokeWidth={1.5} /> ); @@ -181,7 +181,7 @@ function StackGraph({ y1={child.dotY} x2={child.dotX - TREE_DOT_R - 3} y2={child.dotY} - stroke="rgba(167,139,250,0.18)" + stroke="rgba(167,139,250,0.35)" strokeWidth={1.5} /> ); diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index 612d731e5..2ab94498d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -255,7 +255,7 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string
{laneName ?? laneId}
{runningSessions.length} running - {!launchTracked ? no context : null} + {!launchTracked ? Standalone : null}
@@ -296,14 +296,14 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string persistLaunchTracked(next); }} > - {launchTracked ? "tracked" : "no ctx"} + {launchTracked ? "With context" : "Standalone"}
{sessions.length === 0 ? (
- +
) : viewMode === "tabs" ? ( {sessionTabLabel(s)} - {!s.tracked ? no ctx : null} + {!s.tracked ? Standalone : null} {s.status === "running" && s.ptyId ? (
{primarySessionLabel(current)}
- {!current.tracked ? no context : null} + {!current.tracked ? Standalone : null}
{new Date(current.startedAt).toLocaleString()}
diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index cbc066593..8fa9b2079 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -1,7 +1,7 @@ import { ChatCircleText, Command, Terminal } from "@phosphor-icons/react"; import type { WorkDraftKind } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; -import { SANS_FONT } from "./laneDesignTokens"; +import { COLORS, SANS_FONT, SPACING } from "./laneDesignTokens"; import { WorkViewArea } from "../terminals/WorkViewArea"; import { useLaneWorkSessions } from "./useLaneWorkSessions"; @@ -11,9 +11,9 @@ const ENTRY_OPTIONS: Array<{ icon: typeof ChatCircleText; color: string; }> = [ - { kind: "chat", label: "New Chat", icon: ChatCircleText, color: "#8B5CF6" }, - { kind: "cli", label: "CLI Tool", icon: Command, color: "#F97316" }, - { kind: "shell", label: "New Shell", icon: Terminal, color: "#22C55E" }, + { kind: "chat", label: "New Chat", icon: ChatCircleText, color: COLORS.entryChat }, + { kind: "cli", label: "CLI Tool", icon: Command, color: COLORS.entryCli }, + { kind: "shell", label: "New Shell", icon: Terminal, color: COLORS.entryShell }, ]; export function LaneWorkPane({ @@ -48,7 +48,7 @@ export function LaneWorkPane({ style={{ display: "inline-flex", alignItems: "center", - gap: 5, + gap: SPACING.xs, padding: "5px 10px", border: active ? `1px solid ${entry.color}20` : "1px solid transparent", borderRadius: 8, diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 45173a344..04209d001 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -3,7 +3,7 @@ import { useClickOutside } from "../../hooks/useClickOutside"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Group, Panel } from "react-resizable-panels"; import { Check, CaretDown, FileCode, GitBranch, House, Stack, Link, ArrowsOutSimple, ArrowsInSimple, PushPin, Plus, MagnifyingGlass, Terminal, X, ArrowSquareOut, Info } from "@phosphor-icons/react"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, type LaneInspectorTab } from "../../state/appStore"; import { buildIntegrationSourcesByLaneId } from "../../lib/integrationLanes"; import { EmptyState } from "../ui/EmptyState"; import { Button } from "../ui/Button"; @@ -90,6 +90,8 @@ export function LanesPage() { const focusSession = useAppStore((s) => s.focusSession); const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); + const setLaneInspectorTab = useAppStore((s) => s.setLaneInspectorTab); + const clearLaneInspectorTab = useAppStore((s) => s.clearLaneInspectorTab); const keybindings = useAppStore((s) => s.keybindings); const project = useAppStore((s) => s.project); @@ -419,14 +421,18 @@ export function LanesPage() { useEffect(() => { const laneId = params.get("laneId"); const sessionId = params.get("sessionId"); + const inspectorTabParam = params.get("inspectorTab"); if (laneId) { selectLane(laneId); if (params.get("focus") === "single") { setActiveLaneIds([laneId]); } + if (inspectorTabParam) { + setLaneInspectorTab(laneId, inspectorTabParam as LaneInspectorTab); + } } if (sessionId) focusSession(sessionId); - }, [params, selectLane, focusSession]); + }, [params, selectLane, focusSession, setLaneInspectorTab]); useEffect(() => { void loadConflictStatuses(); }, [loadConflictStatuses, lanes.length]); @@ -817,6 +823,10 @@ export function LanesPage() { const deletedIds = new Set(actionable.map((l) => l.id)); if (deletedIds.has(selectedLaneId ?? "")) selectLane(null); setActiveLaneIds((prev) => prev.filter((id) => !deletedIds.has(id))); + // Clean up per-lane inspector tab preferences + for (const id of deletedIds) { + clearLaneInspectorTab(id); + } }); }; @@ -936,7 +946,12 @@ export function LanesPage() { } } - await Promise.all([refreshLanes(), refreshRebaseSuggestions(), refreshAutoRebaseStatuses()]); + const results = await Promise.allSettled([refreshLanes(), refreshRebaseSuggestions(), refreshAutoRebaseStatuses()]); + for (const r of results) { + if (r.status === "rejected") { + console.error("Lane refresh partially failed:", r.reason); + } + } } catch (err) { const message = err instanceof Error ? err.message : String(err); setRebaseSuggestionError(message); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index e02359478..70c79649f 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -49,9 +49,9 @@ export function ManageLaneDialog({ const hasAnyDirty = lanes.some((l) => l.status.dirty); const isAttached = !isBatch && lanes[0]?.laneType === "attached"; - const worktreeDeleteLabel = hasAttached ? "Detach only" : "Worktree only"; - const localDeleteLabel = hasAttached ? "Detach + local branch" : "+ local branch"; - const remoteDeleteLabel = hasAttached ? "Detach + local + remote" : "+ local + remote"; + const worktreeDeleteLabel = hasAttached ? "Unlink lane (keep branch)" : "Remove worktree files only"; + const localDeleteLabel = hasAttached ? "Unlink + delete local branch" : "+ local branch"; + const remoteDeleteLabel = hasAttached ? "Unlink + delete local and remote branch" : "Delete local and remote branch"; const confirmMatch = deleteConfirmText.trim().toLowerCase() === deletePhrase.toLowerCase(); return ( @@ -195,10 +195,13 @@ export function ManageLaneDialog({ )} {/* Force delete */} -