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..e0a80aa64 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,14 @@ app.whenReady().then(async () => { memoryService, }); ctoStateServiceRef = ctoStateService; + try { + memoryFilesService.sync(); + } catch (err) { + logger.warn("memory_files.sync_failed", { + projectRoot, + error: err instanceof Error ? err.message : String(err), + }); + } const workerAgentService = createWorkerAgentService({ db, @@ -1213,6 +1240,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..71f8152a1 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,48 @@ 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 => { + if (attachmentCount > 0) return true; + 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 +1524,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 +1579,275 @@ 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}`; + }; + + /** Reject content that looks like it contains secrets or PII. */ + const AUTO_MEMORY_SECRET_PII_RE = new RegExp( + [ + // API keys / tokens (generic key-like hex/base64 strings after common prefixes) + /(?:api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|bearer)\s*[:=]\s*\S{8,}/i.source, + // Passwords / secrets in assignment form + /(?:password|passwd|pwd|secret)\s*[:=]\s*\S{4,}/i.source, + // AWS-style keys + /\bAKIA[0-9A-Z]{16}\b/.source, + // GitHub / GitLab personal access tokens + /\b(?:ghp|gho|ghu|ghs|ghr|glpat)[_-][A-Za-z0-9]{16,}\b/.source, + // Slack tokens + /\bxox[bpras]-[A-Za-z0-9\-]{10,}\b/.source, + // Email addresses (PII) + /\b[A-Za-z0-9._%+\-]{2,}@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/.source, + // US Social Security Numbers + /\b\d{3}[- ]?\d{2}[- ]?\d{4}\b/.source, + // Credit card numbers (13-19 digits, optionally separated) + /\b(?:\d[ -]?){13,19}\b/.source, + // Private keys / certificates + /-----BEGIN\s+(?:RSA\s+)?(?:PRIVATE\s+KEY|CERTIFICATE)/.source, + ].join("|"), + ); + + 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; + if (AUTO_MEMORY_SECRET_PII_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 }); + let result: WriteMemoryResult; + try { + 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, + }); + } catch (err) { + logger.warn("agent_chat.auto_memory_capture_failed", { + sessionId: managed.session.id, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + 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); + + 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 (!memoryService || !projectId) { - return { classification: "none", contextText: "", telemetry: EMPTY_MEMORY_TELEMETRY }; + return { + classification: "none", + contextText: fileContext.text, + telemetry: { + ...EMPTY_MEMORY_TELEMETRY, + bootstrapLoaded: fileContext.bootstrapLoaded, + topicFilesLoaded: fileContext.topicFilesLoaded, + }, + 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 }; + + 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 +1873,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 +1896,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 +2301,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 +2737,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 +2843,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 +2869,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 +2950,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 +2986,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 +3004,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 +3619,10 @@ 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); + // When persisted modelId is missing we resolved through fallback — use the + // hydrated id as the CLI model string so stale metadata doesn't propagate. + const model = !persisted?.modelId || provider === "unified" ? hydratedModelId : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); const managed: ManagedChatSession = { @@ -3291,7 +3631,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 +3640,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 +3669,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 +3687,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 +3738,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 +3796,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 +3960,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 +4030,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 +4485,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 +4527,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 +4571,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 +4601,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 +4676,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 +4951,6 @@ export function createAgentChatService(args: { modelId, reasoningEffort, reuseExisting, - permissionMode: "full-auto", }), })); } @@ -4796,12 +5193,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 +5219,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 +5248,11 @@ export function createAgentChatService(args: { } persistChatState(managed); + await maybeDrainQueuedSteer( + managed, + runtime.pendingSteers, + async (steerText) => runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }), + ); } }; @@ -5367,6 +5768,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 +5858,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 +6022,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 +6051,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,10 +6072,29 @@ 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; + } + if (method === "error") { const error = (params.error as { message?: unknown; codexErrorInfo?: unknown } | null) ?? null; emitChatEvent(managed, { @@ -5777,6 +6231,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 +6426,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 +6454,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 +6503,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 +6781,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 +6807,7 @@ export function createAgentChatService(args: { bufferedText: null, recentConversationEntries: [], eventSequence: 0, + recoveryState: createRecoveryState(), }; let runtime: CodexRuntime | null = null; @@ -6484,7 +6943,6 @@ export function createAgentChatService(args: { codexSandbox: requestedCodexSandbox, codexConfigSource: requestedCodexConfigSource, unifiedPermissionMode: requestedUnifiedPermissionMode, - permissionMode: requestedPermMode, identityKey, surface, automationId, @@ -6506,15 +6964,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 +6990,7 @@ export function createAgentChatService(args: { ); } effectiveProvider = resolved; - normalizedModel = resolvedDescriptor.isCliWrapped ? resolvedDescriptor.shortId : resolvedDescriptor.id; + normalizedModel = getRuntimeModelRefForDescriptor(resolvedDescriptor, resolved); } const rawEffort = effectiveProvider === "codex" @@ -6541,39 +7001,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 +7043,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 +7078,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 +7148,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 +7179,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 +7282,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 +7307,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 +7405,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 +7503,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 +7560,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 +7611,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 +7657,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 +7702,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 +7756,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 +7801,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 +7822,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 +7876,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 +7961,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 +8080,6 @@ export function createAgentChatService(args: { codexSandbox, codexConfigSource, unifiedPermissionMode, - permissionMode, computerUse, }: AgentChatUpdateSessionArgs): Promise => { const managed = ensureManagedSession(sessionId); @@ -7605,6 +8087,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 +8102,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 +8150,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 +8197,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 +8222,7 @@ export function createAgentChatService(args: { } if ( - permissionMode !== undefined - || claudePermissionMode !== undefined + claudePermissionMode !== undefined || codexApprovalPolicy !== undefined || codexSandbox !== undefined || codexConfigSource !== undefined @@ -7761,6 +8237,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 +8352,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 +8368,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 +8542,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 +8565,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..83d52c4a1 --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionRecovery.test.ts @@ -0,0 +1,302 @@ +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 and cooldown has not elapsed", () => { + const state: RecoveryState = { + attempts: 5, + lastAttemptAt: Date.now(), + recovering: false, + lastError: "old error", + }; + expect(canAttemptRecovery(state)).toBe(false); + }); + + it("allows recovery after max attempts once cooldown has elapsed", () => { + const state: RecoveryState = { + attempts: 3, + lastAttemptAt: Date.now() - 31_000, // past the 30s cooldown + recovering: false, + lastError: "some error", + }; + expect(canAttemptRecovery(state)).toBe(true); + }); +}); + +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..088d2f5cf --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionRecovery.ts @@ -0,0 +1,117 @@ +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) { + // After max attempts, require a cooldown period before allowing retry + const elapsed = Date.now() - state.lastAttemptAt; + return elapsed >= RECOVERY_COOLDOWN_MS; + } + 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..f6f877292 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,19 @@ 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 known safe directories only. + // Reject requests to reveal arbitrary paths (e.g. ~/.ssh, /etc, /System). + const projectRoot = getCtx().project.rootPath; + const allowedDirs = [ + projectRoot, + app.getPath("downloads"), + app.getPath("documents"), + app.getPath("temp"), + ]; + if (!allowedDirs.some((dir) => isWithinDir(dir, normalized))) { + throw new Error("Path is outside allowed directories."); + } shell.showItemInFolder(normalized); }); @@ -3729,11 +3787,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 +4608,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 +4631,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 +4733,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 +5448,6 @@ export function registerIpc({ laneId, modelId: arg.modelId ?? null, reasoningEffort: arg.reasoningEffort ?? null, - permissionMode: arg.permissionMode, }); }); @@ -5465,7 +5517,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..0f2d4cdcd 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(); }, /** @@ -275,8 +280,24 @@ export function createPortAllocationService({ } if (orphaned.length > 0) { + // Resolve any existing conflicts involving the orphaned lanes + // (similar to release() — orphaned lanes no longer participate in conflicts). + for (const orphan of orphaned) { + for (const conflict of conflicts) { + if ( + !conflict.resolved && + (conflict.laneIdA === orphan.laneId || conflict.laneIdB === orphan.laneId) + ) { + conflict.resolved = true; + conflict.resolvedAt = new Date().toISOString(); + broadcastEvent({ type: "port-conflict-resolved", conflict }); + } + } + } + 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..8eccbb92e --- /dev/null +++ b/apps/desktop/src/main/services/memory/memoryFilesService.ts @@ -0,0 +1,371 @@ +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 => { + // Check all expected files, not just the index — topic files may be + // partially absent if the memory dir was only partially present. + if ( + fs.existsSync(indexPath) && + TOPICS.every((topic) => fs.existsSync(topicPaths[topic.key])) + ) { + 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(); + } + + // Only report topics that actually survived the final maxChars truncation. + // If trailing topic sections were clipped away, they should not appear in loadedTopics. + const survivingTopics = loadedTopics.filter((fileName) => text.includes(fileName)); + + return { + text, + bootstrapLoaded: bootstrap.length > 0, + topicFilesLoaded: survivingTopics, + }; + }; + + 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..92f6e9988 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -316,6 +316,38 @@ function resolveManagedPermissionMode(args: { : undefined; } +function mapPermissionModeToNativeFields( + provider: "claude" | "codex" | "unified", + mode: AgentChatPermissionMode | undefined, +): Partial> { + if (!mode) return {}; + // "config-toml" means the worker should inherit permissions from the + // provider/repo config (e.g. a .toml settings file). Don't rewrite it + // into explicit native permission fields — pass through with no overrides + // so the managed session respects the config it was supposed to inherit. + if (mode === "config-toml") 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 +667,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..c047d7e67 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
@@ -493,6 +529,7 @@ export function AgentChatComposer({ const [slashQuery, setSlashQuery] = useState(""); const [slashCursor, setSlashCursor] = useState(0); + const [attachError, setAttachError] = useState(null); const [dragActive, setDragActive] = useState(false); const [advancedMenuOpen, setAdvancedMenuOpen] = useState(false); const [computerUseModalOpen, setComputerUseModalOpen] = useState(false); @@ -502,6 +539,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 +619,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 +641,43 @@ 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. + const MAX_BLOB_SIZE = 10 * 1024 * 1024; // 10 MB + if (file.size > MAX_BLOB_SIZE) { + setAttachError(`File "${file.name || "clipboard"}" is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum allowed size is 10 MB.`); + continue; + } + 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 +692,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 +705,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 +1025,53 @@ export function AgentChatComposer({ } footer={ -
-
+
+
- {nativeControlPanel} -
- -
- +
{nativeControlPanel}
+
+ +
-
-
-
- - - -
+
+
+ + + +
-
@@ -1090,7 +1188,7 @@ export function AgentChatComposer({ > {promptSuggestion} - + Tab @@ -1106,12 +1204,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..8350dcbd2 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, + updateSessionEvents: eventsHook.updateSessionEvents, + }); + + 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, + clearSessionEvents, + removePendingInput, + } = 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 +352,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 +371,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 +381,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 +408,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 } : {}) }); + removePendingInput(selectedSessionId, request.itemId); + } catch (approvalError) { setError(approvalError instanceof Error ? approvalError.message : String(approvalError)); } + }, [eventsHook.pendingInputsBySession, selectedSessionId, removePendingInput]); + + // ── 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 +738,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 +756,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 +765,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 +794,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 +851,9 @@ export function AgentChatPane({ return ( <> - { void updateNativeControls({ claudePermissionMode: value }); }} onCodexApprovalPolicyChange={(value) => { void updateNativeControls({ codexApprovalPolicy: value }); }} @@ -1822,14 +861,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 +874,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) { clearSessionEvents(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 +930,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)); - }); + removePendingInput(selectedSessionId, itemId); + }).catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); }} /> + {selectedEvents.length > 0 ? ( + + ) : null} {sessionDelta ? (
+{sessionDelta.insertions} @@ -2008,28 +963,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 +977,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..955e2a679 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatEvents.ts @@ -0,0 +1,156 @@ +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; + /** Atomically update events for a session, synchronizing the ref and derived state. */ + updateSessionEvents: (sessionId: string, events: AgentChatEventEnvelope[]) => void; + /** Clear all events and derived state for a session atomically. */ + clearSessionEvents: (sessionId: string) => void; + /** Remove a single pending input by itemId without touching events. */ + removePendingInput: (sessionId: string, itemId: string) => void; + 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; + + // ── Synchronized writers ──────────────────────────────────────────── + + /** Atomically update events for a session, synchronizing the ref and all derived state. */ + const updateSessionEvents = useCallback((sessionId: string, events: AgentChatEventEnvelope[]) => { + const derived = deriveRuntimeState(events); + eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: events }; + setEventsBySession((prev) => ({ ...prev, [sessionId]: events })); + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: derived.turnActive })); + setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); + }, []); + + /** Clear all events and derived state for a session atomically. */ + const clearSessionEvents = useCallback((sessionId: string) => { + eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: [] }; + setEventsBySession((prev) => ({ ...prev, [sessionId]: [] })); + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); + setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: [] })); + }, []); + + /** Remove a single pending input by itemId without touching events or turnActive. */ + const removePendingInput = useCallback((sessionId: string, itemId: string) => { + setPendingInputsBySession((prev) => ({ + ...prev, + [sessionId]: (prev[sessionId] ?? []).filter((e) => e.itemId !== itemId), + })); + }, []); + + // ── 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, + updateSessionEvents, + clearSessionEvents, + removePendingInput, + 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..969c4870f --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useAgentChatSessions.ts @@ -0,0 +1,315 @@ +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"; + +// ── 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>; + updateSessionEvents: (sessionId: string, events: AgentChatEventEnvelope[]) => void; +} + +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, + updateSessionEvents, +}: 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; + + 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; + } + + updateSessionEvents(sessionId, merged); + + loadedHistoryRef.current.add(sessionId); + } catch { + // Ignore transcript history failures — don't mark as loaded so retries are allowed. + } + }, [eventsBySessionRef, updateSessionEvents]); + + // ── 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..42c7be300 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/hooks/useDeriveRuntimeState.ts @@ -0,0 +1,92 @@ +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 { + // If the event list was replaced or truncated, force a full rescan + if (events.length < lastIndexRef.current) { + lastIndexRef.current = 0; + stateRef.current = { turnActive: false }; + } + + // 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..e7b78eeec 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -49,9 +49,23 @@ 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 hasNonAttached = lanes.some((l) => l.laneType !== "attached" && l.laneType !== "primary"); + const isMixed = hasAttached && hasNonAttached; + const worktreeDeleteLabel = isMixed + ? "Unlink attached lanes & remove worktree files" + : hasAttached + ? "Unlink lane (keep branch)" + : "Remove worktree files only"; + const localDeleteLabel = isMixed + ? "Unlink attached & delete local branches" + : hasAttached + ? "Unlink + delete local branch" + : "+ local branch"; + const remoteDeleteLabel = isMixed + ? "Unlink attached & delete local + remote branches" + : hasAttached + ? "Unlink + delete local and remote branch" + : "Delete local and remote branch"; const confirmMatch = deleteConfirmText.trim().toLowerCase() === deletePhrase.toLowerCase(); return ( @@ -195,10 +209,13 @@ export function ManageLaneDialog({ )} {/* Force delete */} -