diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs index b3129fad6..aabb948ad 100644 --- a/apps/ade-cli/scripts/verify-built-cli.mjs +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -12,6 +13,7 @@ const bundledRuntimeEntryPaths = [ path.join(packageRoot, "dist", "bootstrap.cjs"), path.join(packageRoot, "dist", "adeRpcServer.cjs"), ]; +const tuiPath = path.join(packageRoot, "dist", "tuiClient", "cli.mjs"); const packageJsonPath = path.join(packageRoot, "package.json"); async function runHelp(command, args) { @@ -35,6 +37,39 @@ async function assertVersion(command, args, expectedVersion) { } } +async function assertIsolatedTuiHelp() { + const tuiContents = await fs.readFile(tuiPath, "utf8"); + for (const token of ["__dirname", "__filename"]) { + if (tuiContents.includes(token) && !tuiContents.includes(`const ${token} =`)) { + throw new Error(`[ade-cli:build] dist/tuiClient/cli.mjs references ${token} without an ESM shim`); + } + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ade-cli-tui-isolated-")); + try { + const isolatedTuiPath = path.join(tempDir, "cli.mjs"); + const runnerPath = path.join(tempDir, "run-tui-help.mjs"); + await fs.copyFile(tuiPath, isolatedTuiPath); + await fs.writeFile( + runnerPath, + "const tui = await import('./cli.mjs');\nprocess.exitCode = await tui.runAdeCodeCli(['--help']);\n", + "utf8", + ); + const { stdout } = await execFileAsync(process.execPath, [runnerPath], { + cwd: tempDir, + env: { + ...process.env, + NODE_PATH: "", + }, + }); + if (!stdout.includes("Terminal-native ADE Work chat.")) { + throw new Error("[ade-cli:build] isolated TUI help output did not include the ADE code banner text"); + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + const contents = await fs.readFile(cliPath, "utf8"); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); const expectedVersion = process.env.ADE_CLI_VERSION?.trim() || packageJson.version; @@ -68,6 +103,7 @@ if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { await runHelp(process.execPath, [cliPath, "--help"]); await assertVersion(process.execPath, [cliPath, "--version"], expectedVersion); +await assertIsolatedTuiHelp(); if (process.platform !== "win32") { await runHelp(cliPath, ["--help"]); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index b15c689ee..6fb7ae3b3 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1451,9 +1451,16 @@ function resolveLaneWorktreePathForSync(args: SyncRemoteCommandServiceArgs, lane return null; } -async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { +async function resolveLaneOverlayContext( + args: SyncRemoteCommandServiceArgs, + laneId: string, + options: { includeArchived?: boolean } = {}, +) { const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); - const lanes = await args.laneService.list({ includeStatus: false }); + const lanes = await args.laneService.list({ + includeStatus: false, + ...(options.includeArchived === true ? { includeArchived: true } : {}), + }); const lane = lanes.find((entry) => entry.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); @@ -1470,6 +1477,31 @@ async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, lan }; } +async function deleteLaneWithRuntimeCleanup( + args: SyncRemoteCommandServiceArgs, + payload: Record, +): Promise<{ ok: true }> { + const deleteArgs = parseDeleteLaneArgs(payload); + const envContext = args.laneEnvironmentService + ? await resolveLaneOverlayContext(args, deleteArgs.laneId, { includeArchived: true }).catch((error: unknown) => { + args.logger.warn("sync_remote.lane_env_cleanup.pre_delete_context_failed", { + laneId: deleteArgs.laneId, + err: String(error), + }); + return null; + }) + : null; + const teardownEnv = args.laneEnvironmentService && envContext?.envInitConfig + ? async () => { + await args.laneEnvironmentService!.cleanupLaneEnvironment(envContext.lane, envContext.envInitConfig); + } + : undefined; + + await args.laneService.delete(deleteArgs, { teardownEnv }); + args.portAllocationService?.release(deleteArgs.laneId); + return { ok: true }; +} + async function resolveChatCreateArgs( service: ReturnType, payload: AgentChatCreateArgs, @@ -1687,10 +1719,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); return { ok: true }; }); - register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.delete(parseDeleteLaneArgs(payload)); - return { ok: true }; - }); + register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => + deleteLaneWithRuntimeCleanup(args, payload)); register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId."))); register("lanes.getChildren", { viewerAllowed: true }, async (payload) => diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx index b3f534812..76b0c2809 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx @@ -114,6 +114,40 @@ describe("FooterControls", () => { expect(frame).toContain("help"); }); + it("adds the Claude terminal control toggle when a Claude terminal is active", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("^t"); + expect(frame).toContain("Claude"); + }); + + it("renders dedicated Claude control hints while the terminal owns input", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("CLAUDE CONTROL"); + expect(frame).toContain("^t"); + expect(frame).toContain("ADE"); + expect(frame).toContain("^]"); + expect(frame).not.toContain("^o lanes"); + }); + it("renders a focus indicator and cell hints when the inline row is focused", () => { const result = render( { expect(rows[0]?.runs[0]?.style.bold).toBe(true); }); + it("marks direct Claude terminal control with the escape hints", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("CLAUDE CONTROL"); + expect(frame).toContain("Ctrl+T returns to ADE"); + expect(frame).toContain("Ctrl+] escape"); + expect(frame).toContain("permission prompt"); + }); + it("uses transcript history for closed terminal sessions instead of the final resume-only snapshot", async () => { const result = render( { }); }); +describe("terminal control toggle", () => { + it("recognizes ctrl-t from Ink key data and raw terminal bytes", () => { + expect(isTerminalControlToggle("t", { ctrl: true })).toBe(true); + expect(isTerminalControlToggle("T", { ctrl: true })).toBe(true); + expect(isTerminalControlToggle("\x14", {})).toBe(true); + expect(isTerminalControlToggle("t", {})).toBe(false); + }); + + it("detaches from terminal control while preserving other raw input bytes", () => { + expect(splitTerminalControlInput("a\x14b\x1dc")).toEqual({ + detach: true, + forwarded: "abc", + }); + expect(splitTerminalControlInput("\x1b[A")).toEqual({ + detach: false, + forwarded: "\x1b[A", + }); + }); +}); + describe("pane width helpers", () => { it("caps prose chat width but lets embedded terminals use the full center pane", () => { expect(resolveChatWrapWidth(180, false, 0)).toBe(110); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index ec2e3e0aa..96d6ce63b 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -1448,6 +1448,15 @@ export function encodeTerminalPromptSubmit(value: string): string { return `${normalized}\r`; } +export function isTerminalControlToggle(input: string, key: { ctrl?: boolean }): boolean { + return input === "\x14" || (key.ctrl === true && input.toLowerCase() === "t"); +} + +export function splitTerminalControlInput(raw: string): { detach: boolean; forwarded: string } { + const forwarded = raw.replace(/[\x14\x1d]/g, ""); + return { detach: forwarded.length !== raw.length, forwarded }; +} + function claudeTerminalRowsForPane(rows: number): number { const safeRows = finiteFloor(rows, 4); return Math.max( @@ -1956,6 +1965,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } () => terminalSessions.find((session) => session.terminalId === activeSessionId) ?? null, [activeSessionId, terminalSessions], ); + const activeTerminalProvider = terminalSessionProvider(activeTerminalSession); const displaySessions = useMemo( () => [...sessions, ...terminalSessions.map(terminalSessionToChatSummary)] .sort((left, right) => { @@ -1965,7 +1975,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }), [sessions, terminalSessions], ); - const activeCommandProvider = terminalSessionProvider(activeTerminalSession) ?? activeSession?.provider ?? modelState.provider; + const claudeTerminalControlAvailable = Boolean( + activeTerminalSession + && activeTerminalSession.status === "running" + && activeTerminalProvider === "claude", + ); + const claudeTerminalControlActive = claudeTerminalControlAvailable + && attachedTerminalId === activeTerminalSession?.terminalId; + const activeCommandProvider = activeTerminalProvider ?? activeSession?.provider ?? modelState.provider; // Once a chat has any sent user message, the provider is locked — swapping // mid-thread breaks runtime continuity. Derived from events; persists across reloads. const providerLocked = useMemo(() => Boolean(activeSession) && hasFirstUserMessage(events), [activeSession, events]); @@ -2286,10 +2303,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { if (!connection || !activeTerminalSession) return; - const cols = clampTerminalPaneCols(terminalPaneWidth); - const terminalRows = claudeTerminalRowsForPane(chatRowBudget); + const cols = clampTerminalPaneCols(claudeTerminalControlActive ? terminalPaneWidth - 2 : terminalPaneWidth); + const terminalRows = claudeTerminalControlActive + ? Math.max(4, chatRowBudget - 1) + : claudeTerminalRowsForPane(chatRowBudget); void resizeTerminal(connection, activeTerminalSession.terminalId, cols, terminalRows).catch(() => {}); - }, [activeTerminalSession, chatRowBudget, connection, terminalPaneWidth]); + }, [activeTerminalSession, chatRowBudget, claudeTerminalControlActive, connection, terminalPaneWidth]); useEffect(() => { if (!connection || !activeTerminalSession) return; @@ -2714,11 +2733,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const handleRawInput = (chunk: Buffer | string) => { const raw = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; if (!raw) return; - if (raw.includes("\x1d")) { - const forwarded = raw.replace(/\x1d/g, ""); + const terminalControlInput = splitTerminalControlInput(raw); + if (terminalControlInput.detach) { setAttachedTerminalId(null); - if (forwarded) { - void writeTerminal(connection, attachedTerminalId, forwarded).catch((err) => { + if (terminalControlInput.forwarded) { + void writeTerminal(connection, attachedTerminalId, terminalControlInput.forwarded).catch((err) => { addNotice(err instanceof Error ? err.message : String(err), "error"); }); } @@ -5623,7 +5642,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useInput((input, key) => { if (attachedTerminalIdRef.current) { - if (input === "\x1d") setAttachedTerminalId(null); + if (input === "\x1d" || isTerminalControlToggle(input, key)) setAttachedTerminalId(null); + return; + } + if (isTerminalControlToggle(input, key)) { + const terminal = activeTerminalSession ?? activeTerminalSessionRef.current; + if ( + terminal?.terminalId === activeSessionIdRef.current + && terminal.status === "running" + && terminalSessionProvider(terminal) === "claude" + ) { + focusChat(); + setAttachedTerminalId(terminal.terminalId); + } return; } const mouse = parseTerminalMouseInput(input); @@ -6715,6 +6746,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } providerLocked={providerLocked} subagentsButtonVisible={subagentsButtonVisible} planMode={isPlanMode(modelState)} + terminalControlAvailable={claudeTerminalControlAvailable} + terminalControlActive={claudeTerminalControlActive} /> diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 7ec25e3e8..8c2427d55 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -53,6 +53,7 @@ Usage: Keys: ctrl-o open or focus lanes and chats ctrl-p open or focus details + ctrl-t toggle Claude terminal control when a Claude Code terminal is active shift-tab cycle pane focus esc return or cancel the active pane ? help when it is the first and only prompt character diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index e6f3c6c6a..1c36b384a 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -92,6 +92,8 @@ export function FooterControls({ inlineRowFocused, inlineRowCell, planMode, + terminalControlAvailable, + terminalControlActive, }: { provider?: AdeCodeProvider | null; providerLocked?: boolean; @@ -107,6 +109,8 @@ export function FooterControls({ inlineRowFocused?: boolean; inlineRowCell?: InlineRowCell; planMode?: boolean; + terminalControlAvailable?: boolean; + terminalControlActive?: boolean; }) { const brand = provider ? theme.provider(provider) : null; const rowFocused = inlineRowFocused === true; @@ -211,7 +215,15 @@ export function FooterControls({ ) : null} - {approvalActive ? ( + {terminalControlActive ? ( + <> + CLAUDE CONTROL + {" · "} + + {" "} + + + ) : approvalActive ? ( <> a {" approve "} @@ -239,6 +251,12 @@ export function FooterControls({ {" "} + {terminalControlAvailable ? ( + <> + {" "} + + + ) : null} )} diff --git a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx index e0ad66dbd..f52aa5162 100644 --- a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx @@ -6,6 +6,7 @@ import type { TerminalSnapshotCell, TerminalSnapshotRow, } from "../../../../desktop/src/shared/types"; +import { useSpinFrame } from "../spinTick"; import { theme } from "../theme"; type HeadlessXtermModule = typeof HeadlessXterm; @@ -224,14 +225,22 @@ export function styledRowsFromSnapshotRows(rows: TerminalSnapshotRow[], maxRows: return rows.slice(0, Math.max(0, maxRows)).map((row) => styledRowFromCells(row.cells, row.text)); } +function transcriptPreviewRows(transcript: string | null | undefined, maxRows: number): TerminalStyledRow[] { + const text = stripTerminalControls(transcript ?? "").trimEnd(); + if (!text) return [{ runs: [{ text: "No terminal output yet.", style: {} }] }]; + return text.split(/\r\n|\n|\r/).slice(-maxRows).map((line) => ({ runs: [{ text: line || " ", style: {} }] })); +} + function fallbackPreviewRows(preview: ChatTerminalPreviewResult | null, maxRows: number): TerminalStyledRow[] { if (!preview) return [{ runs: [{ text: "No terminal preview yet.", style: {} }] }]; if (preview.snapshot?.visibleRows?.length) { return styledRowsFromSnapshotRows(preview.snapshot.visibleRows, maxRows); } - const text = stripTerminalControls(preview.transcript ?? "").trimEnd(); - if (!text) return [{ runs: [{ text: "No terminal output yet.", style: {} }] }]; - return text.split(/\r\n|\n|\r/).slice(-maxRows).map((line) => ({ runs: [{ text: line || " ", style: {} }] })); + return transcriptPreviewRows(preview.transcript, maxRows); +} + +function terminalControlBorderColor(frame: string): string { + return frame === "◐" || frame === "◑" ? theme.color.warning : theme.color.attention2; } export function TerminalPane({ @@ -243,9 +252,13 @@ export function TerminalPane({ height, hiddenBottomRows = 0, }: TerminalPaneProps) { - const cols = clampTerminalPaneCols(width); - const rows = clampTerminalPaneRows(height); - const emulatedRows = clampTerminalPaneRows(rows + Math.max(0, finiteFloor(hiddenBottomRows, 0))); + const spinFrame = useSpinFrame(); + const effectiveHiddenBottomRows = attached ? 0 : hiddenBottomRows; + const contentWidth = attached ? Math.max(1, width - 2) : width; + const visibleHeight = attached ? Math.max(1, height - 1) : height; + const cols = clampTerminalPaneCols(contentWidth); + const rows = clampTerminalPaneRows(visibleHeight); + const emulatedRows = clampTerminalPaneRows(rows + Math.max(0, finiteFloor(effectiveHiddenBottomRows, 0))); const useSnapshotRows = Boolean( preview?.snapshot?.visibleRows?.length && (preview.session.status === "running" || !preview.transcript), @@ -303,27 +316,32 @@ export function TerminalPane({ const lines = useMemo(() => { if (snapshotRows?.length) return snapshotRows.slice(0, rows); + if (preview?.transcript && preview.session.status !== "running" && !liveChunks.length) { + return transcriptPreviewRows(preview.transcript, rows); + } const terminal = terminalRef.current; if (terminal) return styledRowsFromTerminal(terminal, rows); return fallbackPreviewRows(preview, rows); - }, [preview, renderTick, rows, snapshotRows]); + }, [liveChunks.length, preview, renderTick, rows, snapshotRows]); const status = attached - ? "attached · Ctrl+] returns to ADE" + ? "CLAUDE CONTROL · Ctrl+T returns to ADE · Ctrl+] escape" : preview?.session.status === "running" - ? hiddenBottomRows > 0 ? "ADE prompt sends to Claude Code" : "live preview" + ? effectiveHiddenBottomRows > 0 ? "ADE prompt sends to Claude Code" : "live preview" : preview?.session.resumeCommand ? "closed, resumable" : "closed"; - return ( - - - {title} + const content = ( + <> + + + {attached ? `${spinFrame} ${title}` : title} + {status} - - {lines.slice(0, height).map((line, index) => ( + + {lines.slice(0, visibleHeight).map((line, index) => ( {line.runs.map((run, runIndex) => ( ))} + + ); + + if (attached) { + return ( + + {content} + + ); + } + + return ( + + {content} ); } diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index f63635697..d30b4ed32 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -15,6 +15,18 @@ const external = [ "sqlite3", "zod", ]; +const tuiNoExternal = [ + "ink", + "ink-text-input", + "react", + "react/jsx-runtime", + "@opencode-ai/sdk", + "@xterm/headless", + "marked", + "ws", + "yaml", + /^highlight\.js(?:\/.*)?$/, +]; const packageRoot = path.dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { version?: string }; const version = process.env.ADE_CLI_VERSION?.trim() || packageJson.version || "0.0.0"; @@ -63,9 +75,16 @@ export default defineConfig([ sourcemap: true, clean: false, splitting: false, - noExternal: ["ink", "ink-text-input", "react", "react/jsx-runtime", "@opencode-ai/sdk"], + noExternal: tuiNoExternal, banner: { - js: "import { createRequire as __adeCreateRequire } from 'node:module'; const require = __adeCreateRequire(import.meta.url);", + js: [ + "import { createRequire as __adeCreateRequire } from 'node:module';", + "import { fileURLToPath as __adeFileURLToPath } from 'node:url';", + "import { dirname as __adeDirname } from 'node:path';", + "const require = __adeCreateRequire(import.meta.url);", + "const __filename = __adeFileURLToPath(import.meta.url);", + "const __dirname = __adeDirname(__filename);", + ].join("\n"), }, outExtension: () => ({ js: ".mjs" diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index 9561dbab3..843c42fda 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import asar from "@electron/asar"; import { parse as parseYaml } from "yaml"; @@ -349,6 +349,12 @@ async function validatePackagedRuntime(appPath, description) { await assertExecutable(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); + const adeCliTuiContents = await fs.readFile(adeCliTuiPath, "utf8"); + for (const token of ["__dirname", "__filename"]) { + if (adeCliTuiContents.includes(token) && !adeCliTuiContents.includes(`const ${token} =`)) { + throw new Error(`[release:mac] Bundled ADE code TUI references ${token} without an ESM shim`); + } + } await assertRemoteRuntimeBundle(resourcesPath, description); await validatePackageHygiene(appPath, description); @@ -416,6 +422,30 @@ async function validatePackagedRuntime(appPath, description) { throw new Error("[release:mac] Bundled ADE CLI wrapper did not print ADE CLI help"); } + const tuiSmokeDir = await fs.mkdtemp(path.join(os.tmpdir(), "ade-mac-tui-smoke-")); + try { + const tuiRunnerPath = path.join(tuiSmokeDir, "run-tui-help.mjs"); + await fs.writeFile( + tuiRunnerPath, + `const tui = await import(${JSON.stringify(pathToFileURL(adeCliTuiPath).href)});\n` + + "process.exitCode = await tui.runAdeCodeCli(['--help']);\n", + "utf8", + ); + const { stdout: adeCodeHelp } = await execFileAsync(executablePath, [tuiRunnerPath], { + cwd: resourcesPath, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "", + }, + }); + if (!adeCodeHelp.includes("Terminal-native ADE Work chat.")) { + throw new Error("[release:mac] Bundled ADE code TUI did not print help"); + } + } finally { + await fs.rm(tuiSmokeDir, { recursive: true, force: true }); + } + console.log(`[release:mac] Packaged runtime smoke passed for ${description}: ${path.relative(appPath, nodePtyAddon)}`); } diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 8d0d1acc0..6f6549a93 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -3,7 +3,7 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import asar from "@electron/asar"; import { parse as parseYaml } from "yaml"; @@ -491,6 +491,12 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(path.join(onnxRuntimeWinPath, "DirectML.dll"), "Windows DirectML DLL"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); await assertPathExists(crsqliteDllPath, "unpacked Windows cr-sqlite extension"); + const adeCliTuiContents = await fsp.readFile(adeCliTuiPath, "utf8"); + for (const token of ["__dirname", "__filename"]) { + if (adeCliTuiContents.includes(token) && !adeCliTuiContents.includes(`const ${token} =`)) { + fail(`Bundled ADE code TUI references ${token} without an ESM shim`); + } + } await assertRemoteRuntimeBundle(resourcesPath); await validatePackageHygiene(resourcesPath); @@ -549,6 +555,30 @@ async function validatePackagedRuntime(appDir) { }); assertAdeCliHelp(defaultHelp.stdout, "Bundled ADE CLI wrapper"); + const tuiSmokeDir = await fsp.mkdtemp(path.join(os.tmpdir(), "ade-win-tui-smoke-")); + try { + const tuiRunnerPath = path.join(tuiSmokeDir, "run-tui-help.mjs"); + await fsp.writeFile( + tuiRunnerPath, + `const tui = await import(${JSON.stringify(pathToFileURL(adeCliTuiPath).href)});\n` + + "process.exitCode = await tui.runAdeCodeCli(['--help']);\n", + "utf8", + ); + const tuiHelp = await runCommand(appExe, [tuiRunnerPath], { + cwd: resourcesPath, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "", + }, + }); + if (!tuiHelp.stdout.includes("Terminal-native ADE Work chat.")) { + fail("Bundled ADE code TUI did not print help"); + } + } finally { + await fsp.rm(tuiSmokeDir, { recursive: true, force: true }); + } + const nodeOverrideHelp = await runCommand(adeCliBinPath, ["--help"], { cwd: resourcesPath, env: { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 84268a7ae..6f7995fa4 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -424,6 +424,52 @@ function makeSession( } describe("runtime lane snapshot actions", () => { + it("runs lane delete with env teardown and port release in the runtime action domain", async () => { + const lane = makeLane({ id: "lane-delete", name: "Delete me" }); + const envInitConfig = { dependencies: ["npm install"] }; + const cleanupLaneEnvironment = vi.fn(async () => undefined); + const release = vi.fn(); + const deleteLane = vi.fn(async (_args, opts?: { teardownEnv?: () => Promise }) => { + await opts?.teardownEnv?.(); + }); + const runtime = { + laneService: { + list: vi.fn(async () => [lane]), + delete: deleteLane, + }, + projectConfigService: { + getEffective: vi.fn(() => ({ + laneEnvInit: null, + laneOverlayPolicies: [], + })), + }, + laneEnvironmentService: { + resolveEnvInitConfig: vi.fn(() => envInitConfig), + cleanupLaneEnvironment, + }, + portAllocationService: { + getLease: vi.fn(() => null), + release, + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + } as unknown as Parameters[0]; + const laneService = getAdeActionDomainServices(runtime).lane as { + delete?: (args: { laneId: string; force?: boolean; deleteBranch?: boolean }) => Promise; + }; + + await laneService.delete?.({ laneId: lane.id, force: true, deleteBranch: false }); + + expect(deleteLane).toHaveBeenCalledWith( + { laneId: lane.id, force: true, deleteBranch: false }, + { teardownEnv: expect.any(Function) }, + ); + expect(cleanupLaneEnvironment).toHaveBeenCalledWith(lane, envInitConfig); + expect(release).toHaveBeenCalledWith(lane.id); + }); + it("builds rich lane.listSnapshots results from runtime services", async () => { const lane = makeLane({ id: "lane-runtime", name: "Runtime lane" }); const attachedLane = makeLane({ diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a20b5c04f..1713762bc 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -32,6 +32,7 @@ import type { import type { AiConfig, ApplyLaneTemplateArgs, + DeleteLaneArgs, FileChangeEvent, FilesWatchArgs, LaneEnvInitConfig, @@ -2202,6 +2203,26 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { ); }, listRebaseSuggestions: () => runtime.rebaseSuggestionService?.listSuggestions() ?? [], + delete: async (args?: DeleteLaneArgs): Promise => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const laneEnvironmentService = runtime.laneEnvironmentService; + const envContext = laneEnvironmentService + ? await resolveLaneOverlayContext(runtime, laneId).catch((error: unknown) => { + runtime.logger.warn("lane_env_cleanup.pre_delete_context_failed", { + laneId, + error: getErrorMessage(error), + }); + return null; + }) + : null; + const teardownEnv = laneEnvironmentService && envContext?.envInitConfig + ? async () => { + await laneEnvironmentService.cleanupLaneEnvironment(envContext.lane, envContext.envInitConfig); + } + : undefined; + await runtime.laneService.delete({ ...(args ?? {}), laneId }, { teardownEnv }); + runtime.portAllocationService?.release(laneId); + }, dismissRebaseSuggestion: async (args?: { laneId?: string }) => { const laneId = requireNonEmptyString(args?.laneId, "laneId"); runtime.conflictService?.dismissRebase(laneId); diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index b7b886793..5d93406b3 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -3,6 +3,24 @@ import { IPC } from "../../../shared/ipc"; import { ipcInvokeTimeoutMs } from "./ipcTimeouts"; describe("ipcInvokeTimeoutMs", () => { + it("uses the lane delete budget for runtime-backed lane delete actions", () => { + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "lane", action: "delete", args: { laneId: "lane-1" } }, + }])).toBe(4 * 60_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "lane", action: "delete", args: { laneId: "lane-1" } }, + }])).toBe(4 * 60_000); + }); + + it("keeps ordinary runtime actions on the default timeout", () => { + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "lane", action: "list" }, + }])).toBe(30_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction)).toBe(30_000); + }); + it("keeps iOS launch timeout separate from macOS VM provisioning", () => { expect(ipcInvokeTimeoutMs(IPC.iosSimulatorLaunch)).toBe(10 * 60_000); expect(ipcInvokeTimeoutMs(IPC.macosVmProvision)).toBe(120 * 60_000); diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index ea9b535ed..872dc5907 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -1,9 +1,22 @@ import { IPC } from "../../../shared/ipc"; -export function ipcInvokeTimeoutMs(channel: string): number { +function isRuntimeLaneDeleteAction(args: unknown[] | undefined): boolean { + const first = args?.[0]; + if (!first || typeof first !== "object" || Array.isArray(first)) return false; + const request = (first as { request?: unknown }).request; + if (!request || typeof request !== "object" || Array.isArray(request)) return false; + const record = request as { domain?: unknown; action?: unknown }; + return record.domain === "lane" && record.action === "delete"; +} + +export function ipcInvokeTimeoutMs(channel: string, args?: unknown[]): number { switch (channel) { case IPC.lanesDelete: return 4 * 60_000; + case IPC.localRuntimeCallAction: + case IPC.remoteRuntimeCallAction: + if (isRuntimeLaneDeleteAction(args)) return 4 * 60_000; + return 30_000; case IPC.iosSimulatorLaunch: return 10 * 60_000; case IPC.macosVmProvision: diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 74b4dfcf9..51f640fca 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -2240,7 +2240,7 @@ export function registerIpc({ args: summarizeIpcArgs(redactIpcArgsForChannel(channel, args)), }); } - const IPC_TIMEOUT_MS = ipcInvokeTimeoutMs(channel); + const IPC_TIMEOUT_MS = ipcInvokeTimeoutMs(channel, args); let timeoutHandle: NodeJS.Timeout | null = null; try { const result = await Promise.race([ diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 2efb3ed5d..d66e91c2a 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -389,35 +389,33 @@ describe("prService.getGithubSnapshot", () => { })), apiRequest: vi.fn() .mockResolvedValueOnce({ data: [makeGitHubPull({ title: "Cached PR" })] }) - .mockResolvedValueOnce({ data: { items: [] } }) - .mockImplementationOnce(() => revalidationStarted) - .mockResolvedValueOnce({ data: { items: [] } }), + .mockImplementationOnce(() => revalidationStarted), }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); try { const first = await service.getGithubSnapshot(); expect(first.repoPullRequests[0]?.title).toBe("Cached PR"); - expect(githubService.apiRequest).toHaveBeenCalledTimes(2); + expect(githubService.apiRequest).toHaveBeenCalledTimes(1); nowSpy.mockReturnValue(Date.parse("2026-01-01T00:00:00Z") + GITHUB_SNAPSHOT_TTL_MS_FOR_TEST + 1); const stale = await service.getGithubSnapshot(); expect(stale.repoPullRequests[0]?.title).toBe("Cached PR"); await flushMicrotasks(30); - expect(githubService.apiRequest).toHaveBeenCalledTimes(3); + expect(githubService.apiRequest).toHaveBeenCalledTimes(2); resolveRevalidation({ data: [makeGitHubPull({ title: "Fresh PR", updated_at: "2026-01-01T00:05:00Z" })] }); await flushMicrotasks(); const fresh = await service.getGithubSnapshot(); expect(fresh.repoPullRequests[0]?.title).toBe("Fresh PR"); - expect(githubService.apiRequest).toHaveBeenCalledTimes(4); + expect(githubService.apiRequest).toHaveBeenCalledTimes(2); } finally { nowSpy.mockRestore(); } }); - it("fetches open external PRs by default and only includes closed external history when requested", async () => { + it("keeps GitHub tab snapshots scoped to the current repo", async () => { const githubService = makeGithubService({ getStatus: vi.fn(async () => ({ tokenStored: true, @@ -430,77 +428,25 @@ describe("prService.getGithubSnapshot", () => { }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); - await service.getGithubSnapshot({ force: true }); - const defaultExternalCall = githubService.apiRequest.mock.calls.find(([args]: [{ path: string }]) => args.path === "/search/issues"); - expect(defaultExternalCall?.[0].query.q).toContain("is:open"); + const defaultSnapshot = await service.getGithubSnapshot({ force: true }); + expect(defaultSnapshot.externalPullRequests).toEqual([]); + expect(githubService.apiRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ path: "/search/issues" }), + ); githubService.apiRequest.mockClear(); - await service.getGithubSnapshot({ force: true, includeExternalClosed: true }); - const fullHistoryExternalCall = githubService.apiRequest.mock.calls.find(([args]: [{ path: string }]) => args.path === "/search/issues"); - expect(fullHistoryExternalCall?.[0].query.q).not.toContain("is:open"); - }); - - it("does not reuse or overwrite full-history snapshots with narrower in-flight requests", async () => { - let resolveOpenRepo!: (value: unknown) => void; - const openRepoRequest = new Promise((resolve) => { - resolveOpenRepo = resolve; - }); - let repoCalls = 0; - const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), - apiRequest: vi.fn(async (args: { path: string; query?: { q?: string } }) => { - if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { - repoCalls += 1; - if (repoCalls === 1) return openRepoRequest; - return { data: [makeGitHubPull({ number: 2, title: "Full history PR" })] }; - } - return { - data: { - items: [ - makeGitHubPull({ - number: args.query?.q?.includes("is:open") ? 3 : 4, - title: args.query?.q?.includes("is:open") ? "Open external" : "Closed external", - pull_request: { url: "https://api.github.com/repos/elsewhere/project/pulls/4" }, - repository_url: "https://api.github.com/repos/elsewhere/project", - }), - ], - }, - }; - }), - }); - const { service } = buildService({ githubService, laneService: makeLaneService([]) }); - - const openOnly = service.getGithubSnapshot(); - await flushMicrotasks(); - - const fullHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); - expect(fullHistory.repoPullRequests[0]?.title).toBe("Full history PR"); - expect(fullHistory.externalPullRequests[0]?.title).toBe("Closed external"); - - resolveOpenRepo({ data: [makeGitHubPull({ number: 1, title: "Open-only PR" })] }); - await expect(openOnly).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Open-only PR" })], - })); - - const cachedFullHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); - expect(cachedFullHistory.repoPullRequests[0]?.title).toBe("Full history PR"); - expect(cachedFullHistory.externalPullRequests[0]?.title).toBe("Closed external"); - expect(repoCalls).toBe(2); + const fullHistorySnapshot = await service.getGithubSnapshot({ force: true, includeExternalClosed: true }); + expect(fullHistorySnapshot.externalPullRequests).toEqual([]); + expect(githubService.apiRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ path: "/search/issues" }), + ); }); - it("keeps a superseded open-only snapshot cached when a full-history upgrade fails", async () => { + it("reuses an in-flight repo snapshot when closed-history is requested", async () => { let resolveOpenRepo!: (value: unknown) => void; const openRepoRequest = new Promise((resolve) => { resolveOpenRepo = resolve; }); - let rejectFullRepo!: (reason: unknown) => void; - const fullRepoRequest = new Promise((_resolve, reject) => { - rejectFullRepo = reject; - }); let repoCalls = 0; const githubService = makeGithubService({ getStatus: vi.fn(async () => ({ @@ -508,123 +454,67 @@ describe("prService.getGithubSnapshot", () => { repo: REPO, userLogin: "octocat", })), - apiRequest: vi.fn(async (args: { path: string; query?: { q?: string } }) => { + apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { repoCalls += 1; - if (repoCalls === 1) return openRepoRequest; - return fullRepoRequest; + return openRepoRequest; } - return { - data: { - items: [ - makeGitHubPull({ - number: 3, - title: args.query?.q?.includes("is:open") ? "Open external" : "Closed external", - pull_request: { url: "https://api.github.com/repos/elsewhere/project/pulls/3" }, - repository_url: "https://api.github.com/repos/elsewhere/project", - }), - ], - }, - }; + throw new Error(`Unexpected GitHub API path: ${args.path}`); }), }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); - const openOnly = service.getGithubSnapshot(); + const defaultSnapshot = service.getGithubSnapshot(); await flushMicrotasks(); - - const fullHistory = service.getGithubSnapshot({ includeExternalClosed: true }); + const closedHistorySnapshot = service.getGithubSnapshot({ includeExternalClosed: true }); await flushMicrotasks(); - resolveOpenRepo({ data: [makeGitHubPull({ number: 1, title: "Open-only PR" })] }); - await expect(openOnly).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Open-only PR" })], - externalPullRequests: [expect.objectContaining({ title: "Open external" })], + resolveOpenRepo({ data: [makeGitHubPull({ number: 2, title: "Repo PR" })] }); + await expect(defaultSnapshot).resolves.toEqual(expect.objectContaining({ + repoPullRequests: [expect.objectContaining({ title: "Repo PR" })], + externalPullRequests: [], })); - - rejectFullRepo(new Error("full history failed")); - await expect(fullHistory).rejects.toThrow("full history failed"); - - const apiCallsAfterOpenOnly = githubService.apiRequest.mock.calls.length; - const cachedOpenOnly = await service.getGithubSnapshot(); - expect(cachedOpenOnly.repoPullRequests[0]?.title).toBe("Open-only PR"); - expect(cachedOpenOnly.externalPullRequests[0]?.title).toBe("Open external"); - expect(githubService.apiRequest).toHaveBeenCalledTimes(apiCallsAfterOpenOnly); - expect(repoCalls).toBe(2); + await expect(closedHistorySnapshot).resolves.toEqual(expect.objectContaining({ + repoPullRequests: [expect.objectContaining({ title: "Repo PR" })], + externalPullRequests: [], + })); + expect(repoCalls).toBe(1); }); - it("keeps the existing open-only cache when a superseded refresh and full-history upgrade fail", async () => { - let resolveOpenRepo!: (value: unknown) => void; - const openRepoRequest = new Promise((resolve) => { - resolveOpenRepo = resolve; - }); - let rejectFullRepo!: (reason: unknown) => void; - const fullRepoRequest = new Promise((_resolve, reject) => { - rejectFullRepo = reject; - }); - let repoCalls = 0; + it("serves closed-history requests from a fresh repo snapshot cache", async () => { const githubService = makeGithubService({ getStatus: vi.fn(async () => ({ tokenStored: true, repo: REPO, userLogin: "octocat", })), - apiRequest: vi.fn(async (args: { path: string; query?: { q?: string } }) => { + apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { - repoCalls += 1; - if (repoCalls === 1) return { data: [makeGitHubPull({ number: 1, title: "Cached open-only PR" })] }; - if (repoCalls === 2) return openRepoRequest; - return fullRepoRequest; + return { data: [makeGitHubPull({ number: 1, title: "Cached repo PR" })] }; } - return { - data: { - items: [ - makeGitHubPull({ - number: args.query?.q?.includes("is:open") ? 3 : 4, - title: args.query?.q?.includes("is:open") ? "Open external" : "Closed external", - pull_request: { url: "https://api.github.com/repos/elsewhere/project/pulls/4" }, - repository_url: "https://api.github.com/repos/elsewhere/project", - }), - ], - }, - }; + throw new Error(`Unexpected GitHub API path: ${args.path}`); }), }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); - const cached = await service.getGithubSnapshot({ force: true }); - expect(cached.repoPullRequests[0]?.title).toBe("Cached open-only PR"); - - const openOnlyRefresh = service.getGithubSnapshot({ force: true }); - await flushMicrotasks(); - const fullHistory = service.getGithubSnapshot({ includeExternalClosed: true }); - await flushMicrotasks(); - resolveOpenRepo({ data: [makeGitHubPull({ number: 2, title: "Superseded open-only PR" })] }); - await expect(openOnlyRefresh).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Superseded open-only PR" })], - })); - rejectFullRepo(new Error("full history failed")); - await expect(fullHistory).rejects.toThrow("full history failed"); + const cached = await service.getGithubSnapshot({ force: true }); + expect(cached.repoPullRequests[0]?.title).toBe("Cached repo PR"); - const apiCallsAfterFailure = githubService.apiRequest.mock.calls.length; - const cachedOpenOnly = await service.getGithubSnapshot(); - expect(cachedOpenOnly.repoPullRequests[0]?.title).toBe("Cached open-only PR"); - expect(githubService.apiRequest).toHaveBeenCalledTimes(apiCallsAfterFailure); - expect(repoCalls).toBe(3); + const apiCallsAfterCache = githubService.apiRequest.mock.calls.length; + const closedHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); + expect(closedHistory.repoPullRequests[0]?.title).toBe("Cached repo PR"); + expect(closedHistory.externalPullRequests).toEqual([]); + expect(githubService.apiRequest).toHaveBeenCalledTimes(apiCallsAfterCache); }); - it("does not publish a fallback snapshot from a superseded full-history failure", async () => { - let resolveOpenRepo!: (value: unknown) => void; - const openRepoRequest = new Promise((resolve) => { - resolveOpenRepo = resolve; - }); - let rejectSupersededFullRepo!: (reason: unknown) => void; - const supersededFullRepoRequest = new Promise((_resolve, reject) => { - rejectSupersededFullRepo = reject; + it("does not let a superseded open-only snapshot overwrite a fresher cache", async () => { + let resolveStaleRepo!: (value: unknown) => void; + const staleRepoRequest = new Promise((resolve) => { + resolveStaleRepo = resolve; }); - let resolveCurrentFullRepo!: (value: unknown) => void; - const currentFullRepoRequest = new Promise((resolve) => { - resolveCurrentFullRepo = resolve; + let resolveFreshRepo!: (value: unknown) => void; + const freshRepoRequest = new Promise((resolve) => { + resolveFreshRepo = resolve; }); let repoCalls = 0; const githubService = makeGithubService({ @@ -633,57 +523,38 @@ describe("prService.getGithubSnapshot", () => { repo: REPO, userLogin: "octocat", })), - apiRequest: vi.fn(async (args: { path: string; query?: { q?: string } }) => { + apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { repoCalls += 1; - if (repoCalls === 1) return openRepoRequest; - if (repoCalls === 2) return supersededFullRepoRequest; - return currentFullRepoRequest; + if (repoCalls === 1) return staleRepoRequest; + return freshRepoRequest; } - return { - data: { - items: [ - makeGitHubPull({ - number: args.query?.q?.includes("is:open") ? 3 : 4, - title: args.query?.q?.includes("is:open") ? "Open external" : "Closed external", - pull_request: { url: "https://api.github.com/repos/elsewhere/project/pulls/4" }, - repository_url: "https://api.github.com/repos/elsewhere/project", - }), - ], - }, - }; + return { data: { items: [] } }; }), }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); - const openOnly = service.getGithubSnapshot(); + const staleRequest = service.getGithubSnapshot({ force: true }); await flushMicrotasks(); - const supersededFullHistory = service.getGithubSnapshot({ includeExternalClosed: true }); + const freshRequest = service.getGithubSnapshot({ force: true }); await flushMicrotasks(); - resolveOpenRepo({ data: [makeGitHubPull({ number: 1, title: "Open-only PR" })] }); - await expect(openOnly).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Open-only PR" })], + resolveFreshRepo({ data: [makeGitHubPull({ number: 2, title: "Fresh open-only PR" })] }); + await expect(freshRequest).resolves.toEqual(expect.objectContaining({ + repoPullRequests: [expect.objectContaining({ title: "Fresh open-only PR" })], })); - const currentFullHistory = service.getGithubSnapshot({ force: true, includeExternalClosed: true }); - await flushMicrotasks(); - rejectSupersededFullRepo(new Error("superseded full history failed")); - await expect(supersededFullHistory).rejects.toThrow("superseded full history failed"); - - const defaultSnapshot = service.getGithubSnapshot(); - resolveCurrentFullRepo({ data: [makeGitHubPull({ number: 2, title: "Current full-history PR" })] }); - - await expect(currentFullHistory).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Current full-history PR" })], - })); - await expect(defaultSnapshot).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Current full-history PR" })], + resolveStaleRepo({ data: [makeGitHubPull({ number: 1, title: "Stale open-only PR" })] }); + await expect(staleRequest).resolves.toEqual(expect.objectContaining({ + repoPullRequests: [expect.objectContaining({ title: "Stale open-only PR" })], })); - expect(repoCalls).toBe(3); + + const cachedSnapshot = await service.getGithubSnapshot(); + expect(cachedSnapshot.repoPullRequests[0]?.title).toBe("Fresh open-only PR"); + expect(repoCalls).toBe(2); }); - it("does not let a superseded open-only snapshot overwrite a fresher cache", async () => { + it("does not let an invalidated in-flight snapshot repopulate an empty cache", async () => { let resolveStaleRepo!: (value: unknown) => void; const staleRepoRequest = new Promise((resolve) => { resolveStaleRepo = resolve; @@ -705,36 +576,33 @@ describe("prService.getGithubSnapshot", () => { if (repoCalls === 1) return staleRepoRequest; return freshRepoRequest; } - return { data: { items: [] } }; + throw new Error(`Unexpected GitHub API path: ${args.path}`); }), }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); const staleRequest = service.getGithubSnapshot({ force: true }); await flushMicrotasks(); - const freshRequest = service.getGithubSnapshot({ force: true }); - await flushMicrotasks(); + service.invalidateGithubSnapshot(); - resolveFreshRepo({ data: [makeGitHubPull({ number: 2, title: "Fresh open-only PR" })] }); - await expect(freshRequest).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Fresh open-only PR" })], - })); - - resolveStaleRepo({ data: [makeGitHubPull({ number: 1, title: "Stale open-only PR" })] }); + resolveStaleRepo({ data: [makeGitHubPull({ number: 1, title: "Invalidated PR" })] }); await expect(staleRequest).resolves.toEqual(expect.objectContaining({ - repoPullRequests: [expect.objectContaining({ title: "Stale open-only PR" })], + repoPullRequests: [expect.objectContaining({ title: "Invalidated PR" })], })); - const cachedSnapshot = await service.getGithubSnapshot(); - expect(cachedSnapshot.repoPullRequests[0]?.title).toBe("Fresh open-only PR"); + const freshRequest = service.getGithubSnapshot(); + await flushMicrotasks(); + resolveFreshRepo({ data: [makeGitHubPull({ number: 2, title: "Fresh after invalidation" })] }); + await expect(freshRequest).resolves.toEqual(expect.objectContaining({ + repoPullRequests: [expect.objectContaining({ title: "Fresh after invalidation" })], + })); expect(repoCalls).toBe(2); }); - it("preserves full-history cache mode during stale open-only revalidation", async () => { + it("preserves repo snapshot cache mode during stale revalidation", async () => { const initialNow = Date.parse("2026-01-01T00:00:00Z"); const nowSpy = vi.spyOn(Date, "now").mockReturnValue(initialNow); let repoCalls = 0; - const externalQueries: string[] = []; const githubService = makeGithubService({ getStatus: vi.fn(async () => ({ tokenStored: true, @@ -753,43 +621,28 @@ describe("prService.getGithubSnapshot", () => { ], }; } - externalQueries.push(args.query?.q ?? ""); - return { - data: { - items: [ - makeGitHubPull({ - number: externalQueries.length === 1 ? 10 : 11, - title: externalQueries.length === 1 ? "Cached closed external" : "Fresh closed external", - state: "closed", - pull_request: { url: "https://api.github.com/repos/elsewhere/project/pulls/11" }, - repository_url: "https://api.github.com/repos/elsewhere/project", - }), - ], - }, - }; + throw new Error(`Unexpected GitHub API path: ${args.path}`); }), }); const { service } = buildService({ githubService, laneService: makeLaneService([]) }); try { const fullHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); - expect(fullHistory.externalPullRequests[0]?.title).toBe("Cached closed external"); - expect(externalQueries[0]).not.toContain("is:open"); + expect(fullHistory.externalPullRequests).toEqual([]); nowSpy.mockReturnValue(Date.parse("2030-01-01T00:00:00Z")); const staleOpenOnly = await service.getGithubSnapshot(); expect(staleOpenOnly.repoPullRequests[0]?.title).toBe("Cached full history"); - const refreshedFullHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); - expect(externalQueries[1]).not.toContain("is:open"); - expect(refreshedFullHistory.repoPullRequests[0]?.title).toBe("Fresh full history"); - expect(refreshedFullHistory.externalPullRequests[0]?.title).toBe("Fresh closed external"); + const staleClosedHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); + expect(staleClosedHistory.repoPullRequests[0]?.title).toBe("Cached full history"); + expect(staleClosedHistory.externalPullRequests).toEqual([]); + await flushMicrotasks(); const cachedFullHistory = await service.getGithubSnapshot({ includeExternalClosed: true }); expect(cachedFullHistory.repoPullRequests[0]?.title).toBe("Fresh full history"); - expect(cachedFullHistory.externalPullRequests[0]?.title).toBe("Fresh closed external"); + expect(cachedFullHistory.externalPullRequests).toEqual([]); expect(repoCalls).toBe(2); - expect(externalQueries).toHaveLength(2); } finally { nowSpy.mockRestore(); } diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index c9ae1c696..65837cbdd 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1046,6 +1046,7 @@ export function createPrService({ const invalidateGithubSnapshotCache = (): void => { cachedGithubSnapshot = null; cachedGithubSnapshotAt = 0; + githubSnapshotCacheEpoch += 1; }; const pruneExpiredHotRefreshes = (nowMs = Date.now()): void => { @@ -4976,24 +4977,18 @@ export function createPrService({ const GITHUB_SNAPSHOT_TTL_MS = 120_000; let cachedGithubSnapshot: GitHubPrSnapshot | null = null; let cachedGithubSnapshotAt = 0; - let cachedGithubSnapshotIncludesExternalClosed = false; - let githubSnapshotInFlight: { request: Promise; includeExternalClosed: boolean } | null = null; - let pendingOpenOnlySnapshot: { snapshot: GitHubPrSnapshot; capturedAt: number } | null = null; + let githubSnapshotCacheEpoch = 0; + let githubSnapshotInFlight: { request: Promise } | null = null; const publishGithubSnapshot = ( snapshot: GitHubPrSnapshot, - includesExternalClosed: boolean, capturedAt = Date.now(), ): void => { cachedGithubSnapshot = snapshot; cachedGithubSnapshotAt = capturedAt; - cachedGithubSnapshotIncludesExternalClosed = includesExternalClosed; - if (includesExternalClosed) { - pendingOpenOnlySnapshot = null; - } }; - const getGithubSnapshotUncached = async (options: GithubSnapshotOptions = {}): Promise => { + const getGithubSnapshotUncached = async (): Promise => { const githubStatus = await githubService.getStatus(); if (!githubStatus.tokenStored) { throw new Error("GitHub token missing. Set it in Settings to sync pull requests."); @@ -5106,73 +5101,33 @@ export function createPrService({ ); } - const includeExternalClosed = options.includeExternalClosed === true; - const externalQueryState = includeExternalClosed ? "" : "is:open "; - const externalPullRequestsRaw = githubStatus.userLogin - ? await fetchAllPages({ - path: "/search/issues", - query: { - q: `is:pr ${externalQueryState}involves:${githubStatus.userLogin} archived:false -repo:${repo.owner}/${repo.name}`, - sort: "updated", - order: "desc", - }, - select: (payload) => Array.isArray(payload?.items) ? payload.items : [], - }) - : []; - return { repo, viewerLogin: githubStatus.userLogin, repoPullRequests: repoPullRequestsRaw.map((rawPr) => toGitHubItem(rawPr, "repo")), - externalPullRequests: externalPullRequestsRaw - .filter((rawPr) => rawPr?.pull_request) - .map((rawPr) => toGitHubItem(rawPr, "external")), + externalPullRequests: [], syncedAt: nowIso(), }; }; const getGithubSnapshot = async (options: GithubSnapshotOptions = {}): Promise => { const force = options?.force === true; - const includeExternalClosed = options?.includeExternalClosed === true; - const cachedSnapshotSatisfiesRequest = - cachedGithubSnapshot != null - && (!includeExternalClosed || cachedGithubSnapshotIncludesExternalClosed); - const startSnapshotRequest = ( - allowStaleOnError: boolean, - requestIncludeExternalClosed = includeExternalClosed, - ): Promise => { + const startSnapshotRequest = (allowStaleOnError: boolean): Promise => { const staleFallback = cachedGithubSnapshot; - let inFlight!: { request: Promise; includeExternalClosed: boolean }; - const request = getGithubSnapshotUncached({ includeExternalClosed: requestIncludeExternalClosed }) + const requestEpoch = githubSnapshotCacheEpoch; + let inFlight!: { request: Promise }; + const request = getGithubSnapshotUncached() .then((snapshot) => { const capturedAt = Date.now(); - const hasWiderRequestInFlight = - githubSnapshotInFlight !== null - && githubSnapshotInFlight !== inFlight - && githubSnapshotInFlight.includeExternalClosed; const canPublishSnapshot = githubSnapshotInFlight === inFlight - || ( - !requestIncludeExternalClosed - && cachedGithubSnapshot === null - && !cachedGithubSnapshotIncludesExternalClosed - && !hasWiderRequestInFlight - ); + && requestEpoch === githubSnapshotCacheEpoch; if (canPublishSnapshot) { - publishGithubSnapshot(snapshot, requestIncludeExternalClosed, capturedAt); - } else if (!requestIncludeExternalClosed && cachedGithubSnapshot === null) { - pendingOpenOnlySnapshot = { snapshot, capturedAt }; + publishGithubSnapshot(snapshot, capturedAt); } return snapshot; }) .catch((error) => { - const isCurrentRequest = githubSnapshotInFlight === inFlight; - if (isCurrentRequest && requestIncludeExternalClosed && pendingOpenOnlySnapshot) { - if (cachedGithubSnapshot === null) { - publishGithubSnapshot(pendingOpenOnlySnapshot.snapshot, false, pendingOpenOnlySnapshot.capturedAt); - } - pendingOpenOnlySnapshot = null; - } if (allowStaleOnError && staleFallback) { logger.warn("prs.github_snapshot_refresh_failed_stale_returned", { error: error instanceof Error ? error.message : String(error), @@ -5186,31 +5141,23 @@ export function createPrService({ githubSnapshotInFlight = null; } }); - inFlight = { request, includeExternalClosed: requestIncludeExternalClosed }; + inFlight = { request }; githubSnapshotInFlight = inFlight; return request; }; - if (!force && cachedSnapshotSatisfiesRequest) { + if (!force && cachedGithubSnapshot) { const cachedSnapshot = cachedGithubSnapshot; - if (!cachedSnapshot) return startSnapshotRequest(false); const ageMs = Date.now() - cachedGithubSnapshotAt; if (ageMs < GITHUB_SNAPSHOT_TTL_MS) { return cachedSnapshot; } - const refreshIncludeExternalClosed = includeExternalClosed || cachedGithubSnapshotIncludesExternalClosed; - if (!githubSnapshotInFlight || (refreshIncludeExternalClosed && !githubSnapshotInFlight.includeExternalClosed)) { - void startSnapshotRequest(true, refreshIncludeExternalClosed).catch(() => {}); - } else if (includeExternalClosed && githubSnapshotInFlight.includeExternalClosed) { - return githubSnapshotInFlight.request; + if (!githubSnapshotInFlight) { + void startSnapshotRequest(true).catch(() => {}); } return cachedSnapshot; } - if ( - !force - && githubSnapshotInFlight - && (!includeExternalClosed || githubSnapshotInFlight.includeExternalClosed) - ) { + if (!force && githubSnapshotInFlight) { return githubSnapshotInFlight.request; } diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 53ad7986a..21f78ef40 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -787,18 +787,82 @@ describe("createSyncRemoteCommandService", () => { }); it("lanes.delete parses all optional flags", async () => { - await service.execute(makePayload("lanes.delete", { + const result = await service.execute(makePayload("lanes.delete", { laneId: "lane-1", deleteBranch: true, deleteRemoteBranch: false, force: true, })); - expect(laneService.delete).toHaveBeenCalledWith({ + expect(laneService.delete).toHaveBeenCalledWith( + { + laneId: "lane-1", + deleteBranch: true, + deleteRemoteBranch: false, + force: true, + }, + { teardownEnv: undefined }, + ); + expect(result).toEqual({ ok: true }); + }); + + it("lanes.delete runs env teardown and releases the port lease", async () => { + const lane = { + id: "lane-1", + name: "Lane one", + laneType: "feature", + worktreePath: "/repo/.ade/worktrees/lane-1", + }; + const envInitConfig = { dependencies: ["npm install"] }; + const cleanupLaneEnvironment = vi.fn(async () => undefined); + const release = vi.fn(); + laneService.list.mockResolvedValue([lane]); + laneService.delete.mockImplementation(async (_args: unknown, opts?: { teardownEnv?: () => Promise }) => { + await opts?.teardownEnv?.(); + }); + const withRuntimeCleanup = createSyncRemoteCommandService({ + laneService, + prService, + issueInventoryService, + queueLandingService, + ptyService, + sessionService, + fileService, + gitService, + diffService, + agentChatService, + workerAgentService, + conflictService, + processService, + projectConfigService: { + getEffective: vi.fn(() => ({ + laneEnvInit: null, + laneOverlayPolicies: [], + })), + } as any, + laneEnvironmentService: { + resolveEnvInitConfig: vi.fn(() => envInitConfig), + cleanupLaneEnvironment, + } as any, + portAllocationService: { + getLease: vi.fn(() => null), + release, + } as any, + logger: createLogger() as any, + }); + + await withRuntimeCleanup.execute(makePayload("lanes.delete", { laneId: "lane-1", - deleteBranch: true, - deleteRemoteBranch: false, force: true, - }); + deleteBranch: false, + })); + + expect(laneService.list).toHaveBeenCalledWith({ includeStatus: false, includeArchived: true }); + expect(laneService.delete).toHaveBeenCalledWith( + { laneId: "lane-1", force: true, deleteBranch: false, deleteRemoteBranch: undefined }, + { teardownEnv: expect.any(Function) }, + ); + expect(cleanupLaneEnvironment).toHaveBeenCalledWith(lane, envInitConfig); + expect(release).toHaveBeenCalledWith("lane-1"); }); it("lanes.getStackChain requires laneId", async () => { @@ -1949,7 +2013,7 @@ describe("createSyncRemoteCommandService", () => { }); it("handles payload.args being non-object by defaulting to empty record", async () => { - const result = await service.execute({ + await service.execute({ commandId: "cmd-1", action: "prs.list", args: "not-an-object" as any, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index c0a45f489..9fde8f037 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -421,6 +421,164 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.lanesList, {}); }); + it("uses in-process IPC for local PR tab reads instead of waiting on the runtime daemon", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const detail = { + prId: "pr-1", + body: "Ready to merge", + labels: [], + assignees: [], + requestedReviewers: [], + author: null, + isDraft: false, + milestone: null, + linkedIssues: [], + }; + const prs = [ + { + id: "pr-1", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "owner", + repoName: "repo", + githubPrNumber: 12, + githubUrl: "https://github.com/owner/repo/pull/12", + githubNodeId: null, + title: "Fix detail load", + state: "open", + baseBranch: "main", + headBranch: "lane/fix-detail-load", + checksStatus: "none", + reviewStatus: "none", + additions: 0, + deletions: 0, + lastSyncedAt: null, + createdAt: "2026-05-14T12:00:00.000Z", + updatedAt: "2026-05-14T12:00:00.000Z", + conflictAnalysis: null, + }, + ]; + const snapshot = { + repo: { owner: "owner", name: "repo" }, + viewerLogin: "arul", + repoPullRequests: [], + externalPullRequests: [], + syncedAt: "2026-05-14T12:00:00.000Z", + }; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) { + throw new Error("PR tab reads should not call the local runtime daemon"); + } + if (channel === IPC.prsGetDetail) return detail; + if (channel === IPC.prsListWithConflicts) return prs; + if (channel === IPC.prsGetGitHubSnapshot) return snapshot; + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.prs.getDetail("pr-1")).resolves.toEqual(detail); + await expect(bridge.prs.listWithConflicts()).resolves.toEqual(prs); + await expect(bridge.prs.getGitHubSnapshot()).resolves.toEqual(snapshot); + + expect(invoke).toHaveBeenCalledWith(IPC.prsGetDetail, { prId: "pr-1" }); + expect(invoke).toHaveBeenCalledWith(IPC.prsListWithConflicts, {}); + expect(invoke).toHaveBeenCalledWith(IPC.prsGetGitHubSnapshot, {}); + expect(invoke).not.toHaveBeenCalledWith( + IPC.localRuntimeCallAction, + expect.anything(), + ); + }); + + it("keeps remote runtime routing for PR tab reads when the project is remote", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const detail = { + prId: "pr-1", + body: "Loaded remotely", + labels: [], + assignees: [], + requestedReviewers: [], + author: null, + isDraft: false, + milestone: null, + linkedIssues: [], + }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + return { ok: true, domain: request?.domain, action: request?.action, result: detail, statusHints: {} }; + } + if (channel === IPC.prsGetDetail) { + throw new Error("remote PR reads should not call desktop PR IPC"); + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.prs.getDetail("pr-1")).resolves.toEqual(detail); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { domain: "pr", action: "getDetail", arg: "pr-1" }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsGetDetail, expect.anything()); + }); + it("uses in-process file IPC when no local runtime binding exists", async () => { const workspaces = [{ id: "primary", label: "Primary", rootPath: "/repo" }]; const invoke = vi.fn(async (channel: string) => { @@ -1941,4 +2099,105 @@ describe("preload OAuth bridge", () => { }); expect(callback).toHaveBeenCalledTimes(2); }); + + it("multiplexes local session and PR event subscriptions through one IPC listener", async () => { + const sessionEvent = { + sessionId: "session-1", + reason: "meta-updated", + }; + const sessionDelta = { + sessionId: "session-1", + laneId: "lane-1", + startedAt: "2026-05-10T12:00:00.000Z", + endedAt: null, + headShaStart: null, + headShaEnd: null, + filesChanged: 0, + insertions: 0, + deletions: 0, + touchedFiles: [], + failureLines: [], + computedAt: "2026-05-10T12:00:00.000Z", + }; + const prEvent = { + type: "prs-updated", + polledAt: "2026-05-10T12:00:00.000Z", + prs: [], + }; + const invoke = vi.fn(async (channel: string, arg?: { sessionId?: string }) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding: null }; + } + if (channel === IPC.sessionsGetDelta) { + return { ...sessionDelta, computedAt: `${sessionDelta.computedAt}:${arg?.sessionId ?? "unknown"}` }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const sessionCallbackA = vi.fn(); + const sessionCallbackB = vi.fn(); + const prCallbackA = vi.fn(); + const prCallbackB = vi.fn(); + + const unsubscribeSessionA = bridge.sessions.onChanged(sessionCallbackA); + const unsubscribeSessionB = bridge.sessions.onChanged(sessionCallbackB); + const unsubscribePrA = bridge.prs.onEvent(prCallbackA); + const unsubscribePrB = bridge.prs.onEvent(prCallbackB); + const cachedDelta = await bridge.sessions.getDelta("session-1"); + expect(cachedDelta.computedAt).toBe("2026-05-10T12:00:00.000Z:session-1"); + invoke.mockClear(); + + const sessionListeners = on.mock.calls.filter(([channel]) => channel === IPC.sessionsChanged); + const prListeners = on.mock.calls.filter(([channel]) => channel === IPC.prsEvent); + expect(sessionListeners).toHaveLength(1); + expect(prListeners).toHaveLength(1); + + const sessionListener = sessionListeners[0]![1]; + const prListener = prListeners[0]![1]; + sessionListener({}, sessionEvent); + prListener({}, prEvent); + await bridge.sessions.getDelta("session-1"); + + expect(sessionCallbackA).toHaveBeenCalledWith(sessionEvent); + expect(sessionCallbackB).toHaveBeenCalledWith(sessionEvent); + expect(prCallbackA).toHaveBeenCalledWith(prEvent); + expect(prCallbackB).toHaveBeenCalledWith(prEvent); + expect(invoke).toHaveBeenCalledWith(IPC.sessionsGetDelta, { sessionId: "session-1" }); + + unsubscribeSessionA(); + unsubscribePrA(); + expect(removeListener).not.toHaveBeenCalledWith(IPC.sessionsChanged, sessionListener); + expect(removeListener).not.toHaveBeenCalledWith(IPC.prsEvent, prListener); + + sessionListener({}, sessionEvent); + prListener({}, prEvent); + expect(sessionCallbackA).toHaveBeenCalledTimes(1); + expect(sessionCallbackB).toHaveBeenCalledTimes(2); + expect(prCallbackA).toHaveBeenCalledTimes(1); + expect(prCallbackB).toHaveBeenCalledTimes(2); + + unsubscribeSessionB(); + unsubscribePrB(); + expect(removeListener).toHaveBeenCalledWith(IPC.sessionsChanged, sessionListener); + expect(removeListener).toHaveBeenCalledWith(IPC.prsEvent, prListener); + }); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 3958e0e87..bd249847d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1244,6 +1244,14 @@ async function callRemoteProjectRuntimeActionOr( return remote.handled ? remote.result : local(); } +function callPrReadRuntimeActionOr( + action: string, + request: Omit, + local: () => Promise, +): Promise { + return callRemoteProjectRuntimeActionOr("pr", action, request, local); +} + async function callProjectFileRuntimeActionOr( action: string, request: Omit, @@ -1379,6 +1387,51 @@ const remoteSyncStatusEventCallbacks = new Set< const remoteReviewEventCallbacks = new Set< (payload: ReviewEventPayload) => void >(); + +function createLocalIpcEventSubscription( + channel: string, + logLabel: string, + beforeEmit?: () => void, +): (cb: (payload: T) => void) => () => void { + const callbacks = new Set<(payload: T) => void>(); + let listener: ((_event: Electron.IpcRendererEvent, payload: T) => void) | null = null; + + return (cb: (payload: T) => void) => { + callbacks.add(cb); + if (!listener) { + listener = (_event: Electron.IpcRendererEvent, payload: T) => { + beforeEmit?.(); + for (const callback of [...callbacks]) { + try { + callback(payload); + } catch (error) { + console.error(`preload ${logLabel} listener failed`, error); + } + } + }; + ipcRenderer.on(channel, listener); + } + return () => { + callbacks.delete(cb); + if (callbacks.size === 0 && listener) { + ipcRenderer.removeListener(channel, listener); + listener = null; + } + }; + }; +} + +const subscribeLocalSessionChangedEvents = + createLocalIpcEventSubscription( + IPC.sessionsChanged, + "session changed", + () => sessionDeltaCache.clear(), + ); +const subscribeLocalPrEvents = createLocalIpcEventSubscription( + IPC.prsEvent, + "PR event", +); + let remoteRuntimeEventTimer: ReturnType | null = null; let remoteRuntimeEventInFlight = false; let remoteRuntimeEventCursor = 0; @@ -4893,15 +4946,11 @@ contextBridge.exposeInMainWorld("ade", { getDelta: async (sessionId: string): Promise => sessionDeltaCache.get(sessionId), onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: TerminalSessionChangedEvent, - ) => cb(payload); - ipcRenderer.on(IPC.sessionsChanged, listener); + const removeLocal = subscribeLocalSessionChangedEvents(cb); const removeRemote = subscribeRemoteSessionChangedEvents(cb); return () => { removeRemote(); - ipcRenderer.removeListener(IPC.sessionsChanged, listener); + removeLocal(); }; }, }, @@ -6842,41 +6891,41 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsLinkToLane, args), ), getForLane: async (laneId: string): Promise => - callProjectRuntimeActionOr("pr", "getForLane", { arg: laneId }, () => + callPrReadRuntimeActionOr("getForLane", { arg: laneId }, () => ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), ), listAll: async (): Promise => - callProjectRuntimeActionOr("pr", "listAll", { args: {} }, () => + callPrReadRuntimeActionOr("listAll", { args: {} }, () => ipcRenderer.invoke(IPC.prsListAll), ), listOpenForRepo: async (): Promise => - callProjectRuntimeActionOr("pr", "listOpenPullRequests", {}, () => + callPrReadRuntimeActionOr("listOpenPullRequests", {}, () => ipcRenderer.invoke(IPC.prsListOpenForRepo), ), refresh: async ( args: { prId?: string; prIds?: string[] } = {}, ): Promise => - callProjectRuntimeActionOr("pr", "refresh", { args }, () => + callPrReadRuntimeActionOr("refresh", { args }, () => ipcRenderer.invoke(IPC.prsRefresh, args), ), getStatus: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getStatus", { arg: prId }, () => + callPrReadRuntimeActionOr("getStatus", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetStatus, { prId }), ), getChecks: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getChecks", { arg: prId }, () => + callPrReadRuntimeActionOr("getChecks", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetChecks, { prId }), ), getComments: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getComments", { arg: prId }, () => + callPrReadRuntimeActionOr("getComments", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetComments, { prId }), ), getReviews: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getReviews", { arg: prId }, () => + callPrReadRuntimeActionOr("getReviews", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetReviews, { prId }), ), getReviewThreads: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getReviewThreads", { arg: prId }, () => + callPrReadRuntimeActionOr("getReviewThreads", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetReviewThreads, { prId }), ), updateDescription: async (args: UpdatePrDescriptionArgs): Promise => @@ -7036,23 +7085,21 @@ contextBridge.exposeInMainWorld("ade", { () => ipcRenderer.invoke(IPC.prsGetConflictAnalysis, { prId }), ), getMergeContext: (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getMergeContext", { arg: prId }, () => + callPrReadRuntimeActionOr("getMergeContext", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetMergeContext, { prId }), ), getMergeContexts: (prIds: string[]): Promise> => - callProjectRuntimeActionOr( - "pr", + callPrReadRuntimeActionOr( "getMergeContexts", { argsList: [prIds] }, () => ipcRenderer.invoke(IPC.prsGetMergeContexts, { prIds }), ), listWithConflicts: (args: { includeConflictAnalysis?: boolean } = {}): Promise => - callProjectRuntimeActionOr("pr", "listWithConflicts", { args }, () => + callPrReadRuntimeActionOr("listWithConflicts", { args }, () => ipcRenderer.invoke(IPC.prsListWithConflicts, args), ), listSnapshots: (args: { prId?: string } = {}): Promise => - callProjectRuntimeActionOr( - "pr", + callPrReadRuntimeActionOr( "listSnapshots", { args }, () => ipcRenderer.invoke(IPC.prsListSnapshots, args), @@ -7061,8 +7108,7 @@ contextBridge.exposeInMainWorld("ade", { force?: boolean; includeExternalClosed?: boolean; }): Promise => - callProjectRuntimeActionOr( - "pr", + callPrReadRuntimeActionOr( "getGithubSnapshot", { args: args ?? {} }, () => ipcRenderer.invoke(IPC.prsGetGitHubSnapshot, args ?? {}), @@ -7166,35 +7212,31 @@ contextBridge.exposeInMainWorld("ade", { }; }, onEvent: (cb: (ev: PrEventPayload) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: PrEventPayload, - ) => cb(payload); - ipcRenderer.on(IPC.prsEvent, listener); + const unsubscribeLocal = subscribeLocalPrEvents(cb); const unsubscribeRemote = subscribeRemotePrEvents(cb); return () => { unsubscribeRemote(); - ipcRenderer.removeListener(IPC.prsEvent, listener); + unsubscribeLocal(); }; }, getDetail: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getDetail", { arg: prId }, () => + callPrReadRuntimeActionOr("getDetail", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetDetail, { prId }), ), getFiles: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getFiles", { arg: prId }, () => + callPrReadRuntimeActionOr("getFiles", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetFiles, { prId }), ), getCommits: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getCommits", { arg: prId }, () => + callPrReadRuntimeActionOr("getCommits", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetCommits, { prId }), ), getActionRuns: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getActionRuns", { arg: prId }, () => + callPrReadRuntimeActionOr("getActionRuns", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetActionRuns, { prId }), ), getActivity: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getActivity", { arg: prId }, () => + callPrReadRuntimeActionOr("getActivity", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetActivity, { prId }), ), addComment: async (args: AddPrCommentArgs): Promise => @@ -7457,11 +7499,11 @@ contextBridge.exposeInMainWorld("ade", { () => ipcRenderer.invoke(IPC.prsCleanupIntegrationWorkflow, args), ), getDeployments: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getDeployments", { arg: prId }, () => + callPrReadRuntimeActionOr("getDeployments", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetDeployments, { prId }), ), getAiSummary: async (prId: string): Promise => - callProjectRuntimeActionOr("pr", "getAiSummary", { arg: prId }, () => + callPrReadRuntimeActionOr("getAiSummary", { arg: prId }, () => ipcRenderer.invoke(IPC.prsGetAiSummary, { prId }), ), regenerateAiSummary: async (prId: string): Promise => diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx index df2b86350..e3c8a9d99 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx @@ -382,6 +382,30 @@ describe("AgentChatPane companion drawers", () => { }); }); + it("does not mount the terminal drawer when lane tool drawers are hidden", async () => { + const session = buildSession(); + installAdeMocks(session); + seedStore(); + + render( + + + , + ); + + await screen.findByRole("textbox"); + + expect(screen.queryByRole("button", { name: /open terminal/i })).toBeNull(); + expect(globalThis.window.ade.terminal.list).not.toHaveBeenCalled(); + expect(globalThis.window.ade.appControl.getStatus).not.toHaveBeenCalled(); + }); + it("restores an archived chat from the archived selector", async () => { const active = buildSession({ sessionId: "active-session", title: "Active chat" }); const archived = buildSession({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 9bbf016a8..284935276 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1806,6 +1806,7 @@ export function AgentChatPane({ useEffect(() => { const api = window.ade?.appControl; if (!api?.getStatus) return; + if (!laneToolsVisible) return; let cancelled = false; void api.getStatus() .then((status) => { @@ -1819,7 +1820,7 @@ export function AgentChatPane({ return () => { cancelled = true; }; - }, []); + }, [laneToolsVisible]); useEffect(() => { companionHydrationKeyRef.current = companionStateKey; @@ -5908,7 +5909,7 @@ export function AgentChatPane({ ) : null} - {showWorkspaceChrome && laneId ? setTerminalDrawerOpen((v) => !v)} /> : null} + {laneToolsVisible ? setTerminalDrawerOpen((v) => !v)} /> : null} {selectedSession?.provider === "codex" && selectedSession.surface !== "mission" && selectedSessionId @@ -6868,7 +6869,7 @@ export function AgentChatPane({ sessionId={selectedSessionId} /> ) : null} - {showWorkspaceChrome ? ( + {laneToolsVisible ? ( setTerminalDrawerOpen((v) => !v)} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index c049697ce..534994b45 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -9,6 +9,7 @@ import { resolveCreateLaneRequest, resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, + runLaneDeleteBatchSequentially, selectGithubLanePrTag, selectLaneTabPrTag, selectLanePrTag, @@ -368,6 +369,48 @@ describe("selectLanePrTag", () => { }); }); +describe("runLaneDeleteBatchSequentially", () => { + it("runs independent lane deletes one at a time and preserves failures", async () => { + const lanes = [ + { id: "lane-a", parentLaneId: null }, + { id: "lane-b", parentLaneId: null }, + { id: "lane-c", parentLaneId: null }, + ]; + const order: string[] = []; + let active = 0; + let maxActive = 0; + + const results = await runLaneDeleteBatchSequentially(lanes, async (lane) => { + active += 1; + maxActive = Math.max(maxActive, active); + order.push(`start:${lane.id}`); + await Promise.resolve(); + if (lane.id === "lane-b") { + active -= 1; + order.push(`fail:${lane.id}`); + throw new Error("locked"); + } + active -= 1; + order.push(`done:${lane.id}`); + }); + + expect(maxActive).toBe(1); + expect(order).toEqual([ + "start:lane-a", + "done:lane-a", + "start:lane-b", + "fail:lane-b", + "start:lane-c", + "done:lane-c", + ]); + expect(results.map((result) => [result.lane.id, result.status])).toEqual([ + ["lane-a", "fulfilled"], + ["lane-b", "rejected"], + ["lane-c", "fulfilled"], + ]); + }); +}); + describe("selectLaneTabPrTag", () => { it("falls back to repo GitHub PRs by lane branch when no ADE PR row is mapped", () => { const githubPr = makeGitHubPr({ @@ -398,6 +441,43 @@ describe("selectLaneTabPrTag", () => { }); }); + it("uses a fresh GitHub terminal state over a stale open ADE row for the same PR", () => { + const mappedPr = makePr({ id: "mapped-pr", state: "open" }); + const githubPr = makeGitHubPr({ + id: "github-pr", + state: "merged", + linkedPrId: "mapped-pr", + linkedLaneId: "lane-1", + title: "Merged upstream", + }); + + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ + source: "github", + id: "github-pr", + linkedPrId: "mapped-pr", + state: "merged", + title: "Merged upstream", + }); + }); + + it("keeps the ADE row when the GitHub match is not the same PR", () => { + const mappedPr = makePr({ id: "mapped-pr", state: "open", githubPrNumber: 224 }); + const githubPr = makeGitHubPr({ + id: "github-pr", + state: "merged", + githubPrNumber: 999, + githubUrl: "https://github.com/arul28/ADE/pull/999", + linkedPrId: "other-pr", + linkedLaneId: "lane-1", + }); + + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ + source: "ade", + id: "mapped-pr", + state: "open", + }); + }); + it("labels GitHub-only draft PRs as draft lane tags", () => { expect(selectLaneTabPrTag(makeLane(), [], [ makeGitHubPr({ diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 0b9ecc7dd..b3c02328e 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -35,6 +35,7 @@ import { resolveLaneDeleteStartSelection, resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, + runLaneDeleteBatchSequentially, selectLaneTabPrTag, shouldApplyLaneIdsDeepLink, sortLaneListRows, @@ -1548,17 +1549,17 @@ export function LanesPage() { }); if (runnable.length === 0) continue; - const results = await Promise.allSettled( - runnable.map(async (lane) => { + const results = await runLaneDeleteBatchSequentially( + runnable, + async (lane) => { const args = deleteArgsByLaneId.get(lane.id); if (!args) return; await window.ade.lanes.delete(args); - }), + }, ); - results.forEach((result, index) => { + results.forEach((result) => { if (result.status === "fulfilled") return; - const lane = runnable[index]; - if (!lane) return; + const lane = result.lane; blockedLaneIds.add(lane.id); errors.push(`${lane.name}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`); setDeleteProgressByLaneId((prev) => { diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index 6c7fce151..a55f66eb1 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -8,6 +8,10 @@ type CreateLaneRequest = | { kind: "root"; args: { name: string; baseBranch: string } } | { kind: "import"; args: { branchRef: string; name: string; baseBranch?: string } }; +export type LaneDeleteBatchResult = + | { status: "fulfilled"; lane: T } + | { status: "rejected"; lane: T; reason: unknown }; + export type LaneTabPrTag = { source: "ade" | "github"; id: string; @@ -96,6 +100,22 @@ export function planLaneDeleteBatches( + lanes: T[], + deleteLane: (lane: T) => Promise, +): Promise[]> { + const results: LaneDeleteBatchResult[] = []; + for (const lane of lanes) { + try { + await deleteLane(lane); + results.push({ status: "fulfilled", lane }); + } catch (reason) { + results.push({ status: "rejected", lane, reason }); + } + } + return results; +} + export function laneHasAncestor>( laneId: string, ancestorLaneId: string, @@ -228,13 +248,43 @@ function toLaneTabPrTagFromGithubItem(pr: GitHubPrListItem, laneId: string): Lan }; } +function isTerminalPrState(state: PrSummary["state"]): boolean { + return state === "merged" || state === "closed"; +} + +function githubPrMatchesAdePr(pr: PrSummary, githubPr: GitHubPrListItem): boolean { + return ( + githubPr.linkedPrId === pr.id || + githubPr.githubPrNumber === pr.githubPrNumber || + githubPr.githubUrl === pr.githubUrl + ); +} + +function selectTerminalGithubUpdateForPr( + pr: PrSummary, + githubPrs: GitHubPrListItem[], +): GitHubPrListItem | null { + if (isTerminalPrState(pr.state)) return null; + return githubPrs + .filter((githubPr) => + githubPr.scope === "repo" && + isTerminalPrState(githubPr.state) && + githubPrMatchesAdePr(pr, githubPr) + ) + .sort(comparePrTags)[0] ?? null; +} + export function selectLaneTabPrTag( lane: Pick, prs: PrSummary[], githubPrs: GitHubPrListItem[], ): LaneTabPrTag | null { const mappedPr = selectLanePrTag(lane, prs); - if (mappedPr) return toLaneTabPrTagFromPrSummary(mappedPr); + if (mappedPr) { + const terminalGithubPr = selectTerminalGithubUpdateForPr(mappedPr, githubPrs); + if (terminalGithubPr) return toLaneTabPrTagFromGithubItem(terminalGithubPr, lane.id); + return toLaneTabPrTagFromPrSummary(mappedPr); + } const githubPr = selectGithubLanePrTag(lane, githubPrs); return githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id) : null; } diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index 5f21ff955..52d2c7aab 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -492,8 +492,12 @@ function PRsPageInner() { } export function PRsPage() { + const providerKey = useAppStore((state) => + state.projectBinding?.key ?? state.project?.rootPath ?? "__no_project__" + ); + return ( - + ); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 62aef3c75..284255242 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -308,6 +308,7 @@ function renderPane(args: { getDetail?: ReturnType; getFiles?: ReturnType; getCommits?: ReturnType; + getActionRuns?: ReturnType; snapshotHydration?: PrSnapshotHydration | null; snapshotHydrationOwnedByContext?: boolean; liveDetailReady?: boolean; @@ -334,12 +335,14 @@ function renderPane(args: { const aiResolutionStop = vi.fn().mockResolvedValue(undefined); const getReviewThreads = vi.fn().mockResolvedValue(args.reviewThreads); const getChecks = vi.fn().mockResolvedValue(args.freshChecks ?? args.checks); - const getActionRuns = vi.fn(); - if (args.freshActionRuns) { - getActionRuns.mockResolvedValueOnce(args.actionRuns ?? []); - getActionRuns.mockResolvedValue(args.freshActionRuns); - } else { - getActionRuns.mockResolvedValue(args.actionRuns ?? []); + const getActionRuns = args.getActionRuns ?? vi.fn(); + if (!args.getActionRuns) { + if (args.freshActionRuns) { + getActionRuns.mockResolvedValueOnce(args.actionRuns ?? []); + getActionRuns.mockResolvedValue(args.freshActionRuns); + } else { + getActionRuns.mockResolvedValue(args.actionRuns ?? []); + } } const getStatus = vi.fn().mockResolvedValue(args.statusOverrides ? makeStatus(args.statusOverrides) : makeStatus()); const issueInventorySync = vi.fn().mockResolvedValue({ @@ -531,6 +534,8 @@ function renderPane(args: { let currentChecks = args.checks; let currentReviews = args.reviews ?? []; let currentComments = args.comments ?? []; + let currentSnapshotHydration = args.snapshotHydration; + let currentLiveDetailReady = args.liveDetailReady; const renderSubject = (prOverrides: Partial = args.prOverrides ?? {}) => ( , nextDetail?: { checks?: PrCheck[]; reviews?: PrReview[]; comments?: PrComment[] }) => { + rerenderPane: (prOverrides: Partial, nextDetail?: { + checks?: PrCheck[]; + reviews?: PrReview[]; + comments?: PrComment[]; + snapshotHydration?: PrSnapshotHydration | null; + liveDetailReady?: boolean; + }) => { currentChecks = nextDetail?.checks ?? currentChecks; currentReviews = nextDetail?.reviews ?? currentReviews; currentComments = nextDetail?.comments ?? currentComments; + if (nextDetail && "snapshotHydration" in nextDetail) { + currentSnapshotHydration = nextDetail.snapshotHydration; + } + if (nextDetail && "liveDetailReady" in nextDetail) { + currentLiveDetailReady = nextDetail.liveDetailReady; + } rendered.rerender(renderSubject({ ...(args.prOverrides ?? {}), ...prOverrides })); }, ...rendered, @@ -618,6 +635,30 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); + it("renders live PR detail without waiting for slow action-run hydration", async () => { + const user = userEvent.setup(); + renderPane({ + checks: [], + reviewThreads: [], + getFiles: vi.fn().mockResolvedValue([ + { + filename: "src/pr-detail.ts", + status: "modified", + additions: 3, + deletions: 1, + patch: null, + previousFilename: null, + }, + ]), + getActionRuns: vi.fn(() => new Promise(() => {})), + }); + + await user.click(screen.getByRole("button", { name: /files/i })); + await waitFor(() => { + expect(screen.getByText("src/pr-detail.ts")).toBeTruthy(); + }); + }); + it("shows the resolve action in the checks tab when issues are actionable", async () => { const user = userEvent.setup(); renderPane({ @@ -834,6 +875,51 @@ describe("PrDetailPane issue resolver CTA", () => { expect(listSnapshots).not.toHaveBeenCalled(); }); + it("does not let late context snapshot hydration overwrite live rich detail", async () => { + const freshDetail = { + prId: "pr-80", + body: "Fresh live body", + labels: [{ name: "fresh-label", color: "22c55e", description: null }], + assignees: [], + requestedReviewers: [], + author: { login: "octocat", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + }; + const staleSnapshot: PrSnapshotHydration = { + prId: "pr-80", + detail: { + ...freshDetail, + body: "Stale cached body", + labels: [{ name: "stale-label", color: "ef4444", description: null }], + }, + status: makeStatus({ checksStatus: "passing", reviewStatus: "approved" }), + checks: [], + reviews: [], + comments: [], + files: [], + commits: [], + updatedAt: "2026-03-23T12:01:00.000Z", + }; + const { rerenderPane } = renderPane({ + checks: [], + reviewThreads: [], + getDetail: vi.fn().mockResolvedValue(freshDetail), + snapshotHydration: null, + snapshotHydrationOwnedByContext: true, + }); + + await waitFor(() => { + expect(screen.getByText("fresh-label")).toBeTruthy(); + }); + + rerenderPane({}, { snapshotHydration: staleSnapshot }); + + expect(screen.getByText("fresh-label")).toBeTruthy(); + expect(screen.queryByText("stale-label")).toBeNull(); + }); + it("prefers authoritative empty live detail over cached snapshot data", async () => { const user = userEvent.setup(); const listSnapshots = vi.fn().mockResolvedValue([{ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index de5469d15..ac49a9196 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -842,15 +842,24 @@ export function PrDetailPane({ const detailStatusRefreshKeyRef = React.useRef(null); const inventoryLoadSeqRef = React.useRef(0); const snapshotHydrationRef = React.useRef(snapshotHydration); + const liveDetailLoadedForPrRef = React.useRef(null); + const liveFilesLoadedForPrRef = React.useRef(null); + const liveCommitsLoadedForPrRef = React.useRef(null); const applySnapshotHydration = React.useCallback((cachedSnapshot: PrSnapshotHydration) => { - if (cachedSnapshot.detail) setDetail(cachedSnapshot.detail); - if (cachedSnapshot.status) setSnapshotStatus(cachedSnapshot.status); - if (cachedSnapshot.checks.length > 0) setSnapshotChecks(cachedSnapshot.checks); - if (cachedSnapshot.reviews.length > 0) setSnapshotReviews(cachedSnapshot.reviews); - if (cachedSnapshot.comments.length > 0) setSnapshotComments(cachedSnapshot.comments); - if (cachedSnapshot.files.length > 0) setFiles(cachedSnapshot.files); - if (cachedSnapshot.commits.length > 0) setCommits(cachedSnapshot.commits); + setSnapshotStatus(cachedSnapshot.status); + setSnapshotChecks(cachedSnapshot.checks); + setSnapshotReviews(cachedSnapshot.reviews); + setSnapshotComments(cachedSnapshot.comments); + if (liveDetailLoadedForPrRef.current !== cachedSnapshot.prId) { + setDetail(cachedSnapshot.detail); + } + if (liveFilesLoadedForPrRef.current !== cachedSnapshot.prId) { + setFiles(cachedSnapshot.files); + } + if (liveCommitsLoadedForPrRef.current !== cachedSnapshot.prId) { + setCommits(cachedSnapshot.commits); + } }, []); React.useEffect(() => { @@ -872,17 +881,34 @@ export function PrDetailPane({ applySnapshotHydration(cachedSnapshot); } } - const [d, f, c, a] = await Promise.all([ - window.ade.prs.getDetail(pr.id).catch(() => null), - window.ade.prs.getFiles(pr.id).catch(() => []), - (window.ade.prs.getCommits?.(pr.id) ?? Promise.resolve([])).catch(() => []), - window.ade.prs.getActionRuns(pr.id).catch(() => []), - ]); - if (requestId !== detailLoadSeqRef.current) return; - setDetail(d); - setFiles(f); - setCommits(c); - setActionRuns(a); + const applyIfCurrent = (apply: (value: T) => void) => (value: T) => { + if (requestId === detailLoadSeqRef.current) apply(value); + return value; + }; + const detailPromise = window.ade.prs.getDetail(pr.id) + .then(applyIfCurrent((value) => { + liveDetailLoadedForPrRef.current = pr.id; + setDetail(value); + })) + .catch(() => null); + const filesPromise = window.ade.prs.getFiles(pr.id) + .then(applyIfCurrent((value) => { + liveFilesLoadedForPrRef.current = pr.id; + setFiles(value); + })) + .catch(() => []); + const commitsPromise = (typeof window.ade.prs.getCommits === "function" + ? window.ade.prs.getCommits(pr.id) + .then(applyIfCurrent((value) => { + liveCommitsLoadedForPrRef.current = pr.id; + setCommits(value); + })) + .catch(() => []) + : Promise.resolve([])); + const actionRunsPromise = window.ade.prs.getActionRuns(pr.id) + .then(applyIfCurrent((value) => setActionRuns(value))) + .catch(() => []); + await Promise.allSettled([detailPromise, filesPromise, commitsPromise, actionRunsPromise]); } catch { // silently fail - basic data still available from context } @@ -928,6 +954,17 @@ export function PrDetailPane({ setShowReviewerEditor(false); setShowReviewModal(false); setActivity([]); + liveDetailLoadedForPrRef.current = null; + liveFilesLoadedForPrRef.current = null; + liveCommitsLoadedForPrRef.current = null; + setDetail(null); + setFiles([]); + setCommits([]); + setActionRuns([]); + setSnapshotStatus(null); + setSnapshotChecks([]); + setSnapshotReviews([]); + setSnapshotComments([]); const requestId = ++convergenceLoadSeqRef.current; const cachedRuntime = cachedConvergenceRuntimeRef.current; diff --git a/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts b/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts index 1b628e518..d5e680c5e 100644 --- a/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts +++ b/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts @@ -127,17 +127,16 @@ describe("prsRouteState", () => { ).toBe("?tab=workflows&workflow=rebase&laneId=lane-456"); }); - it("stores the last PRs route per project and falls back to the legacy global key", () => { + it("stores the last PRs route per project without falling back across projects", () => { writeStoredPrsRoute("/prs?tab=normal&prId=project-a", "/tmp/project-a"); writeStoredPrsRoute("/prs?tab=normal&prId=project-b", "/tmp/project-b"); writeStoredPrsRoute("/files", "/tmp/project-b"); + writeStoredPrsRoute("/prs?tab=workflows&workflow=queue"); expect(readStoredPrsRoute("/tmp/project-a")).toBe("/prs?tab=normal&prId=project-a"); expect(readStoredPrsRoute("/tmp/project-b")).toBe("/prs?tab=normal&prId=project-b"); expect(readStoredPrsRoute("/tmp/project-c")).toBeNull(); - - writeStoredPrsRoute("/prs?tab=workflows&workflow=queue"); - expect(readStoredPrsRoute("/tmp/project-c")).toBe("/prs?tab=workflows&workflow=queue"); + expect(readStoredPrsRoute()).toBe("/prs?tab=workflows&workflow=queue"); }); }); diff --git a/apps/desktop/src/renderer/components/prs/prsRouteState.ts b/apps/desktop/src/renderer/components/prs/prsRouteState.ts index 278d14d09..e9cfa9d6a 100644 --- a/apps/desktop/src/renderer/components/prs/prsRouteState.ts +++ b/apps/desktop/src/renderer/components/prs/prsRouteState.ts @@ -101,9 +101,11 @@ export function sanitizeStoredPrsRoute(value: string | null | undefined): string export function readStoredPrsRoute(projectRoot?: string | null): string | null { if (typeof window === "undefined") return null; + const root = projectRoot?.trim(); try { - const scopedRoute = sanitizeStoredPrsRoute(window.localStorage.getItem(scopedPrsRouteStorageKey(projectRoot))); - if (scopedRoute) return scopedRoute; + if (root) { + return sanitizeStoredPrsRoute(window.localStorage.getItem(scopedPrsRouteStorageKey(root))); + } return sanitizeStoredPrsRoute(window.localStorage.getItem(PRS_LAST_ROUTE_STORAGE_KEY)); } catch { return null; diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index a91375cdf..b1a285842 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -81,6 +81,7 @@ function DetailHarness() { detailChecks, detailReviews, detailComments, + detailLiveDataPrId, detailStatus, loading, selectedPrId, @@ -97,6 +98,7 @@ function DetailHarness() {
{loading ? "loading" : "idle"}
{detailBusy ? "busy" : "idle"}
{selectedPrId ?? ""}
+
{detailLiveDataPrId ?? ""}
{detailStatus?.state ?? ""}
{detailChecks.length}
{detailReviews.length}
@@ -105,6 +107,28 @@ function DetailHarness() { ); } +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function enqueueDeferred(store: Record[]>, prId: string): Deferred { + const request = createDeferred(); + store[prId] = [...(store[prId] ?? []), request]; + return request; +} + function MergeContextHarness() { const { loading, mergeContextByPrId } = usePrs(); return ( @@ -548,6 +572,124 @@ describe("PrsContext refresh", () => { }); }); + it("applies selected PR status and checks without waiting for slow comments", async () => { + const user = userEvent.setup(); + vi.mocked(window.ade.prs.listWithConflicts).mockResolvedValue([makeFakePr("pr-1")]); + Object.assign(window.ade.prs, { + listSnapshots: vi.fn(async () => []), + getStatus: vi.fn(async () => ({ state: "open" })), + getChecks: vi.fn(async () => [ + { + name: "ci", + status: "completed", + conclusion: "success", + detailsUrl: null, + startedAt: null, + completedAt: null, + }, + ]), + getReviews: vi.fn(async () => []), + getComments: vi.fn((_prId: string) => new Promise(() => {})), + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + await user.click(screen.getByRole("button", { name: "select pr-1" })); + await waitFor(() => { + expect(screen.getByTestId("status").textContent).toBe("open"); + expect(screen.getByTestId("checks-count").textContent).toBe("1"); + expect(screen.getByTestId("detail-busy").textContent).toBe("idle"); + }); + expect(window.ade.prs.getComments).toHaveBeenCalledWith("pr-1"); + }); + + it("ignores stale primary detail settlements after reselecting the same PR", async () => { + const user = userEvent.setup(); + vi.mocked(window.ade.prs.listWithConflicts).mockResolvedValue([makeFakePr("pr-1"), makeFakePr("pr-2")]); + const statusRequests: Record[]> = {}; + const checksRequests: Record[]> = {}; + const reviewsRequests: Record[]> = {}; + const commentsRequests: Record[]> = {}; + Object.assign(window.ade.prs, { + listSnapshots: vi.fn(async () => []), + getStatus: vi.fn((prId: string) => enqueueDeferred(statusRequests, prId).promise), + getChecks: vi.fn((prId: string) => enqueueDeferred(checksRequests, prId).promise), + getReviews: vi.fn((prId: string) => enqueueDeferred(reviewsRequests, prId).promise), + getComments: vi.fn((prId: string) => enqueueDeferred(commentsRequests, prId).promise), + }); + + const resolveDetailSet = async (prId: string, index: number, state: string) => { + const status = statusRequests[prId]?.[index]; + const checks = checksRequests[prId]?.[index]; + const reviews = reviewsRequests[prId]?.[index]; + const comments = commentsRequests[prId]?.[index]; + if (!status || !checks || !reviews || !comments) { + throw new Error(`Missing detail requests for ${prId} #${index}`); + } + await act(async () => { + status.resolve({ state }); + checks.resolve([ + { + name: `ci-${state}`, + status: "completed", + conclusion: state === "open" ? "success" : "failure", + detailsUrl: null, + startedAt: null, + completedAt: null, + }, + ]); + reviews.resolve([]); + comments.resolve([]); + await Promise.all([status.promise, checks.promise, reviews.promise, comments.promise]); + }); + }; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + await user.click(screen.getByRole("button", { name: "select pr-1" })); + await waitFor(() => { + expect(statusRequests["pr-1"]).toHaveLength(1); + }); + await user.click(screen.getByRole("button", { name: "select pr-2" })); + await waitFor(() => { + expect(statusRequests["pr-2"]).toHaveLength(1); + }); + await user.click(screen.getByRole("button", { name: "select pr-1" })); + await waitFor(() => { + expect(statusRequests["pr-1"]).toHaveLength(2); + }); + + await resolveDetailSet("pr-1", 1, "open"); + await waitFor(() => { + expect(screen.getByTestId("live-detail-pr-id").textContent).toBe("pr-1"); + expect(screen.getByTestId("status").textContent).toBe("open"); + }); + + await resolveDetailSet("pr-1", 0, "closed"); + await waitFor(() => { + expect(screen.getByTestId("live-detail-pr-id").textContent).toBe("pr-1"); + expect(screen.getByTestId("status").textContent).toBe("open"); + }); + + await resolveDetailSet("pr-2", 0, "closed"); + }); + it("does not let cached snapshot hydration overwrite live detail data", async () => { const user = userEvent.setup(); vi.mocked(window.ade.prs.listWithConflicts).mockResolvedValue([makeFakePr("pr-1")]); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 3b2b42e42..9059a7e40 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -1068,6 +1068,7 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { let cancelled = false; let liveDetailApplied = false; + let snapshotForRequest: PrSnapshotHydration | null = null; const prId = selectedPrId; const cachedDetailAgeMs = Date.now() - (detailLoadedAtByPrIdRef.current[prId] ?? 0); const hasFreshDetailCache = @@ -1106,6 +1107,7 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { setDetailBusy(false); return; } + snapshotForRequest = snapshot; detailSnapshotStatePrIdRef.current = prId; detailSnapshotLoadedAtByPrIdRef.current[prId] = Date.now(); setDetailSnapshot(snapshot); @@ -1134,65 +1136,80 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { setDetailBusy(true); detailFetchInProgress.current = true; - Promise.allSettled([ - window.ade.prs.getStatus(prId), - window.ade.prs.getChecks(prId), - window.ade.prs.getReviews(prId), - window.ade.prs.getComments(prId), - ]) - .then(([statusResult, checksResult, reviewsResult, commentsResult]) => { - if (cancelled) return; - // Check for rate-limit errors in any rejected result - for (const result of [statusResult, checksResult, reviewsResult, commentsResult]) { - if (result.status === "rejected") { - const msg = String(result.reason?.message ?? result.reason); - if (msg.includes("rate limit") || msg.includes("API rate")) { - rateLimitedUntilRef.current = Date.now() + 5 * 60_000; - console.warn("[PrsContext] GitHub rate limit hit — pausing detail polling for 5 min"); - // Keep cached snapshot/detail data visible while GitHub is degraded. - setDetailLiveDataPrId(null); - return; + let primarySettledCount = 0; + let primaryFulfilledCount = 0; + let rateLimited = false; + const primaryRequestCount = 4; + const markPrimarySettled = (fulfilled: boolean) => { + primarySettledCount += 1; + if (fulfilled) primaryFulfilledCount += 1; + if (selectedPrIdRef.current === prId && primarySettledCount === 1) { + detailFetchInProgress.current = false; + } + if (selectedPrIdRef.current === prId && primarySettledCount === primaryRequestCount) { + detailFetchInProgress.current = false; + setDetailLiveDataPrId(primaryFulfilledCount === primaryRequestCount ? prId : null); + } + }; + const isRateLimitError = (error: unknown): boolean => { + const msg = String((error as { message?: unknown } | null)?.message ?? error); + return msg.includes("rate limit") || msg.includes("API rate"); + }; + const loadPrimaryPiece = ( + name: string, + promise: Promise, + apply: (value: T) => void, + ) => { + let fulfilled = false; + promise + .then((value) => { + if (cancelled || selectedPrIdRef.current !== prId) return; + if (rateLimited) return; + fulfilled = true; + if (value != null && (!Array.isArray(value) || value.length > 0)) { + liveDetailApplied = true; + } + detailStatePrIdRef.current = prId; + detailLoadedAtByPrIdRef.current[prId] = Date.now(); + apply(value); + setDetailBusy(false); + }) + .catch((error: unknown) => { + if (cancelled || selectedPrIdRef.current !== prId) return; + if (isRateLimitError(error)) { + rateLimited = true; + rateLimitedUntilRef.current = Date.now() + 5 * 60_000; + console.warn("[PrsContext] GitHub rate limit hit — pausing detail polling for 5 min"); + if (snapshotForRequest?.prId === prId) { + setDetailStatus(snapshotForRequest.status); + setDetailChecks(snapshotForRequest.checks); + setDetailReviews(snapshotForRequest.reviews); + setDetailComments(snapshotForRequest.comments); } + setDetailLiveDataPrId(null); + } else { + console.warn(`[PrsContext] Failed to load PR ${name}:`, error); } - } + setDetailBusy(false); + }) + .finally(() => { + if (cancelled) return; + markPrimarySettled(fulfilled); + }); + }; - liveDetailApplied = true; - detailStatePrIdRef.current = prId; - if (statusResult.status === "fulfilled") { - setDetailStatus(statusResult.value ?? null); - } else { - console.warn("[PrsContext] Failed to load PR status:", statusResult.reason); - setDetailStatus(null); - } - if (checksResult.status === "fulfilled") { - setDetailChecks(checksResult.value); - } else { - console.warn("[PrsContext] Failed to load PR checks:", checksResult.reason); - setDetailChecks([]); - } - if (reviewsResult.status === "fulfilled") { - setDetailReviews(reviewsResult.value); - } else { - console.warn("[PrsContext] Failed to load PR reviews:", reviewsResult.reason); - setDetailReviews([]); - } - if (commentsResult.status === "fulfilled") { - setDetailComments(commentsResult.value); - } else { - console.warn("[PrsContext] Failed to load PR comments:", commentsResult.reason); - setDetailComments([]); - } - if ([statusResult, checksResult, reviewsResult, commentsResult].every((result) => result.status === "fulfilled")) { - setDetailLiveDataPrId(prId); - } else { - setDetailLiveDataPrId(null); - } - detailLoadedAtByPrIdRef.current[prId] = Date.now(); - }) - .finally(() => { - detailFetchInProgress.current = false; - if (!cancelled) setDetailBusy(false); - }); + loadPrimaryPiece("status", window.ade.prs.getStatus(prId), (value) => { + setDetailStatus(value ?? null); + }); + loadPrimaryPiece("checks", window.ade.prs.getChecks(prId), (value) => { + setDetailChecks(value); + }); + loadPrimaryPiece("reviews", window.ade.prs.getReviews(prId), (value) => { + setDetailReviews(value); + }); + loadPrimaryPiece("comments", window.ade.prs.getComments(prId), (value) => { + setDetailComments(value); + }); // Progressive secondary fetch (review threads, deployments, AI summary) — yields // to the main paint so the primary header + checks render first. diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx index f02990c78..03187d8a2 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx @@ -229,6 +229,34 @@ describe("GitHubTab", () => { expect(screen.getByRole("button", { name: /connect github/i })).toBeTruthy(); }); + it("ignores cross-repo external PRs from legacy snapshots", async () => { + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue({ + ...snapshot, + externalPullRequests: [ + makeGitHubPr({ + id: "external-other-repo", + scope: "external", + repoOwner: "elsewhere", + repoName: "other-project", + githubPrNumber: 901, + githubUrl: "https://github.com/elsewhere/other-project/pull/901", + title: "Other project PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + }), + ], + }); + + renderTab(); + + await waitFor(() => { + expect(screen.getByText("Open PR")).toBeTruthy(); + }); + expect(screen.queryByText("Other project PR")).toBeNull(); + }); + it("passes queue context into the normal PR detail pane", async () => { renderTab({ selectedPrId: "pr-queue" }); @@ -237,6 +265,27 @@ describe("GitHubTab", () => { }); }); + it("uses the GitHub read-only detail when a linked PR has no local project id yet", async () => { + mockUsePrs.mockReturnValue({ + prs: [], + mergeContextByPrId: {}, + detailStatus: null, + detailChecks: [], + detailReviews: [], + detailComments: [], + detailBusy: false, + loading: true, + setViewerLogin: vi.fn(), + }); + + renderTab({ selectedPrId: "pr-open" }); + + await waitFor(() => { + expect(screen.getByText("Open PR")).not.toBeNull(); + }); + expect(screen.queryByTestId("pr-detail-pane")).toBeNull(); + }); + it("shows a running CI indicator for PR cards with pending checks", async () => { renderTab(); diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index 551849601..489c8b9e6 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -2,7 +2,7 @@ import React from "react"; import { ArrowsClockwise, ArrowSquareOut, ChatText, CheckCircle, CircleNotch, GitMerge, GithubLogo, Link, MagnifyingGlass, Warning, XCircle } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; -import type { GitHubPrListItem, GitHubPrSnapshot, LaneSummary, MergeMethod, PrSummary } from "../../../../shared/types"; +import type { GitHubPrListItem, GitHubPrSnapshot, LaneSummary, MergeMethod, PrSummary, PrWithConflicts } from "../../../../shared/types"; import { EmptyState } from "../../ui/EmptyState"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton } from "../../lanes/laneDesignTokens"; import { isMissionResultLane } from "../../lanes/laneUtils"; @@ -457,6 +457,7 @@ export function GitHubTab({ const inFlightSnapshotRef = React.useRef<{ request: Promise; includeExternalClosed: boolean } | null>(null); const loadingSnapshotRequestCountRef = React.useRef(0); const lastSnapshotLoadedAtRef = React.useRef(initialWarmCacheRef.current?.cachedAt ?? 0); + const missingLinkedPrHydrationRef = React.useRef(null); const filterRef = React.useRef(filter); const externalHistoryLoadedRef = React.useRef(externalHistoryLoaded); const projectRootRef = React.useRef(projectRoot); @@ -657,7 +658,7 @@ export function GitHubTab({ }, [searchQuery]); const allItems = React.useMemo( - () => [...(snapshot?.repoPullRequests ?? []), ...(snapshot?.externalPullRequests ?? [])], + () => snapshot?.repoPullRequests ?? [], [snapshot], ); @@ -738,9 +739,50 @@ export function GitHubTab({ () => allItems.find((item) => item.id === selectedItemId) ?? null, [allItems, selectedItemId], ); + const missingLinkedPrId = selectedItem?.linkedPrId && !prsByIdMap.has(selectedItem.linkedPrId) + ? selectedItem.linkedPrId + : null; + + React.useEffect(() => { + if (!missingLinkedPrId) { + missingLinkedPrHydrationRef.current = null; + return; + } + if (missingLinkedPrHydrationRef.current === missingLinkedPrId) return; + missingLinkedPrHydrationRef.current = missingLinkedPrId; + void onRefreshAll({ prId: missingLinkedPrId }).catch(() => {}); + }, [missingLinkedPrId, onRefreshAll]); const selectedLinkedPr = React.useMemo( - () => (selectedItem?.linkedPrId ? prs.find((pr) => pr.id === selectedItem.linkedPrId) ?? null : null), + (): PrWithConflicts | null => { + if (!selectedItem?.linkedPrId) return null; + const linked = prs.find((pr) => pr.id === selectedItem.linkedPrId); + if (linked) return linked; + const fallbackProjectId = prs[0]?.projectId; + if (!fallbackProjectId) return null; + return { + id: selectedItem.linkedPrId, + laneId: selectedItem.linkedLaneId ?? "", + projectId: fallbackProjectId, + repoOwner: selectedItem.repoOwner, + repoName: selectedItem.repoName, + githubPrNumber: selectedItem.githubPrNumber, + githubUrl: selectedItem.githubUrl, + githubNodeId: null, + title: selectedItem.title, + state: selectedItem.state, + baseBranch: selectedItem.baseBranch ?? "", + headBranch: selectedItem.headBranch ?? "", + checksStatus: "none", + reviewStatus: "none", + additions: 0, + deletions: 0, + lastSyncedAt: null, + createdAt: selectedItem.createdAt, + updatedAt: selectedItem.updatedAt, + conflictAnalysis: null, + }; + }, [prs, selectedItem], ); const selectedQueueContext = React.useMemo(() => { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index e1ce8e714..6648f789a 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -218,9 +218,19 @@ enum InitialHydrationGate { enum SyncRequestTimeout { static let defaultTimeoutNanoseconds: UInt64 = 30_000_000_000 static let chatSendTimeoutNanoseconds: UInt64 = 120_000_000_000 + static let laneDeleteTimeoutNanoseconds: UInt64 = 240_000_000_000 static let message = "The machine took too long to respond. Reconnecting now." static let chatSendMessage = "The machine is still starting this chat turn. Live updates will keep syncing." + static func commandTimeoutNanoseconds(for action: String) -> UInt64 { + switch action { + case "lanes.delete": + return laneDeleteTimeoutNanoseconds + default: + return defaultTimeoutNanoseconds + } + } + static func error(message: String = Self.message, underlyingError: Error? = nil) -> NSError { var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message] if let underlyingError { @@ -6549,17 +6559,18 @@ final class SyncService: ObservableObject { commandId: String? = nil, disconnectOnTimeout: Bool = true, timeoutMessage: String = SyncRequestTimeout.message, - timeoutNanoseconds: UInt64 = SyncRequestTimeout.defaultTimeoutNanoseconds + timeoutNanoseconds: UInt64? = nil ) async throws -> Any { guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) } let requestId = commandId ?? makeRequestId() + let effectiveTimeoutNanoseconds = timeoutNanoseconds ?? SyncRequestTimeout.commandTimeoutNanoseconds(for: action) let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: disconnectOnTimeout, timeoutMessage: timeoutMessage, - timeoutNanoseconds: timeoutNanoseconds + timeoutNanoseconds: effectiveTimeoutNanoseconds ) { self.sendEnvelope( type: "command", @@ -6581,7 +6592,7 @@ final class SyncService: ObservableObject { args: [String: Any], disconnectOnTimeout: Bool = true, timeoutMessage: String = SyncRequestTimeout.message, - timeoutNanoseconds: UInt64 = SyncRequestTimeout.defaultTimeoutNanoseconds + timeoutNanoseconds: UInt64? = nil ) async throws -> Any { let commandId = makeRequestId() if canSendLiveRequests() { diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index 8bbf5aa9c..d8f3f916f 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -895,13 +895,14 @@ struct PrDetailView: View { pr = listItems.first(where: { $0.id == prId }) snapshot = try await snapshotTask - // Fall back to the GitHub snapshot when the PR isn't in the lane-PR list - // (e.g. external PRs or stale local cache). This keeps the hero card from - // collapsing into "Pull request / @unknown" placeholders. + // Fall back to the repo-scoped GitHub snapshot when the PR isn't in the + // lane-PR list. This keeps the hero card from collapsing into + // "Pull request / @unknown" placeholders without resurrecting legacy + // cross-repo snapshot items. if pr == nil && isLive { if let github = try? await syncService.fetchGitHubPullRequestSnapshot() { - let all = github.repoPullRequests + github.externalPullRequests - githubItem = all.first { $0.linkedPrId == prId || $0.id == prId } + githubItem = repoScopedGitHubPullRequests(from: github) + .first { $0.linkedPrId == prId || $0.id == prId } } } reviewThreads = await reviewThreadsTask?.value ?? [] diff --git a/apps/ios/ADE/Views/PRs/PrHelpers.swift b/apps/ios/ADE/Views/PRs/PrHelpers.swift index 91e8296c0..0cf288ca9 100644 --- a/apps/ios/ADE/Views/PRs/PrHelpers.swift +++ b/apps/ios/ADE/Views/PRs/PrHelpers.swift @@ -228,6 +228,10 @@ func filterPullRequestListItems( } } +func repoScopedGitHubPullRequests(from snapshot: GitHubPrSnapshot?) -> [GitHubPrListItem] { + snapshot?.repoPullRequests ?? [] +} + func matchesPullRequestListItemStatus(_ item: PullRequestListItem, state: PrGitHubStatusFilter) -> Bool { switch state { case .all: diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 35fd56544..1d9a32b2f 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -121,7 +121,7 @@ struct PRsTabView: View { } private var allGitHubPrs: [GitHubPrListItem] { - (githubSnapshot?.repoPullRequests ?? []) + (githubSnapshot?.externalPullRequests ?? []) + repoScopedGitHubPullRequests(from: githubSnapshot) } private var filteredGitHubPrs: [GitHubPrListItem] { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 4147f232a..c53b5c543 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -260,6 +260,8 @@ final class ADETests: XCTestCase { func testSyncRequestTimeoutUsesThirtySecondFriendlyReconnectMessage() { XCTAssertEqual(SyncRequestTimeout.defaultTimeoutNanoseconds, 30_000_000_000) XCTAssertEqual(SyncRequestTimeout.chatSendTimeoutNanoseconds, 120_000_000_000) + XCTAssertEqual(SyncRequestTimeout.commandTimeoutNanoseconds(for: "lanes.delete"), 240_000_000_000) + XCTAssertEqual(SyncRequestTimeout.commandTimeoutNanoseconds(for: "lanes.rename"), 30_000_000_000) XCTAssertEqual(SyncRequestTimeout.error().localizedDescription, "The machine took too long to respond. Reconnecting now.") XCTAssertEqual( SyncRequestTimeout.error(message: SyncRequestTimeout.chatSendMessage).localizedDescription, @@ -3802,6 +3804,51 @@ final class ADETests: XCTestCase { XCTAssertEqual(filterPullRequestListItems(items, query: "", state: .open).map(\.id), ["pr-1"]) } + func testRepoScopedGitHubPullRequestsIgnoreLegacyExternalHistory() { + func githubItem(id: String, scope: String, owner: String, repo: String, number: Int) -> GitHubPrListItem { + GitHubPrListItem( + id: id, + scope: scope, + repoOwner: owner, + repoName: repo, + githubPrNumber: number, + githubUrl: "https://github.com/\(owner)/\(repo)/pull/\(number)", + title: "PR \(number)", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "feature/\(number)", + author: "octocat", + createdAt: "2026-05-14T00:00:00.000Z", + updatedAt: "2026-05-14T00:00:00.000Z", + linkedPrId: nil, + linkedGroupId: nil, + linkedLaneId: nil, + linkedLaneName: nil, + adeKind: nil, + workflowDisplayState: nil, + cleanupState: nil, + labels: [], + isBot: false, + commentCount: 0 + ) + } + + let snapshot = GitHubPrSnapshot( + repo: GitHubRepoRef(owner: "arul", name: "ADE", defaultBranch: "main"), + viewerLogin: "octocat", + repoPullRequests: [ + githubItem(id: "repo-pr", scope: "repo", owner: "arul", repo: "ADE", number: 10), + ], + externalPullRequests: [ + githubItem(id: "external-pr", scope: "external", owner: "elsewhere", repo: "other", number: 20), + ], + syncedAt: "2026-05-14T00:00:00.000Z" + ) + + XCTAssertEqual(repoScopedGitHubPullRequests(from: snapshot).map(\.id), ["repo-pr"]) + } + func testPrLinkLanePreselectionRequiresExactBranchMatch() { func lane(id: String, name: String, branchRef: String) -> LaneSummary { LaneSummary( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21b2ff4e5..cace14a3e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,7 +148,7 @@ Terminal-native **Work** chat client (Ink + React) for agents and power users wh - **Attached mode** (default): connects to `~/.ade/sock/ade.sock`, or to an explicit socket passed on the parent `ade` invocation. Starts `ade serve` if the socket is missing. - **Embedded mode**: `--embedded` / `--headless` runs the shared `apps/ade-cli` services in-process without going through a daemon. Used when no daemon is reachable. -Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. +Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The built TUI bundle is intended to run in isolation: tsup bundles its Ink/xterm/highlight dependencies and injects ESM shims for `__dirname` / `__filename`; both `apps/ade-cli/scripts/verify-built-cli.mjs` and the desktop artifact validators smoke-import it and run `runAdeCodeCli(["--help"])`. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. ### 2.4 iOS client (`apps/ios/`) @@ -410,7 +410,7 @@ ade.updates.* `apps/desktop/src/main/services/ipc/registerIpc.ts` (~6,400 lines) is the single registration point: - `ipcMain.handle(IPC.channelName, async (event, args) => { ... })` for invoke channels. -- Every handler is wrapped with a **30-second timeout** — if it does not resolve, the call rejects with a timeout error rather than hanging the renderer. +- Every handler is wrapped with a timeout — 30 seconds by default, with explicit longer budgets for known long operations such as lane delete, iOS Simulator launch/control, macOS VM provisioning, App Control, and built-in browser actions. Runtime-dispatched `lane.delete` calls get the same 4-minute budget as the direct `ade.lanes.delete` IPC. - Every handler emits structured tracing: `ipc.invoke.begin`, `ipc.invoke.done`, `ipc.invoke.failed` with call ID, channel, window ID, duration, and summarized args/results. - `AppContext` indirection: handlers close over a context pointer that swaps atomically on project switch, so IPC channels remain registered across project transitions. - **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its project context before routing into services. @@ -465,7 +465,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `github/` | `githubService.ts` | GitHub REST/GraphQL access; PR CRUD; checks; reviewers. | | `history/` | `operationService.ts` | Operation audit records (one row per mutation). | | `ios/` | `iosSimulatorService.ts` | macOS-only iOS Simulator backend: tool readiness probes, simctl device + app discovery, build/install/launch with progress events (hardened with `simctl bootstatus` and `simctl install` timeouts), screenshot + ADEInspector + accessibility hit-test, IOSurface/Indigo primary streaming and input with idb/simctl/window-capture fallbacks, recovery-only H.264+ffmpeg after idb MJPEG failure, and single-owner chat session locking. The macOS Simulator window placement / capture state probe (`getSimulatorWindowState`, `prepareSimulatorWindowForCapture`) lives next to the IPC handlers in `ipc/registerIpc.ts` because it depends on the active `BrowserWindow`. See [features/ios-simulator/README.md](./features/ios-simulator/README.md). | -| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / action dispatch / event stream, local-work checks, LAN discovery) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. `ipcTimeouts.ts` carries the shared 30-second handler timeout wrapper. | +| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / action dispatch / event stream, local-work checks, LAN discovery) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. `ipcTimeouts.ts` carries the default 30-second handler timeout plus long-operation overrides; it inspects runtime action payloads so `localRuntimeCallAction` / `remoteRuntimeCallAction` lane deletes receive the lane-delete budget. | | `jobs/` | `jobEngine.ts` | Event-driven background scheduler for lane refresh + conflict prediction. Coalesced, debounced. | | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | @@ -582,7 +582,7 @@ Enforced rules (from the stability overhaul): 2. New integrations are dormant-until-configured. 3. Feature pages stage data: cheapest (list/summary/topology) first, heavy (dashboard/settings/model metadata/overlays) on delay. 4. Never mount expensive trees eagerly — settings dialogs, advanced launcher sections unmount when closed. -5. Renderer polling is route-scoped; terminal attention only polls on terminal routes; lane panels only poll while live sessions exist. The plain PR list does not fire a GitHub refresh on mount, fetches open external PRs before closed/all history, skips conflict analysis, and defers rebase-needs / auto-rebase polling until the user opens a workflow tab or selects a PR. Workflow PR views batch merge contexts and conflict analysis against metadata-only lane rows instead of running per-PR git/status work. The Lanes page reuses the `LaneSummary.autoRebaseStatus` snapshot already in the lane list instead of probing per-lane on `LaneGitActionsPane` mount; a fallback probe runs only when the snapshot is missing and after a visibility-gated 3.5 s delay. Run's `LaneRuntimeBar` keeps health/process refreshes separate from preview routing / port / OAuth refreshes so process events do not reread routing state. The Work top-bar sync chip refreshes on focus and on `sync-status` events instead of a 5 s interval. The chat composer's Cursor model inventory is fetched lazily — `ProviderModelSelector` calls `onOpen` on first open of the model catalog, and `AgentChatPane.refreshCursorModelInventory` is the only entry point that hits `cursor` with `activateRuntime: true`. +5. Renderer polling is route-scoped; terminal attention only polls on terminal routes; lane panels only poll while live sessions exist. The plain PR list does not fire a GitHub refresh on mount, renders active-repository PR snapshots only, skips conflict analysis, and defers rebase-needs / auto-rebase polling until the user opens a workflow tab or selects a PR. Selected PR detail reads apply progressively so slow comments or action-run hydration do not block status/checks/files from painting. Workflow PR views batch merge contexts and conflict analysis against metadata-only lane rows instead of running per-PR git/status work. The Lanes page reuses the `LaneSummary.autoRebaseStatus` snapshot already in the lane list instead of probing per-lane on `LaneGitActionsPane` mount; a fallback probe runs only when the snapshot is missing and after a visibility-gated 3.5 s delay. Run's `LaneRuntimeBar` keeps health/process refreshes separate from preview routing / port / OAuth refreshes so process events do not reread routing state. The Work top-bar sync chip refreshes on focus and on `sync-status` events instead of a 5 s interval. The chat composer's Cursor model inventory is fetched lazily — `ProviderModelSelector` calls `onOpen` on first open of the model catalog, and `AgentChatPane.refreshCursorModelInventory` is the only entry point that hits `cursor` with `activateRuntime: true`. 6. Shared caches for high-frequency calls (`sessionListCache`, GitHub fingerprint-based snapshots). 7. Memoize expensive renderer computations (`useMemo`, `React.memo`); isolate frequently-refreshing subtrees (e.g., budget footers). 8. `Promise.allSettled` over `Promise.all` for parallel startup — one failing service must not block others. @@ -649,7 +649,7 @@ webPreferences: { **CSP**: `default-src 'self'`; `script-src 'self'` (no eval, no inline scripts); `style-src 'self' 'unsafe-inline'` (required for Tailwind); `connect-src 'self'`; `img-src 'self' data:`. -Every IPC handler **validates** its arguments; invalid args return structured errors, never crash. Every handler has a **30s timeout**. Every handler emits structured tracing. +Every IPC handler **validates** its arguments; invalid args return structured errors, never crash. Every handler has a bounded timeout: 30 seconds by default, with named longer budgets for long-running lifecycle and control-plane calls. Every handler emits structured tracing. ### 8.3 ADE CLI auth + API-key storage @@ -1003,7 +1003,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - `runtimeBinaryPermissions.cjs` — restores exec bits on `node-pty` spawn helpers, Codex vendor binaries, Claude SDK ripgrep helpers; patches `node-pty` `unixTerminal.js` for ASAR-unpacked paths. - `after-pack-runtime-fixes.cjs` — electron-builder after-pack hook. Covers both platforms: runs the permissions pass on macOS and stages CLI wrappers + runtime shims on Windows. -- `validate-mac-artifacts.mjs` / `validate-win-artifacts.mjs` — per-platform artifact validators; confirm expected binaries and release signing state. Windows signing verification is opt-in with `--require-signed` or `ADE_REQUIRE_WIN_SIGNING=1`. +- `validate-mac-artifacts.mjs` / `validate-win-artifacts.mjs` — per-platform artifact validators; confirm expected binaries, release signing state, bundled ADE CLI help, and isolated ADE Code TUI help. They also fail if the bundled TUI references `__dirname` / `__filename` without ESM shims. Windows signing verification is opt-in with `--require-signed` or `ADE_REQUIRE_WIN_SIGNING=1`. - `notarize-mac-dmg.mjs` — Apple notarization. ### 14.5 Documentation diff --git a/docs/browser-mock.md b/docs/browser-mock.md deleted file mode 100644 index 428574c2e..000000000 --- a/docs/browser-mock.md +++ /dev/null @@ -1,69 +0,0 @@ -# Browser mock (Vite without Electron) - -ADE’s renderer is built for Electron: `window.ade` comes from the preload bridge. When you open the Vite dev URL in a **normal browser** (Chrome, Safari, Edge) or in **Cursor’s Simple Browser**, there is no main process, so the app injects a **browser mock** (`src/renderer/browserMock.ts`) that returns safe defaults for every IPC-shaped API so the UI can load. - -## Run the site without Electron (Vite only) - -From `apps/desktop`: - -```bash -npm run dev:vite -``` - -Open **http://localhost:5173/**. The console will log that the browser mock is active. - -`dev:vite` refreshes the local browser snapshot first, using the nearest project root with `.ade/ade.db` (or `ADE_PROJECT_ROOT` when set). If no ADE database exists, Vite still starts and the mock falls back to built-in demo data. - -The full dev launcher (Vite + main-process watch + Electron) is: - -```bash -npm run dev -``` - -## Routing in the browser - -On `http://localhost:5173` the app uses **path-based** routing (`/work`, `/graph`, …). The embedded Cursor browser and normal browsers share the same behavior. Hash-based routing is reserved for non-`http(s)` loads (e.g. packaged `file://`). - -## Vite HMR and the mock - -The mock reapplies on hot reload so `window.ade` is not left half-initialized. If you see “missing” APIs after a long session, do a full page reload. - -## Use real project rows from your local `.ade` database - -The mock’s default data is **demonstration data**. To mirror the **current** local ADE SQLite state (same source the desktop app uses), generate a local snapshot the mock can read: - -1. Open the project in ADE (Electron) at least once so `.ade/ade.db` exists, **or** point at a project root that already has `.ade/ade.db`. -2. From `apps/desktop` run: - - ```bash - ADE_PROJECT_ROOT=/path/to/your/repo npm run export:browser-mock-ade - ``` - - Or pass the path as the first argument: - - ```bash - node ./scripts/export-browser-mock-ade-snapshot.mjs /path/to/your/repo - ``` - -3. The script writes: - - `apps/desktop/src/renderer/browser-mock-ade-snapshot.generated.json` - - (gitignored). Restart Vite or hard-refresh the browser. - -4. **While that file exists**, the browser mock prefers exported DB-backed rows and uses built-in demo data only for domains that were not exported. Delete the generated file to restore the full built-in demos. - -**Scope:** The export covers project metadata, lanes, lane status snapshots, PR summaries and cached PR detail snapshots, queue landing state, integration workflow rows, rebase signals, history operations, terminal sessions, chat transcript event histories, process definitions/runtime, automation run/ingress history, CTO memory state, usage summaries, and mission summaries when those tables have rows. It also walks each lane `worktree_path` on disk and embeds **`filesTreeByWorkspace`** (depth-1 `listTree` slices per directory, for the Files tab in Vite) plus **`filesContentsByWorkspace`** for a bounded set of small text files so `readFile` can open real sources in the browser mock. - -It is still a static browser snapshot, not a main-process replacement. Actions that need GitHub, git, PTYs, file contents, live process control, computer use, or fresh backend computation are no-ops or safe defaults. Re-run `npm run dev:vite` or `npm run export:browser-mock-ade` after the desktop app changes the database. - -## Known dev-only issues - -- **Vite HMR** can log `send was called before connect` in the console; the app filters harmless cases in `main.tsx` in development. -- **WebSocket** warnings (e.g. “closed due to suspension”) can appear if the editor **backgrounds** the tab; that is the host throttling the page, not ADE logic. -- **Computer-use / proof** UI expects snapshots with an `artifacts` array; the mock uses optional chaining so partial snapshots do not crash. - -## Related - -- `AGENTS.md` — project norms and validation commands -- `apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs` — export implementation diff --git a/docs/codex migration plan.md b/docs/codex migration plan.md deleted file mode 100644 index 5b1288c08..000000000 --- a/docs/codex migration plan.md +++ /dev/null @@ -1,1197 +0,0 @@ -# Codex `app-server` Migration Plan - -Linear: [ADE-32](https://linear.app/ade-linear/issue/ADE-32) · GitHub: [#278](https://github.com/arul28/ADE/issues/278) -Status: spec (proposed) -Date: 2026-05-11 -Target Codex release: `rust-v0.130.0` (latest stable; alpha track ignored) - -This document is the wire-level + UI-level migration spec for bringing ADE's bundled Codex `app-server` and its work-tab + TUI chat surfaces to feature parity with Codex CLI / Codex Desktop on the **chat UX layer** (Tier A in the planning conversation). Capability-layer additions (plugins UI, MCP-in-app, hooks UI, realtime voice, fs/process/command-exec RPCs, environments, dynamic tools, multi-agent UI, memory mode) are explicitly out of scope here — they are tracked separately and called out at the bottom. - -The spec assumes the prior plan at [`plans/ade-32-codex-v130-chat-parity.md`](../plans/ade-32-codex-v130-chat-parity.md) is approved. This document replaces and supersedes that plan with structural detail. - ---- - -## 1. Reference snapshot - -All Codex source citations are pinned to `openai/codex` `main` branch as of 2026-05-11. Every URL below resolves to a single immutable Rust file or markdown doc; we should re-verify these before starting implementation in case `main` has moved. - -### 1.1 Codex repo (`openai/codex/codex-rs/`) - -| Topic | URL | -|---|---| -| Wire registry (every method + notification name) | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/common.rs | -| JSON-RPC envelope | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/jsonrpc_lite.rs | -| Item enum (`ThreadItem`) and item-streaming notifications | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/item.rs | -| Thread lifecycle, goals, compaction, token usage | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/thread.rs | -| Turn lifecycle, `TurnPlanStep`, `UserInput` | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/turn.rs | -| Top-level notifications (`error`, `warning`, `deprecationNotice`) | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/notification.rs | -| App-server README — initialize, capabilities, subscription lifecycle | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md | -| v1 → v2 migration doc | https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/protocol_v1.md | -| v0.130.0 release notes | https://github.com/openai/codex/releases/tag/rust-v0.130.0 | -| Slash command inventory | https://developers.openai.com/codex/cli/slash-commands | - -### 1.2 ADE codebase (current state, file:line refs) - -| Surface | File | Line range | -|---|---|---| -| Codex runtime (spawn, JSON-RPC, notification dispatch) | `apps/desktop/src/main/services/chat/agentChatService.ts` | spawn 11023-11068; readline transport 11070-11146; notification dispatch 10441-11021; `turn/start` 7770-7807 | -| Executable resolver | `apps/desktop/src/main/services/ai/codexExecutable.ts` | 1-50 (whole file) | -| Normalized event union (used by both surfaces) | `apps/desktop/src/shared/types/chat.ts` | `AgentChatEvent` 150-446; `Session` 551-553 | -| Desktop chat root | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | 1763-5919 | -| Desktop message list (event switch) | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | switch 2024-2915; `CollapsibleCard` 963-1021; `InlineDisclosureRow` 699-748 | -| Desktop plan card (existing for Claude) | `apps/desktop/src/renderer/components/chat/ChatProposedPlanCard.tsx` | whole file | -| Desktop composer | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | 1-800+ | -| Desktop slash/file palette | `apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx` | 76-160 | -| TUI ChatView formatter | `apps/ade-cli/src/tuiClient/format.ts` | switch 258-352 | -| TUI command registry | `apps/ade-cli/src/tuiClient/commands.ts` | 12-113 | -| TUI palettes | `apps/ade-cli/src/tuiClient/components/SlashPalette.tsx` 8-46; `MentionPalette.tsx` 13-34 | | -| TUI model status bar | `apps/ade-cli/src/tuiClient/components/ModelStatus.tsx` | 28-76 | -| TUI theme | `apps/ade-cli/src/tuiClient/theme.ts` | 55-78 | -| Release matrix | `.github/workflows/release-core.yml` | runtime matrix 215-332; mac signing 87-130; runtime sign+notarize 313-318 | -| Extra resources packaging | `apps/desktop/package.json` | 175-214 | -| Version stamping | `apps/desktop/scripts/set-release-version.mjs` | 13-34 | - ---- - -## 2. Scope, goals, non-goals - -### 2.1 Goals (Tier A) - -Each item below is a fully-defined deliverable, with both a desktop and a TUI implementation, that ships as part of this milestone: - -1. Bundle and pin `codex` `rust-v0.130.0` in the desktop installer and the `apps/ade-cli` npm package (replacing the current `PATH`-based resolution). -2. Cleanup of the existing handshake (remove triple-name `effort` shim; drop `--disable plugins --disable apps` flags; keep `--disable browser_use --disable computer_use`). -3. Structured plan-mode card (`/plan`): renders `turn/plan/updated` + `Plan` items + `plan/delta`. -4. Manual `/compact` slash command: calls `thread/compact/start`; renders the `ContextCompaction` item. -5. Goals (`/goal set | get | clear`): calls `thread/goal/{set,get,clear}`; renders `thread/goal/updated`/`thread/goal/cleared` as a pinned banner. -6. Image input parity: support `{ type: "image", url }` for clipboard / drag-dropped URLs (we already support `localImage`). -7. `imageGeneration` item rendering (thumbnail + revised prompt + path). -8. `imageView` tool-call item rendering. -9. Rich `webSearch` item: render every `WebSearchAction` variant (`search`, `openPage`, `findInPage`, plus an `other` catch-all). -10. Token-usage HUD: surface `thread/tokenUsage/updated` (both `total` cumulative and `last` per-turn) in the model status bar. -11. Thread history UX: `/resume` palette with filter+search; fork, unarchive, rollback actions. -12. Long-thread pagination: `thread/turns/list` with `itemsView: "summary"` on resume, lazy-load `"full"` on scroll. -13. `optOutNotificationMethods` plumbing for non-streaming consumers (TUI `--print`). - -### 2.2 Explicit non-goals (deferred to follow-ups) - -These will become separate Linear tickets after Tier A ships: - -- Plugin browser UI, marketplace add/remove/upgrade UI (the runtime is enabled by dropping `--disable plugins`; users configure plugins via the Codex CLI / `~/.codex/`). -- Apps / connectors UI (same: runtime enabled by dropping `--disable apps`). -- MCP-in-app UI (OAuth, server status, tool catalog). -- Hooks system (`hooks/list`, `hook/started`, `hook/completed`, `hookPrompt` item). -- Realtime voice (`thread/realtime/*`). -- `command/exec`, `process/spawn`, `fs/*` RPCs. -- Environments (`environment/add`, environment-routed `view_image`). -- Dynamic client tools (`item/tool/call` server-initiated, `dynamicToolCall` item). -- Multi-agent collaboration UI (`collabAgentToolCall` item, collaboration mode picker). -- Memory mode (`thread/memoryMode/set`, `memory/reset`). -- Attestation (`attestation/generate` server-initiated request). -- External agent import (`externalAgentConfig/{detect,import}`). -- Vim composer, `/keymap`, `/title`, `/statusline`, `/ide`, `Ctrl+R` reverse history search (TUI-only nice-to-haves). - -### 2.3 Architectural principle - -ADE's TUI does **not** speak Codex protocol. It consumes the normalized `AgentChatEvent` envelope (`apps/desktop/src/shared/types/chat.ts:150-446`) published by `agentChatService.ts` via the ADE RPC server. Every feature in this spec therefore threads through **three layers** in order: - -``` - [ codex app-server JSON-RPC ] - ↓ ↑ - [ agentChatService.ts ] ←—— receive Codex notification / send Codex request - ↓ - [ AgentChatEvent union (shared/types/chat.ts) ] ←—— add new variant - ↓ - ┌──────────────────────┬──────────────────────────┐ - │ desktop renderer │ TUI ChatView formatter │ - │ (AgentChatMessageList│ (format.ts) │ - │ .tsx switch) │ │ - └──────────────────────┴──────────────────────────┘ -``` - -The shared union is the contract. **Every phase below ships a desktop AND a TUI renderer for any new variant** (per the user's "parity in one pass" choice). - ---- - -## 3. Architecture decisions and their sources - -This section captures the load-bearing architectural calls with citations. If a future engineer reverses one, they should at least know what they're reversing. - -### 3.1 Pin to `rust-v0.130.0` stable, not the alpha track - -- **Decision:** bundle `rust-v0.130.0`. Skip `rust-v0.131.0-alpha.*`. -- **Source:** v0.130.0 is the latest stable per [github.com/openai/codex/releases/tag/rust-v0.130.0](https://github.com/openai/codex/releases/tag/rust-v0.130.0). 0.131 alphas are work-in-progress; nothing in Tier A requires them. -- **Reversibility:** trivial. We can re-pin in a single env var (`CODEX_VERSION`) plus a checksum update. - -### 3.2 Bundle the binary; do not rely on user's `codex` on PATH - -- **Decision:** ship the binary inside both the Electron installer (`extraResources` → `resources/codex-bin/{target}/codex`) and the `apps/ade-cli` npm package. -- **Source:** current `resolveCodexExecutable` (`apps/desktop/src/main/services/ai/codexExecutable.ts:18-42`) falls through to literal `"codex"` if nothing else resolves, which means users without `codex` on PATH get a confusing crash. The release matrix already does this for ADE's own runtime binaries (`.github/workflows/release-core.yml:215-332`); we extend the same pattern. -- **Reversibility:** keep `CODEX_EXECUTABLE` / `CODEX_EXECUTABLE_PATH` env overrides for dev. Bundled binary is just a higher-priority resolution step. - -### 3.3 Speak Codex v2 only (we already do) - -- **Decision:** continue to send v2 wire names (`thread/start`, `turn/start`, `item/*`) and ignore the deprecated v1 `codex/event` / `newConversation` / `sendUserMessage` surface. -- **Source:** verified ADE already speaks v2 at `agentChatService.ts:11349, 7793, 10441-11021`. v1 docs at [codex-rs/docs/protocol_v1.md](https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/protocol_v1.md) explicitly mark v1 as legacy. -- **Reversibility:** none; v1 is being removed upstream. - -### 3.4 Drop `--disable plugins --disable apps`; keep `--disable browser_use --disable computer_use` - -- **Decision:** at `agentChatService.ts:11059`, the launch line currently disables all four. Plugins and apps are configured via the Codex CLI / `~/.codex/` and benefit ADE for free; browser-use and computer-use conflict with ADE's own ai-tools layer and stay disabled. -- **Source:** plugin install flow is the `plugin/install` JSON-RPC method (`codex-rs/app-server-protocol/src/protocol/v2/plugin.rs`), invoked by users via `codex plugin install @`. ADE doesn't need a UI for this in Tier A — it just needs to stop disabling the runtime path. -- **Reversibility:** trivial. - -### 3.5 Normalize through `AgentChatEvent`, do not pass Codex types to renderers - -- **Decision:** every new Codex item gets its own `AgentChatEvent` variant; renderers never import Codex protocol types directly. -- **Source:** the shared union at `apps/desktop/src/shared/types/chat.ts:150-446` is already the single source of truth for both renderers (47 variants today). The TUI runs out of process and only receives JSON via ADE's RPC server — Codex types can't cross that boundary. -- **Reversibility:** none. This is structural to ADE. - -### 3.6 Use `experimentalApi: true` in `initialize` - -- **Decision:** opt in. -- **Source:** README, [codex-rs/app-server/README.md L1850](https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md): *"This setting is negotiated once at initialization time for the process lifetime."* Several Tier A surfaces are experimental-gated: `thread/turns/list`, `thread/goal/*`, `thread/start.permissions`, `turn/start.permissions`. Skipping the flag means those return `requires experimentalApi capability` errors. -- **Reversibility:** flip the boolean. - -### 3.7 Do not implement `requestAttestation` or any `chatgptAuthTokens` capability - -- **Decision:** do not set `capabilities.requestAttestation`; do not attempt to handle `attestation/generate` server-initiated requests or `account/chatgptAuthTokens/refresh`. -- **Source:** Codex desktop is the host that owns ChatGPT tokens in-memory for the VS Code / desktop extension flow. ADE uses the standard ChatGPT OAuth via the `codex` CLI's existing auth files in `~/.codex/`. No `requestChatgptAuthTokens` capability exists in the README on `main` (verified). Per the protocol research agent's gotcha #12: *"If the migration spec mentions this, the spec is wrong."* -- **Reversibility:** add later when ADE wants in-app ChatGPT OAuth. - -### 3.8 Render `WebSearchAction::Other` as a generic fallback - -- **Decision:** the Rust enum has `#[serde(other)]` `Other` — meaning any new action variant added upstream deserializes as `{ type: "other" }`. Our renderer must handle this, not crash on it. -- **Source:** `WebSearchAction` definition in `codex-rs/app-server-protocol/src/protocol/v2/item.rs`. -- **Reversibility:** none — this is forward-compat scaffolding. - -### 3.9 Pagination defaults to `itemsView: "summary"` on resume - -- **Decision:** when resuming a thread, fetch a summary view first, then lazily upgrade to `"full"` on user scroll. -- **Source:** README L405: *"omitted `itemsView` defaults to `"summary"`."* Resume + summary is the fastest path; fetching `"full"` upfront on multi-hundred-turn threads will block the UI. -- **Reversibility:** trivial. - -### 3.10 ADE never sends a `"jsonrpc": "2.0"` field - -- **Decision:** match Codex's non-strict envelope. -- **Source:** verbatim from `jsonrpc_lite.rs`: *"We do not do true JSON-RPC 2.0, as we neither send nor expect the `jsonrpc: 2.0` field."* -- **Reversibility:** none. - ---- - -## 4. Wire spec — exact shapes we'll handle - -The protocol research agent extracted the Rust struct definitions verbatim. Below are the TypeScript types we'll add to `apps/desktop/src/shared/types/chat.ts` (or a new sibling `apps/desktop/src/shared/types/codex.ts` — see §5.2). Every type maps one-to-one to a Rust struct in `codex-rs/app-server-protocol/src/protocol/v2/`. - -### 4.1 Items (subset we render) - -```ts -// codex-rs/app-server-protocol/src/protocol/v2/item.rs - -export type CodexPlanItem = { - type: "plan"; - id: string; - text: string; -}; - -export type CodexContextCompactionItem = { - type: "contextCompaction"; - id: string; -}; - -export type CodexWebSearchItem = { - type: "webSearch"; - id: string; - query: string; - action: CodexWebSearchAction | null; -}; - -export type CodexWebSearchAction = - | { type: "search"; query: string | null; queries: string[] | null } - | { type: "openPage"; url: string | null } - | { type: "findInPage"; url: string | null; pattern: string | null } - | { type: "other" }; - -export type CodexImageGenerationItem = { - type: "imageGeneration"; - id: string; - status: string; // free-form per upstream - revisedPrompt: string | null; - result: string; // URL or base64 ref - savedPath?: string; -}; - -export type CodexImageViewItem = { - type: "imageView"; - id: string; - path: string; -}; -``` - -### 4.2 Goal types - -```ts -// codex-rs/app-server-protocol/src/protocol/v2/thread.rs - -export type CodexThreadGoal = { - threadId: string; - objective: string; - status: "active" | "paused" | "budgetLimited" | "complete"; - tokenBudget: number | null; - tokensUsed: number; - timeUsedSeconds: number; - createdAt: number; - updatedAt: number; -}; - -export type CodexThreadGoalSetParams = { - threadId: string; - objective?: string | null; - status?: CodexThreadGoal["status"] | null; - // Double-Option: omit ⇒ unchanged; null ⇒ clear; number ⇒ set. - tokenBudget?: number | null; -}; - -export type CodexThreadGoalUpdatedNotification = { - threadId: string; - turnId: string | null; - goal: CodexThreadGoal; -}; - -export type CodexThreadGoalClearedNotification = { - threadId: string; -}; -``` - -### 4.3 Token usage - -```ts -// codex-rs/app-server-protocol/src/protocol/v2/thread.rs - -export type CodexTokenUsageBreakdown = { - totalTokens: number; - inputTokens: number; - cachedInputTokens: number; - outputTokens: number; - reasoningOutputTokens: number; -}; - -export type CodexTokenUsage = { - total: CodexTokenUsageBreakdown; // cumulative across thread - last: CodexTokenUsageBreakdown; // last turn only - modelContextWindow: number | null; -}; - -export type CodexThreadTokenUsageUpdatedNotification = { - threadId: string; - turnId: string; - tokenUsage: CodexTokenUsage; -}; -``` - -### 4.4 Plan steps (turn-scoped, structured) - -```ts -// codex-rs/app-server-protocol/src/protocol/v2/turn.rs - -export type CodexTurnPlanStep = { - step: string; - status: "pending" | "inProgress" | "completed"; -}; - -export type CodexTurnPlanUpdatedNotification = { - threadId: string; - turnId: string; - explanation: string | null; - plan: CodexTurnPlanStep[]; -}; - -export type CodexPlanDeltaNotification = { - threadId: string; - turnId: string; - itemId: string; - delta: string; -}; -``` - -### 4.5 Thread list / read / fork / unarchive / rollback - -```ts -// codex-rs/app-server-protocol/src/protocol/v2/thread.rs - -export type CodexThreadListParams = { - cursor?: string; - limit?: number; - sortKey?: "created_at" | "updated_at"; // SNAKE_CASE on wire (see §3 gotcha 5) - sortDirection?: "asc" | "desc"; // SNAKE_CASE on wire - modelProviders?: string[]; - sourceKinds?: CodexThreadSourceKind[]; - archived?: boolean; - cwd?: string | string[]; // untagged union (see §3 gotcha 6) - searchTerm?: string; -}; - -export type CodexThreadSourceKind = - | "cli" | "vscode" | "exec" | "appServer" - | "subAgent" | "subAgentReview" | "subAgentCompact" - | "subAgentThreadSpawn" | "subAgentOther" | "unknown"; - -export type CodexThreadListResponse = { - data: CodexThread[]; - nextCursor: string | null; - backwardsCursor: string | null; -}; - -export type CodexThreadForkParams = { - threadId: string; - ephemeral?: boolean; - // ... plus optional model/sandbox/permission overrides (see thread.rs) -}; - -export type CodexThreadRollbackParams = { - threadId: string; - numTurns: number; // must be >= 1 -}; - -export type CodexThreadTurnsListParams = { - threadId: string; - cursor?: string; - limit?: number; - sortDirection?: "asc" | "desc"; - itemsView?: "notLoaded" | "summary" | "full"; // defaults to "summary" -}; -``` - -### 4.6 User input (sent on `turn/start`) - -```ts -// codex-rs/app-server-protocol/src/protocol/v2/turn.rs - -export type CodexUserInput = - | { type: "text"; text: string; text_elements?: CodexTextElement[] } - | { type: "image"; url: string } // ← NEW for Phase 5 - | { type: "localImage"; path: string } // already implemented - | { type: "skill"; name: string; path: string } - | { type: "mention"; name: string; path: string }; // already implemented - -export type CodexTextElement = { - byteRange: { start: number; end: number }; - placeholder?: string | null; -}; -``` - -### 4.7 Initialize capabilities - -```ts -// codex-rs/app-server/README.md (verbatim shape) - -export type CodexInitializeParams = { - clientInfo: { name: string; title?: string; version: string }; - capabilities?: { - experimentalApi?: boolean; // we set true - optOutNotificationMethods?: string[]; // exact method names - // NOTE: do NOT set `requestAttestation` (see §3.7) - }; -}; -``` - -### 4.8 Gotchas captured from the protocol research - -These are subtle wire facts that will cost us hours if forgotten. Each maps to a specific check we have to enforce: - -1. **No `"jsonrpc": "2.0"` field** — neither sent nor expected (`jsonrpc_lite.rs`). -2. **`WebSearchAction.Other`** is a `#[serde(other)]` catch-all; our union must include `{ type: "other" }`. -3. **Token usage carries 5 fields** per breakdown, including `cachedInputTokens` and `reasoningOutputTokens` — not the 3-field shape from older protocols. -4. **Double-Option serialization** in goals, service tier, git info: distinguish "omit (unchanged)" from "null (cleared)". On our side that means `undefined` in TS request bodies must be elided, not serialized as `null`. -5. **`ThreadSortKey` and `SortDirection` use snake_case** (`"created_at"`, `"updated_at"`, `"asc"`, `"desc"`) — unlike every other enum. Don't camelCase these. -6. **`ThreadListParams.cwd` is `#[serde(untagged)]`** — accepts a string OR a string array. -7. **`UserInput::Image.url` not `imageUrl`** — v2 wire renames it. -8. **`developerInstructions` is thread-scoped only**, not on `TurnStartParams`. Don't try to override per-turn. -9. **`dynamicTools` is thread-scoped only** (same as `developerInstructions`). -10. **`thread/turns/items/list` returns unsupported-method** on `main`. Use `thread/turns/list` with `itemsView: "full"`. -11. **`ContextCompactedNotification` is deprecated**; use the `ContextCompaction` item. -12. **30-min idle eviction** of subscribed threads only after last subscriber leaves AND zero activity. New `thread/start`/`thread/fork` auto-subscribes. - ---- - -## 5. Migration phases - -Each phase is a self-contained deliverable. Phases are ordered by risk + dependency, not user value. Phase 0 must land first; phases 1-9 can be parallelized across people but the desktop and TUI legs of any single phase must land together (per the user's "parity in one pass" call). - -### Phase 0 — Bundle binary + handshake cleanup - -#### 0.1 Bundle `codex` v0.130.0 - -**What changes:** - -1. New env var `CODEX_VERSION=0.130.0` (canonical version source, mirrors `ADE_STATIC_NODE_VERSION`). -2. New script `apps/desktop/scripts/download-codex-binary.mjs` that: - - Reads `CODEX_VERSION` and target triple from CI matrix. - - Downloads from `https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${target}.tar.gz`. - - Verifies SHA256 against a checked-in manifest `apps/desktop/resources/codex-bin/checksums.json`. - - Extracts the `codex` binary to `apps/desktop/resources/codex-bin/${target}/codex`. -3. New release-workflow job `download-codex-binaries` in `.github/workflows/release-core.yml`, parallel to `build-runtime-binaries`, fanned across the same matrix (lines 219-228: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`; Windows added at workflow level). -4. macOS notarization: codex binary inherits the app-bundle code signature when `notarize:mac:dmg` runs because it lives under `extraResources` (already-signed `hardenedRuntime` entitlements at `apps/desktop/build/entitlements.mac.plist` apply). If notarization rejects it, fall back to signing the binary independently with `apps/desktop/scripts/notarize-mac-dmg.mjs` extended to walk `resources/codex-bin/`. -5. `extraResources` entry added to `apps/desktop/package.json:175-214`: - ```json - { "from": "resources/codex-bin", "to": "codex-bin", "filter": ["**/*"] } - ``` -6. `apps/ade-cli/package.json`: add an optional dependency per platform (npm's standard binary-shipping pattern) — `@ade/codex-bin-darwin-arm64`, etc. Each is a thin npm package containing the binary. Alternative: a `postinstall` script that downloads the binary on user install. Per the release pipeline research (§3), ADE CLI is pure JS today; postinstall is the cleaner first step. - -**Sources for this approach:** -- ADE already does this fanned-matrix pattern for its own runtime binaries: `release-core.yml:215-332`. Sign+notarize step at L313-318 is the model for darwin codex binaries. -- `extraResources` shape: existing entries at `apps/desktop/package.json:175-214`. - -#### 0.2 Extend `resolveCodexExecutable` - -Current (`apps/desktop/src/main/services/ai/codexExecutable.ts:18-42`) resolves in this order: auth → env → known-dir → fallback `"codex"`. Insert a new step at priority 2 (after env vars, before known-dir search): - -```ts -// Pseudocode -const bundledPath = resolveBundledCodexPath(); // checks app.getAppPath()/Contents/Resources/codex-bin/{target}/codex on macOS -if (bundledPath && fs.existsSync(bundledPath)) { - return { path: bundledPath, source: "bundled" }; -} -``` - -Add `"bundled"` to the `CodexExecutableResolution.source` enum. - -#### 0.3 Drop `--disable plugins --disable apps` - -At `agentChatService.ts:11059`, change: - -```ts -appServerArgs.push("--disable", "plugins", "--disable", "apps", "--disable", "browser_use", "--disable", "computer_use"); -``` - -to: - -```ts -appServerArgs.push("--disable", "browser_use", "--disable", "computer_use"); -``` - -#### 0.4 Initialize handshake - -At `agentChatService.ts:11259` (where `initialize` is sent), set: - -```ts -{ - clientInfo: { name: "ade_desktop", title: "ADE Desktop", version: ADE_VERSION }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: [], // populated in Phase 9 - // requestAttestation intentionally omitted (§3.7) - }, -} -``` - -For the TUI runtime path (also through `agentChatService.ts`, since the TUI calls into the same service via ADE RPC), use `name: "ade_tui"` to differentiate. If the TUI runs `--print` (non-interactive), pass `optOutNotificationMethods` from §5.10. - -#### 0.5 Reasoning effort triple-name cleanup - -At `agentChatService.ts:7800-7802`, the current shim sends three keys for compat with older app-server builds: - -```ts -...(managed.session.reasoningEffort - ? { - effort: managed.session.reasoningEffort, - reasoningEffort: managed.session.reasoningEffort, - reasoning_effort: managed.session.reasoningEffort, - } - : {}), -``` - -v0.130 canonical key is `effort`. Replace with `{ effort: managed.session.reasoningEffort }` only. - -#### 0.6 Stub server-initiated requests we don't answer - -Codex may send these server→client requests; today we'd return JSON-RPC method-not-found. Wire them as explicit "capability not granted" responses so the server can degrade cleanly: - -- `attestation/generate` → `{ error: { code: -32601, message: "capability not granted" } }` -- `account/chatgptAuthTokens/refresh` → same -- `item/tool/call` (dynamic tools) → same - -**Files touched in Phase 0:** - -| File | Change | -|---|---| -| `apps/desktop/src/main/services/ai/codexExecutable.ts` | Add bundled-path resolution step | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | L11059 disable list, L11259 initialize, L7800-7802 effort cleanup, new server→client request handlers | -| `apps/desktop/package.json` | Add `extraResources` entry for `codex-bin` | -| `apps/desktop/scripts/download-codex-binary.mjs` | New script | -| `apps/desktop/resources/codex-bin/checksums.json` | New manifest | -| `.github/workflows/release-core.yml` | New `download-codex-binaries` matrix job | -| `apps/ade-cli/package.json` | Postinstall + per-platform optional deps OR binary shipping mechanism | - ---- - -### Phase 1 — Structured plan-mode card - -**Wire surface:** `Plan` item (text only) + `plan/delta` (streaming text) + `turn/plan/updated` (structured `TurnPlanStep[]`). The streaming text is informational; the structured step array is authoritative. - -**Current state in ADE:** `agentChatService.ts:10952` handles `item/plan/delta` and accumulates into a text buffer; `10787` handles `turn/plan/updated` (logged only). No structured step rendering. There's an existing `ChatProposedPlanCard.tsx` (Claude-flavored) we'll generalize. - -**New `AgentChatEvent` variants:** - -```ts -| { - type: "codex_plan"; - turnId: string; - sessionId: string; - explanation: string | null; - steps: { step: string; status: "pending" | "inProgress" | "completed" }[]; - streamingText: string; // accumulated plan/delta tail (informational) - state: "active" | "complete"; - } -``` - -**`agentChatService.ts` changes:** - -1. On `item/started` where `item.type === "plan"`: emit `codex_plan` with empty `steps`, empty `streamingText`, `state: "active"`. -2. On `item/plan/delta`: append `delta` to `streamingText`. -3. On `turn/plan/updated`: replace `steps` and `explanation`. -4. On `item/completed` where `item.type === "plan"`: set `state: "complete"`. - -**Desktop UI design:** - -Insert a new case in the `AgentChatMessageList.tsx:2024-2915` switch, around line 2068 (replacing the current `plan` `InlineDisclosureRow`). The card uses the existing `CollapsibleCard` primitive (file:line 963-1021). Color: violet accent (matches the `#A78BFA` token in TUI theme), since plans are an assistant-generated artifact. - -ASCII mock (desktop card, ~600px wide): - -``` -┌─ Plan ────────────────────────────────────────────────── ▾ ─┐ -│ Refactor the auth middleware to split session token │ -│ storage from request validation, per the legal/compliance │ -│ ask. │ -│ │ -│ ◐ 1. Read the existing middleware and identify the │ -│ coupling point (in progress) │ -│ ○ 2. Extract session-storage interface │ -│ ○ 3. Implement file-based and Redis-based storage backs │ -│ ○ 4. Wire the middleware to take a storage backend via DI │ -│ ○ 5. Update tests for both backends │ -│ │ -│ ▸ Live thoughts (click to expand) │ -└──────────────────────────────────────────────────────────────┘ -``` - -Step glyphs match TUI for visual consistency. The "Live thoughts" disclosure reveals the streamed `plan/delta` text — only useful for debugging. - -**TUI UI design:** - -ChatView formatter at `format.ts:258-352` gets a new case. Render the structured plan inline (no border — ApprovalPrompt already uses borders, plans should feel calmer): - -``` -plan · 14:23 - Refactor the auth middleware to split session token storage from request validation. - - ◐ Read existing middleware and identify coupling point (in progress) - ○ Extract session-storage interface - ○ Implement file-based and Redis-based storage backends - ○ Wire the middleware to take a storage backend via DI - ○ Update tests for both backends -``` - -Use `theme.ts:TONE_COLORS.notice` (gray) for non-active steps, `TONE_COLORS.user` (`#A78BFA`) for the active step. Step glyphs in `theme.ts` need three new entries (`pending = ○`, `inProgress = ◐`, `completed = ●`). - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | Add `codex_plan` variant to `AgentChatEvent` | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Replace text-only plan handling at L10787, L10952; emit structured event | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | New switch case at L2068, replace `InlineDisclosureRow` with `CodexPlanCard` | -| `apps/desktop/src/renderer/components/chat/CodexPlanCard.tsx` | New component (modeled on `ChatProposedPlanCard.tsx`) | -| `apps/ade-cli/src/tuiClient/format.ts` | New case in switch at L258-352 | -| `apps/ade-cli/src/tuiClient/theme.ts` | Add `glyphFor(status)` helper | - ---- - -### Phase 2 — `/compact` slash command + `ContextCompaction` item - -**Wire surface:** client sends `thread/compact/start { threadId }`; server streams a `contextCompaction` item via standard `item/started` → `item/completed`. The legacy `thread/compacted` notification is deprecated. - -**Current state in ADE:** no compaction wire at all today. The Codex CLI's `/compact` is a slash command that calls `thread/compact/start`. - -**New `AgentChatEvent` variant:** - -```ts -| { - type: "codex_context_compaction"; - turnId: string; - sessionId: string; - state: "started" | "completed"; - } -``` - -**Slash-command wiring:** - -The slash registry is server-driven for desktop (filtered in `ChatCommandMenu.tsx:85-89`) and a mix of `BUILTIN_COMMANDS` + server commands for TUI (`commands.ts:12-113`). Since `/compact` is provider-specific, we: - -1. Add `/compact` to the Codex provider's slash list emitted from `agentChatService.ts` (the same place `skills/list` results are exposed). -2. On dispatch (desktop: `AgentChatComposer.tsx` slash dispatch; TUI: `commands.ts` `parseCommand`), route to a new IPC method `window.ade.codex.compact({ sessionId })` that calls `thread/compact/start`. - -**Desktop UI design:** - -Compaction is mid-stream — not a hero card. Inline subtle notice: - -``` - ┌──────────────┐ - │ ⟳ compacted │ - └──────────────┘ -``` - -The chip sits between the last message and the next. On hover, tooltip: "Context compacted at 14:31 — N tokens reclaimed" (we don't have the token count yet from this notification, but Phase 6's token HUD updates simultaneously). - -**TUI UI design:** - -``` -[notice] context compacted -``` - -Single dimmed line, `tone: "notice"`, no border. - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | New variant | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | New handler for `item/started`/`item/completed` where `item.type === "contextCompaction"`; new public method `compact(sessionId)` | -| `apps/desktop/src/main/ipc/codexHandlers.ts` (new file or extend existing chat IPC) | Expose `compact` | -| `apps/desktop/src/preload/...` | Wire `window.ade.codex.compact` | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | New switch case for chip | -| `apps/ade-cli/src/tuiClient/format.ts` | New case → `tone: "notice"` | -| `apps/ade-cli/src/tuiClient/commands.ts` | Add `/compact` to `BUILTIN_COMMANDS` for codex provider | - ---- - -### Phase 3 — Goals (`/goal set | get | clear`) - -**Wire surface:** `thread/goal/set`, `thread/goal/get`, `thread/goal/clear` + `thread/goal/updated`, `thread/goal/cleared` notifications. Goal type at §4.2. - -**New `AgentChatEvent` variants:** - -```ts -| { - type: "codex_goal_updated"; - sessionId: string; - goal: CodexThreadGoal; - } -| { - type: "codex_goal_cleared"; - sessionId: string; - } -``` - -We also need to persist the active goal in `ChatSession` (`apps/desktop/src/shared/types/chat.ts:551-553` adds a `codexGoal: CodexThreadGoal | null` field). - -**Slash-command wiring:** - -- `/goal` (no args) — show current goal. -- `/goal ` — `thread/goal/set { objective }`. -- `/goal clear` — `thread/goal/clear`. -- `/goal status active|paused` — `thread/goal/set { status }`. -- `/goal budget ` — `thread/goal/set { tokenBudget: N }`. -- `/goal budget clear` — `thread/goal/set { tokenBudget: null }` (double-Option `null` ⇒ clear). - -**Desktop UI design:** - -A persistent slim banner above the message list when a goal is set. Click to edit (opens a small inline form). - -``` -┌───────────────────────────────────────────────────────────────────────────┐ -│ ◎ Goal: Refactor auth middleware for legal/compliance ask ✎ ✕ │ -│ ───────────────────────────────────────────── │ -│ 2,341 / 50,000 tokens · 4m 12s elapsed · status: active │ -└───────────────────────────────────────────────────────────────────────────┘ -[message list scrolls below this banner] -``` - -`◎` glyph (target). Color: amber-on-dim — important but not alarming. Token-budget progress bar uses the same `ContextMeter` style as the TUI footer. - -**TUI UI design:** - -Below the header, above the chat scrollback (uses the existing Drawer/RightPane layout primitives at `app.tsx`): - -``` -◎ Goal: Refactor auth middleware... 2.3k/50k · 4m · active -``` - -Single line, truncated to terminal width. Color: amber (`#F59E0B` already in theme as `warning` / `approval`). - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | New variants + `codexGoal` field on Session | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | New handlers, new public methods `goalSet/Get/Clear` | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Render `GoalBanner` above message list when `session.codexGoal != null` | -| `apps/desktop/src/renderer/components/chat/CodexGoalBanner.tsx` | New component | -| `apps/ade-cli/src/tuiClient/components/Header.tsx` | New goal line beneath title (or replace one of the existing lines) | -| `apps/ade-cli/src/tuiClient/commands.ts` | Add `/goal` builtin with subcommands | - ---- - -### Phase 4 — Image input parity (URL form) - -**Wire surface:** `UserInput::Image { url: string }` (§4.6). We already send `localImage` for clipboard-pasted / drag-dropped files; we need to also send `image` when the source is a URL (e.g. user pastes an image URL in the prompt, or drags an image from a browser tab — Chromium can give us a URL instead of bytes). - -**Current state:** `agentChatService.ts:7781-7789` only handles `localImage` and `mention`. The composer at `AgentChatComposer.tsx` has attachment plumbing but no URL form. - -**Changes:** - -- Composer paste handler: if clipboard contains a `text/uri-list` or a single image URL, append it as an attachment of new type `image-url` (in our `AgentChatFileRef` discriminator). -- `agentChatService.ts:7781-7789`: extend the for-loop to handle `attachment.type === "image-url"` → push `{ type: "image", url: attachment.url }`. - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | Extend `AgentChatFileRef` with `image-url` variant | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Paste/drop handler emits `image-url` ref | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | L7781-7789 handle new ref type → `image` UserInput | -| `apps/ade-cli/src/tuiClient/commands.ts` | Add `--image-url ` flag to send command | - ---- - -### Phase 5 — `imageGeneration` and `imageView` items - -**Wire surface:** - -```ts -type CodexImageGenerationItem = { - type: "imageGeneration"; id: string; status: string; - revisedPrompt: string | null; result: string; savedPath?: string; -}; -type CodexImageViewItem = { type: "imageView"; id: string; path: string }; -``` - -**New `AgentChatEvent` variants:** - -```ts -| { - type: "codex_image_generation"; - turnId: string; - sessionId: string; - status: string; - revisedPrompt: string | null; - result: string; - savedPath: string | null; - } -| { - type: "codex_image_view"; - turnId: string; - sessionId: string; - path: string; - } -``` - -**Desktop UI design:** - -Image generation card: - -``` -┌─ Image generated ──────────────────────────────────────────┐ -│ [200x200 thumbnail of the generated image] │ -│ │ -│ ▸ Revised prompt: "A serene mountain landscape with..." │ -│ Saved to: ~/Projects/.../assets/mountain.png ↗ open │ -└─────────────────────────────────────────────────────────────┘ -``` - -Image view (tool call): - -``` - ↳ Viewing image: assets/screenshot.png ↗ open -``` - -Inline single-line, indented to indicate it's a tool call. - -**TUI UI design:** - -``` -[tool] image generated → ~/Projects/.../mountain.png (h to open) - revised: A serene mountain landscape with... - -[tool] viewing image → assets/screenshot.png (h to open) -``` - -`h` key opens via system handler (`open ` on macOS, `xdg-open` on Linux, `start ""` on Windows). Wire through existing TUI key handling in `app.tsx`. - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | Two new variants | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Handle `item.type === "imageGeneration" / "imageView"` in `item/started` + `item/completed` | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Two new switch cases | -| `apps/desktop/src/renderer/components/chat/CodexImageGenerationCard.tsx` | New | -| `apps/desktop/src/renderer/components/chat/CodexImageViewLine.tsx` | New (or inline) | -| `apps/ade-cli/src/tuiClient/format.ts` | Two new cases | -| `apps/ade-cli/src/tuiClient/app.tsx` | Wire `h` key to `open` system call for images | - ---- - -### Phase 6 — Rich `webSearch` item rendering - -**Wire surface:** `WebSearchAction` union with `search`, `openPage`, `findInPage`, `other` variants (§4.1). - -**Current state:** `agentChatService.ts:10878` logs `codex/event/web_search_begin` only. There's also a `web_search` variant in `AgentChatEvent` already at `AgentChatMessageList.tsx:2166-2227` for other providers' web search — we can reuse the visual treatment. - -**Change to existing `AgentChatEvent` web_search variant** (extend, don't replace, so other providers keep working): - -```ts -| { - type: "web_search"; - turnId: string; - sessionId: string; - query: string; - state: "running" | "completed" | "failed"; - actions?: CodexWebSearchAction[]; // ← NEW, only populated for Codex - } -``` - -Note: a single `webSearch` item carries one `action`. We accumulate them across `item/started` (initial action) and `item/completed` (final action) into `actions[]` for richer rendering. If multiple `webSearch` items fire in a turn, each gets its own event. - -**Desktop UI design:** - -``` -┌─ Web search ───────────────────────────────────────────── ▸ ─┐ -│ 🔍 "Codex app-server thread/turns/list pagination" │ -│ │ -│ • search: thread/turns/list pagination │ -│ • openPage: github.com/openai/codex/.../thread.rs │ -│ • findInPage: "items_view" in thread.rs │ -└────────────────────────────────────────────────────────────────┘ -``` - -Use the existing Motion card from `AgentChatMessageList.tsx:2166-2227`; just extend its body to render the action list when `actions[]` is present. - -**TUI UI design:** - -``` -🔍 web search · "Codex app-server thread/turns/list pagination" - search thread/turns/list pagination - openPage github.com/openai/codex/.../thread.rs - findInPage "items_view" in thread.rs -``` - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | Extend `web_search` variant with `actions[]` | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Replace L10878 stub with structured handling of `webSearch` items via `item/started` + `item/completed` | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Extend existing case at L2166-2227 to render actions list when present | -| `apps/ade-cli/src/tuiClient/format.ts` | Extend `web_search` case to render actions | - ---- - -### Phase 7 — Token-usage HUD - -**Wire surface:** `thread/tokenUsage/updated` (§4.3) — carries both `total` (cumulative) and `last` (latest turn) breakdowns plus `modelContextWindow`. - -**New `AgentChatEvent` variant:** - -```ts -| { - type: "codex_token_usage"; - sessionId: string; - total: CodexTokenUsageBreakdown; - last: CodexTokenUsageBreakdown; - modelContextWindow: number | null; - } -``` - -We don't render this in the message stream — it updates the persistent footer/status bar. So `agentChatService.ts` stashes it in `ChatSession.codexTokenUsage` and emits a `session_updated` event for renderers to re-read. - -**Desktop UI design:** - -Extend the model status area (currently rendered inline in `status` events at `AgentChatMessageList.tsx:2915-2978`). Add a persistent footer: - -``` -┌────────────────────────────────────────────────────────────────────────────┐ -│ ◇ Codex · gpt-5 · medium effort · workspace-write │ -│ ▓▓▓▓▓▓▓░░░░░ 64% (128k / 200k) last turn: +2.3k in · 1.1k out (450 ✶) │ -└────────────────────────────────────────────────────────────────────────────┘ -``` - -`✶` denotes cached input tokens. Bar is `modelContextWindow`-relative. - -**TUI UI design:** - -Extend `ModelStatus.tsx:28-76` (right-side `ContextMeter`). Replace the existing `tokenSummary` string with a richer one: - -``` -◇ Codex · gpt-5 · medium · workspace-write ▓▓▓▓▓▓▓░░░ 64% +2.3k/1.1k (450✶) -``` - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | `CodexTokenUsage*` types; `codexTokenUsage` on Session; `codex_token_usage` event | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | New handler | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | New footer component below message list when `session.codexTokenUsage != null` | -| `apps/desktop/src/renderer/components/chat/CodexTokenFooter.tsx` | New | -| `apps/ade-cli/src/tuiClient/components/ModelStatus.tsx` | Extend `ContextMeter` summary string | - ---- - -### Phase 8 — Thread history: list + read + fork + unarchive + rollback - -This is the biggest UX piece. Codex CLI's `/resume` flow is the target. - -**Wire surface:** `thread/list`, `thread/read`, `thread/fork`, `thread/unarchive`, `thread/rollback`. Shapes at §4.5. - -**Currently in ADE:** `thread/resume` and `thread/archive` are called; nothing else. - -**New IPC layer:** add a `CodexHistoryService` in main process that owns these requests. Two design choices, picking option A: - -- **Option A (picked):** reuse the active managed session's runtime if there is one open (cheaper, one process). -- **Option B:** spawn a transient `codex app-server` for history queries, shut down after. (Heavier, but useful for closed-app scenarios — defer to a follow-up.) - -Expose to renderer via `window.ade.codex.history.{list, read, fork, unarchive, rollback}`. - -**Desktop UI design — `/resume` modal:** - -``` -┌─ Codex history ─────────────────────────────────────────────────────────┐ -│ │ -│ [search threads...] │ -│ │ -│ [Active] [Archived] [Forks] cwd: [all ▾] provider: [codex ▾] │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ thr_a1b2c3d4 "Refactor auth middleware" │ -│ 2026-05-11 14:23 · 12 turns · ~/Projects/auth-svc │ -│ [resume] [fork] [archive] [rollback ▾] │ -│ ───────────────────────────────────────────────────────────────────── │ -│ thr_e5f6g7h8 "Add SSO with Okta" │ -│ 2026-05-09 09:11 · 47 turns · ~/Projects/auth-svc │ -│ [resume] [fork] [archive] [rollback ▾] │ -│ ───────────────────────────────────────────────────────────────────── │ -│ ... │ -│ │ -│ Load more (next cursor: xyz...) │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -`[rollback ▾]` opens a small inline picker: "Rollback last 1 / 2 / 5 / 10 turns…". - -Modal pattern follows the existing `LinearIssueBrowser` modal precedent (`AgentChatPane.tsx:42`). No `Dialog` primitive exists; we use `createPortal` + a backdrop div. - -**TUI UI design — `ChatHistoryPalette`:** - -A new palette component modeled on `MentionPalette.tsx:13-34` / `SlashPalette.tsx:29-45`. Opened by `Ctrl+R` or `/resume`: - -``` -┌─ resume codex thread ────────────────────────────────────┐ -│ [search threads...] │ -│ │ -│ › thr_a1b2c3d4 Refactor auth middleware 12t │ -│ thr_e5f6g7h8 Add SSO with Okta 47t │ -│ thr_i9j0k1l2 Pipeline builder refactor 8t │ -│ ... │ -│ │ -│ ↵ resume f fork a archive r rollback ⎋ close │ -└──────────────────────────────────────────────────────────┘ -``` - -`r` opens an inline number prompt for "rollback N turns". - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/shared/types/chat.ts` | `CodexThread*` types | -| `apps/desktop/src/main/services/chat/codexHistoryService.ts` | New file — wraps the 5 RPCs against the active runtime | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Expose `getRuntime(sessionId)` helper for the history service | -| `apps/desktop/src/main/ipc/codexHandlers.ts` | New IPC channels | -| `apps/desktop/src/preload/...` | `window.ade.codex.history.*` | -| `apps/desktop/src/renderer/components/chat/CodexHistoryModal.tsx` | New modal | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Mount modal; wire `/resume` slash to open it | -| `apps/ade-cli/src/tuiClient/components/ChatHistoryPalette.tsx` | New TUI palette | -| `apps/ade-cli/src/tuiClient/app.tsx` | Wire `Ctrl+R` keybind and `/resume` to open palette | -| `apps/ade-cli/src/tuiClient/commands.ts` | Add `/resume` builtin | - ---- - -### Phase 9 — Long-thread pagination - -**Wire surface:** `thread/turns/list { threadId, itemsView, cursor, limit, sortDirection }`. Defaults `itemsView: "summary"`. - -**Migration plan:** - -1. When `thread/resume` returns, ADE currently expects the full history in the response. Instead: - - Issue `thread/resume` (still required to subscribe to live events). - - Immediately issue `thread/turns/list { threadId, itemsView: "summary", limit: 50 }`. - - Render summary cards as turn boundaries with a "Load full turn" disclosure. -2. On scroll-up past the top of currently-loaded turns: - - Issue `thread/turns/list { threadId, cursor: nextCursor, itemsView: "summary", limit: 50 }`. -3. On user expanding a specific turn's full content: - - Issue `thread/turns/list { threadId, cursor: , itemsView: "full", limit: 1 }` and replace the summary view for that single turn. - -**Subtlety:** Codex `Turn` carries an `itemsView` field that tells us what's already there. README says the default for `thread/turns/list` is `"summary"`. We must not assume `"full"` is always populated. - -**Desktop UI design:** the existing message list already scrolls; we add: - -- A "Load older turns" button at the top of the scrollback when `nextCursor != null`. -- A `[ Show full turn ▾ ]` button at the top of each summary-rendered turn. - -**TUI UI design:** when scrolling up past the loaded window, the bottom-status bar shows `[older turns: press Ctrl+G to load]`. Pressing Ctrl+G fetches the next 50. - -**Files touched:** - -| File | Change | -|---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Issue `thread/turns/list` after `thread/resume`; expose `loadOlderTurns(sessionId, cursor)` | -| `apps/desktop/src/shared/types/chat.ts` | Add `loadCursor: string \| null` and `itemsViewByTurnId: Record` to Session | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | "Load older" UI; "Show full turn" button | -| `apps/ade-cli/src/tuiClient/app.tsx` | Ctrl+G keybind | - ---- - -### Phase 10 — `optOutNotificationMethods` for perf - -For renderers that don't need streaming (TUI non-interactive `ade chat send --print`), opt out of high-volume deltas: - -```ts -optOutNotificationMethods: [ - "item/agentMessage/delta", - "item/reasoning/summaryTextDelta", - "item/reasoning/textDelta", - "item/commandExecution/outputDelta", -] -``` - -Desktop chat keeps all deltas. The TUI interactive mode also keeps all deltas. Only the `--print` path opts out. - -**File:** `agentChatService.ts:11259` — pass `optOutNotificationMethods` conditionally based on `ChatSession.runtimeMode`. - ---- - -## 6. Migration sequencing & dependencies - -``` -Phase 0 (binary + handshake) - ├─→ Phase 1 (plan card) - ├─→ Phase 2 (compact) - ├─→ Phase 3 (goal) - ├─→ Phase 4 (image input URL) - ├─→ Phase 5 (image gen + view) - ├─→ Phase 6 (web search) - ├─→ Phase 7 (token HUD) - └─→ Phase 8 (thread history) - └─→ Phase 9 (pagination) - └─→ Phase 10 (opt-out) -``` - -Phases 1-7 are independent of each other after Phase 0 lands. Phase 9 depends on Phase 8 (it relies on the history service infrastructure). Phase 10 depends on Phase 9 because that's when we start having streams large enough to want to opt out of. - -**Suggested cadence:** Phase 0 first (1 PR). Phases 1, 2, 3, 7 next as a "chat affordances" batch (one PR each, parallelizable). Phases 4, 5, 6 next as an "image + search" batch. Phase 8 as a standalone PR (largest). Phases 9 + 10 as a single follow-up PR. - ---- - -## 7. Where to use parallel agents - -This is the user's explicit ask: where do parallel subagents accelerate this? The boundaries below are the natural seams. - -### 7.1 Code-generation parallelism - -The phases factor cleanly across the three layers — wire handler, shared type, renderer — and within each phase the desktop and TUI renderers don't touch each other's files. Recommended pattern per phase: - -**For each Phase 1-7 (small phases), spawn 2 agents in parallel:** - -- **Agent A** ("wire + types"): adds the `AgentChatEvent` variant in `apps/desktop/src/shared/types/chat.ts`, wires the new request/notification in `agentChatService.ts`, and emits the new event. Touches main-process code only. -- **Agent B** ("renderers"): once Agent A has merged the type variant, branches off to add the new switch case in `AgentChatMessageList.tsx` AND the new case in TUI `format.ts`. Touches renderer code only. - -Reason this works: the union type is the API contract between them. Agent A finishing the type definition unblocks Agent B even before A's IPC is fully wired (B can stub the event with a dev-tools button). - -**For Phase 8 (thread history), spawn 3 agents in parallel after the IPC layer is sketched:** - -- **Agent A:** `codexHistoryService.ts` + IPC handlers + preload bindings. -- **Agent B:** `CodexHistoryModal.tsx` + `AgentChatPane` mount + `/resume` slash routing on desktop. -- **Agent C:** `ChatHistoryPalette.tsx` + `app.tsx` keybind wiring + `commands.ts` builtin on TUI. - -### 7.2 Research parallelism (during ongoing implementation) - -Once we're in execution, certain research tasks block specific phases. Spawn these as research agents the first time the phase begins: - -- **Phase 0 binary bundling:** an agent to verify SHA256 manifest building works against `github.com/openai/codex/releases/download/rust-v0.130.0/*` for each target triple. The agent fetches each tarball, computes sha256, writes the manifest. -- **Phase 8 history modal:** an agent to UX-prototype the modal layout in a sandbox (the user has a strong preference against generic AI aesthetics — see `feedback_design.md` memory — so we want an explicit design pass before locking in the look). -- **Phase 7 token HUD:** an agent to validate that `thread/tokenUsage/updated` actually fires on every turn boundary (vs. only at completion) by running a fake stream against a real `codex app-server` v0.130 — this affects whether the HUD updates feel real-time or laggy. - -### 7.3 Test-generation parallelism - -After each phase's implementation lands, spawn a single agent per phase to add tests in parallel with the next phase's implementation. The test agent's prompt should be: *"For [phase] in [PR #], add unit tests in `agentChatService.test.ts` that exercise the new wire handler against a fake app-server (use the existing fixture harness). Do NOT add brittle render-tree tests."* This obeys the existing memory `feedback_testing_quality.md`. - -### 7.4 Cross-phase agents - -Once 3+ phases have landed, spawn an **audit agent** with the prompt: *"Walk every new `AgentChatEvent` variant added since [start commit]; confirm desktop and TUI both have a renderer case; confirm there is a corresponding `agentChatService.ts` handler; confirm tests exist."* This catches drift between the three layers without a human re-checking each phase. - ---- - -## 8. Testing strategy - -Per the project's testing memory (`feedback_testing_quality.md`, `feedback_test_scoping.md`, `feedback_test_sharding.md`): - -1. **Real-value tests only.** No brittle DOM snapshot tests, no fragile render assertions. -2. **Scope to changed files.** Run `pnpm test apps/desktop/src/main/services/chat/agentChatService.test.ts` and the codex-specific TUI fixture only — never the full suite per change. -3. **Always shard.** Use the existing shard configuration. - -### 8.1 Per-phase test deliverables - -- **Phase 0:** integration test that spawns the bundled binary, completes an `initialize` handshake, sends one trivial `turn/start`, asserts a `turn/completed` notification. This proves the bundled binary works end-to-end. -- **Phase 1:** fake-app-server fixture that emits a `turn/plan/updated` + `Plan` item + `plan/delta` sequence; assert the emitted `AgentChatEvent` matches snapshot. -- **Phase 2:** fixture that runs `thread/compact/start`, fake server emits `contextCompaction` item; assert event. -- **Phase 3:** `/goal set Foo` → assert `thread/goal/set` request body shape matches §4.2 (esp. double-Option semantics for `tokenBudget`). -- **Phase 4-5:** assert URL-form image goes through as `{ type: "image", url }`; assert imageGeneration/imageView items become the right events. -- **Phase 6:** fixture emits one `webSearch` item with each `WebSearchAction` variant including `{ type: "other" }`; assert no crash and a renderable event. -- **Phase 7:** fixture emits `thread/tokenUsage/updated` with the 5-field breakdown; assert footer model gets all 5 fields. -- **Phase 8:** `thread/list` returns paginated data with `nextCursor`; assert the modal renders and `[Load more]` issues another request with that cursor. Test snake_case wire encoding of `sortKey` (gotcha 5). -- **Phase 9:** fixture returns a thread with 100 turns; assert only 50 summary items load on resume, full only on expand. -- **Phase 10:** `--print` mode skips emitting delta events. - -### 8.2 Manual smoke checklist - -For each phase, end with a manual smoke pass against the dev Electron build: - -- Open the work tab, start a chat, exercise the new feature, capture screenshot. -- Open `ade` TUI in another terminal, attach to the same lane, exercise the same feature. -- Compare event ordering against `git log` of `agentChatService.ts` debug output. - ---- - -## 9. Rollout & migration plan - -1. **Pre-merge:** all phase PRs target a feature branch `feature/codex-v130`, not `main`. -2. **First release with new binary:** ship in a beta channel build. The channel-isolated profile work already landed (commit `5de5f054 — Isolate desktop profiles by channel`) means beta users get a clean profile so a broken Codex session can't poison prod profiles. -3. **Rollback plan:** keep the `CODEX_EXECUTABLE` env override functional. If v0.130 has a regression, users can drop to a known-good local install with `export CODEX_EXECUTABLE=/usr/local/bin/codex` and restart. -4. **Telemetry to add:** on every Codex JSON-RPC method call, log method + duration + error code. Surface in the existing ADE telemetry pipeline. Especially important: `thread/turns/list` p95 (Phase 9 hinges on it staying < 500ms). - ---- - -## 10. Open questions / risks - -1. **Binary signing on Windows.** Codex binaries are unsigned upstream. macOS notarization will pick up the binary via the app bundle's hardened-runtime entitlements (verified — there's a `disable-library-validation` entitlement at `apps/desktop/build/entitlements.mac.plist:4-9`). Windows is different — `ADE_REQUIRE_WIN_SIGNING` (env var at `apps/desktop/scripts/validate-win-artifacts.mjs:34`) currently fails the build if shipped binaries are unsigned. We may need to re-sign the codex binary with our cert or disable the check for the codex bin specifically. Confirm with whoever owns the Windows release. -2. **Bundle size.** Codex binary is ~50MB per platform. Universal mac DMG = arm64 + x64 = 100MB extra. Windows installer = 50MB extra. We already ship runtime binaries the same way, so this is a known cost. -3. **Plugin/app misconfig noise.** Once `--disable plugins --disable apps` is dropped, users with broken plugin configs in `~/.codex/` will hit `configWarning` notifications. We should surface these as a subtle dimmed line in the chat (one-line addition; out of scope for Phase 0). -4. **Double-Option for token budget.** TS doesn't distinguish `undefined` from `null` natively in `JSON.stringify` if you set the field. We need explicit serialization helpers; consider a small `omitUndefined()` wrapper before sending `thread/goal/set` requests, and prefer `null` only when the user means "clear". -5. **`developerInstructions` is thread-scoped only.** If we ever want per-turn override (e.g. "for this prompt only, be terse"), we'll need a `thread/fork` + `thread/start` pattern instead. Document this in the slash command help. -6. **`thread/turns/items/list` returns unsupported-method.** We rely on `thread/turns/list` with `itemsView: "full"` instead. If a future Codex version implements per-item pagination, we can swap. -7. **Goal banner real estate in TUI.** The TUI already has a Header + ModelStatus + FooterControls stack. One more line is fine, but check it doesn't push the chat scrollback into a too-small region on 24-line terminals. Test with `COLUMNS=80 LINES=24`. - ---- - -## 11. Definition of done - -- `codex` v0.130.0 binary ships in macOS arm64, macOS x64, Windows x64, Linux x64, Linux arm64 builds — verified by running the smoke handshake test in CI. -- `--disable plugins --disable apps` is gone from the spawn line; `--disable browser_use --disable computer_use` remains. -- `/plan`, `/compact`, `/goal`, `/resume` are wired in both desktop slash registry and TUI `commands.ts`. -- Plan, compaction, goal banner, image-gen card, image-view line, webSearch card all have dedicated visual treatment (no `text` fallback). -- Token usage shows the 5-field breakdown in the desktop footer and the TUI `ModelStatus` line; `modelContextWindow`-relative progress bar is visible in both surfaces. -- Resume picker opens via `/resume` (and `Ctrl+R` in TUI); search, fork, unarchive, rollback all work. -- Resuming a 100-turn thread loads in < 1s thanks to `itemsView: "summary"`; "Show full turn" expands a single turn lazily. -- No regression in approval flow, command execution rendering, file diff rendering, reasoning streaming, /review slash command. -- All new tests pass under sharded `pnpm test`; manual smoke checklist signed off. diff --git a/docs/codex-cli-passthrough-audit.md b/docs/codex-cli-passthrough-audit.md deleted file mode 100644 index 66d7e086e..000000000 --- a/docs/codex-cli-passthrough-audit.md +++ /dev/null @@ -1,100 +0,0 @@ -# Codex CLI slash-command pass-through audit - -Date: 2026-05-12 -Scope: every Codex slash command listed in -`apps/desktop/src/main/services/chat/agentChatService.ts:498–533` and the matching -Claude slash list at `:535–577`. Goal: find dead-listed commands (advertised in -the palette but with no working ADE handler, no Codex SDK route, or both) so we -can either wire them properly or remove them from the palette in a follow-up. - -## How the pass-through works - -The Codex provider keeps almost no slash-command logic in ADE. Only `/fast`, -`/plan`, `/compact`, and `/goal` have explicit local handlers -(`agentChatService.ts:7874–8021`); every other registry entry is forwarded -verbatim to the Codex app server through the `sendMessage` path. The Codex -app server then either resolves the command itself or no-ops. - -Because the Codex CLI was authored as a terminal UI, several of its slash -commands operate on TUI concerns (`/keymap`, `/statusline`, `/title`, -`/personality`, `/vim`, `/theme`) that ADE renders differently. Those entries -work in the upstream `codex` binary but produce no useful effect in ADE. - -Reference: - -## Codex registry (`agentChatService.ts:498–533`) - -| Command | Listing | Local handler | Codex SDK behavior in ADE | Recommendation | -|---|---|---|---|---| -| `/permissions` | sdk | — | Codex sends a `permissions/configure` notification; ADE has no UI consumer so it lands as a `system_notice` row. | Wire to a renderer surface in a follow-up, or document the no-UI behavior. | -| `/sandbox-add-read-dir` | sdk | — | Forwarded; Codex applies the change in-thread, ADE only sees a confirmation `system_notice`. | Keep — works end-to-end. | -| `/agent` | sdk | — | Used to switch between Codex agent threads (sub-agent identity). ADE collapses agents into a single chat session, so the response is informational only. | Keep but document the limitation; revisit when sub-agent UI lands. | -| `/apps` | sdk | — | Opens an in-CLI "apps" browser. ADE has no equivalent; Codex returns an informational message but no usable UI. | Dead-listed for ADE — remove from palette OR wire a proper modal (post-Tier-A). | -| `/plugins` | sdk | — | Same problem as `/apps`. | Dead-listed — remove or wire UI. | -| `/clear` | sdk | TUI-side `/clear` (ADE Code) shadows | Codex app server's `/clear` resets thread state. ADE Code's TUI overrides it locally to clear the viewport. Desktop renderer has no local override — it forwards. | OK for now; desktop UX may want an explicit "Clear viewport" affordance separate from Codex's destructive `/clear`. | -| `/compact` | sdk | yes (`thread/compact/start`) | Direct wire call; emits `codex_context_compaction`. | Keep — works end-to-end. | -| `/copy` | sdk | — | Codex CLI copies the latest output to its TUI buffer. In ADE there is no such buffer; the request is silently dropped. | Dead-listed for ADE. Either implement `/copy` locally (mirror of TUI `Ctrl+O`) or remove from palette. | -| `/diff` | sdk | — | Codex CLI prints a git diff to stdout. ADE has a richer git pane elsewhere; Codex emits text the renderer displays as an assistant message. | Keep (the textual diff still reads). Consider routing to ADE's diff viewer in a follow-up. | -| `/exit` | sdk | — | Tells Codex CLI to exit. In ADE this terminates the app-server process, which the runtime then auto-restarts — surprising side-effect. | Remove from palette (ADE owns `/quit`; session continuation happens by sending the next message). | -| `/experimental` | sdk | — | Codex toggles experimental flags. Works in ADE. | Keep. | -| `/feedback` | sdk | — | Codex queues logs for upload. Works through the SDK. | Keep. | -| `/init` | sdk | — | Generates an `AGENTS.md` scaffold via Codex. Works. | Keep. | -| `/goal` | sdk | yes (`thread/goal/*`) | Local handler covers `set`, `clear`, `status`, `budget`, `pause`/`resume`. | Keep — works end-to-end. | -| `/logout` | sdk | — | Forwarded. Codex clears credentials. | Keep. | -| `/mention` | sdk | — | Codex CLI's mention UI is keyboard-driven; in ADE we have `@`-mentions in the composer that fulfill this need without `/mention`. | Remove from palette to avoid duplicating the composer affordance. | -| `/model` | sdk | shadowed by `/model` in ADE Code (right pane) | ADE owns model selection via right pane; the Codex SDK reply is redundant text. | Remove the Codex `/model` palette entry (ADE owns model selection). | -| `/fast` | sdk | yes | Local handler. | Keep. | -| `/plan` | sdk | yes | Local handler. | Keep. | -| `/personality` | sdk | — | Codex switches its persona; the change applies inside the Codex thread. Works, but discoverability is low. | Keep. | -| `/ps` | sdk | — | Codex lists background terminals. ADE doesn't expose Codex background terminals; the reply is text-only. | Dead-listed — remove or expose Codex BG terminals. | -| `/stop` | sdk | — | Stops all Codex background terminals. Same coverage gap as `/ps`. | Dead-listed — pair with `/ps` decision. | -| `/fork` | sdk | — | After §A.6, the IPC method `forkCodexThread` is gone but the slash remains in the registry. Sending `/fork` now forwards to Codex SDK; Codex responds with a thread-fork notification that ADE no longer renders. | **Remove from palette.** Audit hand-off: §A.6 was supposed to remove this entry. | -| `/rollback` | local | gone | `rollbackCodexThread` IPC was removed in §A.6; sending `/rollback` from chat just forwards plain text to Codex SDK (no rollback). | **Remove from palette.** §A.6 leftover. | -| `/resume` | sdk | gone | `listCodexThreads`/`resumeCodexThread` IPC was removed in §A.6; ResumePalette is gone in §C.1. The slash now forwards to Codex SDK and Codex responds with a thread-resume UI that ADE never surfaces. | **Remove from palette.** §A.6 leftover. ADE uses its chat sidebar instead. | -| `/unarchive` | local | gone | `unarchiveCodexThread` IPC was removed in §A.6; the slash is now inert. | **Remove from palette.** §A.6 leftover. | -| `/new` | sdk | — | Codex starts a new thread. Conflicts with ADE's own `/new chat` / `/new lane`. | Remove the bare `/new` to avoid clashing with ADE's multi-word commands. | -| `/quit` | sdk | TUI `/quit` shadows | TUI handles it inline (exit the CLI). Desktop renderer forwards to Codex SDK which terminates the app-server. | Keep TUI handling; remove from desktop palette where it has destructive side effects. | -| `/review` | sdk | — | Codex starts a review (`review/start { type: "prompt" }`). §F.4 expands this with `diff`/`branch` variants. | Keep. | -| `/status` | sdk | shadowed by ADE Code right-pane `/status` | TUI overrides; desktop forwards to Codex's text status reply. | Keep, but expect duplicate behavior in desktop. | -| `/debug-config` | sdk | — | Codex prints config diagnostics. Works. | Keep. | -| `/statusline` | sdk | — | Configures the Codex CLI status line. No equivalent in ADE; the change applies in the upstream Codex CLI binary but ADE never displays it. | Remove from palette — TUI-only feature. | -| `/title` | sdk | — | Configures the terminal window/tab title. ADE owns its own window chrome. | Remove from palette — TUI-only feature. | - -Commands listed in the task scope (Section E.1) but **not** in the Codex -registry: `/keymap`, `/vim`, `/agents`, `/apps` (already covered), `/plugins` -(already covered), `/hooks`, `/ide`. These either belong to the Claude registry -(`/agents`, `/hooks`, `/ide`) or simply don't exist as Codex slashes -(`/keymap`, `/vim`). The audit task's wording suggests they're cross-listed — -they aren't. - -## Claude registry (`agentChatService.ts:535–577`) — short pass - -| Command | Notes | -|---|---| -| `/agents`, `/hooks`, `/permissions`, `/ide`, `/statusline` | Claude Agent SDK owns these; ADE forwards. UX is text-only, no modal. Same "dead UI" pattern as Codex's `/apps`. Worth noting but the immediate Tier-A scope keeps them. | -| `/keymap`, `/vim` | Not in either registry. The task description listed them as ADE-advertised but they aren't. No action needed. | -| `/clear`, `/compact`, `/copy`, `/diff`, `/feedback`, `/init`, `/model`, `/quit`, `/review`, `/status`, `/title`, `/resume` | Same observations as the Codex versions — works end-to-end via the SDK, modulo the same TUI-only caveats. | -| `/skills`, `/security-review`, `/simplify`, `/tasks`, `/theme`, `/usage` | Claude-specific surfaces; out of scope for this audit. | - -## Summary — recommended palette cleanup (Codex) - -Removing the dead-listed entries below tightens the palette and removes the -"I see a command but nothing happens" failure mode. None of these need any -behavior change in this PR — just delete the rows from -`CODEX_BUILT_IN_SLASH_COMMANDS`: - -- `/fork`, `/resume`, `/rollback`, `/unarchive` — §A.6 was supposed to remove - these; they were missed during the wire pass. **High priority.** -- `/apps`, `/plugins`, `/ps`, `/stop` — Codex-CLI-only surfaces with no ADE - consumer. -- `/mention`, `/new` — duplicate ADE's own composer and `/new chat` flows. -- `/statusline`, `/title` — TUI-only configuration; no effect inside ADE. -- `/exit` — destructive side effect on ADE's runtime; ADE owns `/quit`. - -Optional but nice: deduplicate `/model`, `/status`, `/quit`, `/clear` once the -desktop renderer adopts the same shadow-list rules ADE Code's TUI already uses. - -## Not done in this task - -Per the task scope, this audit is documentation only. No palette entries are -deleted in this PR. The cleanup above is a follow-up PR (or §A.6 fix-forward). diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 8696fdb3f..ffdca623a 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -32,7 +32,7 @@ It is a client. The runtime, lanes, chats, transcripts, PRs, processes, and proo | `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state under `~/.ade/`: the last selected chat per lane (`lastChatByLane`) plus the most recently active lane (`lastLaneId`), used to restore lane focus across launches. | | `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens. Mirrors the Claude Design wireframe terminal palette 1:1: surfaces, text levels, brand violets, status (`running`/`attention`/`idle`/`failed`/`primary`), executor brand colors (Claude/Codex/Cursor/OpenCode/Droid + Shell + Copilot), plus helper exports `laneStatusColor`, `agentStatusColor`, `agentStatusGlyph`, and per-provider `glyph` + `wordmark`. | | `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, navigation DTOs aligned with `apps/desktop/src/shared/types`. | -| `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer` (with `DrawerPrSummary` rows), `ChatView` (exports `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText` for `/copy`), `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`, and `TerminalPane` (xterm-headless preview pane that consumes `ChatTerminalPreviewResult` from `ade.terminal.preview` plus live `ade.pty.data` chunks to render a real terminal grid inside Ink). | +| `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer` (with `DrawerPrSummary` rows), `ChatView` (exports `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText` for `/copy`), `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`, and `TerminalPane` (xterm-headless preview pane that consumes `ChatTerminalPreviewResult` from `ade.terminal.preview` plus live `ade.pty.data` chunks to render a real terminal grid inside Ink; running Claude terminals can be put into direct control mode from the TUI). | | `apps/ade-cli/src/tuiClient/keybindings/index.ts` | Verbatim `~/.claude/keybindings.json` reader and TUI action dispatcher (chord support, vim namespace, clipboard-image paste hooks). Resolves `defaultKeybindingsPath()`, parses the Claude keybindings schema, and maps key sequences onto TUI actions. | | `apps/ade-cli/src/tuiClient/statusline/index.ts` | Claude-compatible status line config reader and runner. Reads the `~/.claude/statusline.json` contract, executes the configured status command, and exposes the rendered lines to `ModelStatus`. | | `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds). Imported per-module so ade-cli typecheck stays scoped. | @@ -86,6 +86,13 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Pending tool approvals surface as `ApprovalPrompt`. - **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. For an active lane, the default `lane-details` view shows live git stats (`DiffLineStats` via `diff.listLaneDiffStats`), the linked PR if any (`pr.listPrsByLane`), the most recent tool/command summary, elapsed time for the active turn, and a `worktreeAvailable` guard that surfaces a recoverable warning when a lane's worktree path is missing from disk. - **FooterControls** — two-row footer. The top row (mode bar, only present when there's content) shows provider glyph + label, model display, fast-mode badge, reasoning effort, permission summary, pending steer count, a 10-cell token usage bar (`TokenBar`) that recolors at 50 / 80 / 95 %, and the cached context-percent / token summary. The bottom row shows pane toggles (`lanes`, `setup`, plus an optional `agents` toggle when subagents/teammates exist) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. +- **Claude terminal control** — when the active session is a running + Claude terminal, `Ctrl+T` moves keyboard input from ADE into that + terminal. `TerminalPane` switches from preview mode to a bordered + control frame, stops hiding Claude's bottom input rows, and the footer + shows `CLAUDE CONTROL` with `Ctrl+T` to return to ADE and `Ctrl+]` + as the escape chord. Raw terminal input strips only those control + bytes before forwarding the rest to the PTY. Heartbeats are kept alive with `startTuiHeartbeat` so the runtime knows the chat client is still attached. @@ -189,7 +196,7 @@ ade --socket /tmp/ade-runtime-dev.sock code # attach to a specific socket (dev runtime, peer machine, etc.) ``` -After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli.cjs` and `dist/tuiClient/cli.mjs` exist for packaged and linked use. During repo development, `npm run dev:code` runs the source TUI against the shared dev runtime at `/tmp/ade-runtime-dev.sock`. +After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli.cjs` and `dist/tuiClient/cli.mjs` exist for packaged and linked use. The CLI build verifier imports `dist/tuiClient/cli.mjs` from an isolated temp directory, checks that bundled `__dirname` / `__filename` references have ESM shims, and confirms `runAdeCodeCli(["--help"])` prints the ADE Code help banner without relying on repo-local `node_modules`. During repo development, `npm run dev:code` runs the source TUI against the shared dev runtime at `/tmp/ade-runtime-dev.sock`. ## Claude Code 2.1.x parity diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 0a11b874b..26794a77e 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -31,7 +31,7 @@ machinery layered on top. | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS / App Control toggles are suppressed because the Work right-edge sidebar owns those drawers at lane scope; the pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware ADE skill roots. Proof remains chat-scoped and stays on the chat header. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware ADE skill roots. Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, macOS VM, and attachment context into prompt text. Automatic macOS VM capability context is prompt-intent gated (`ADE VM`, `macOS VM`, Lume, isolated macOS GUI, etc.) so ordinary sends do not query or inject VM state. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 0f33b8ac7..d2f90084e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -354,7 +354,11 @@ new entry, and the `AgentChatPane` `revealCreatedTerminal` effect calls the same drawer with the recovered `{ terminalId, ptyId, label }`. `ChatTerminalToggle` is the header button that shows the active tab -count. +count. The drawer is mounted only when lane tool drawers are visible on +the chat surface. Work-grid tiles pass `hideLaneToolDrawers` because the +Work sidebar owns lane-scoped tools there; in that mode the header +toggle is absent and the pane does not call `ade.terminal.list` just to +hydrate a hidden drawer. ## Pending input modal diff --git a/docs/features/computer-use/app-control.md b/docs/features/computer-use/app-control.md index 997540a7d..e719dd9fb 100644 --- a/docs/features/computer-use/app-control.md +++ b/docs/features/computer-use/app-control.md @@ -69,7 +69,7 @@ The companion **chat terminal** surface lives at `ade.terminal.*` and shares the - **Inspect** — overlays a hit-test crosshair on the screenshot. Hovering inspects an element via `inspectPoint`; clicking commits via `selectPoint`, producing an `AppControlContextItem` that the chat composer attaches as a context chip plus an attachment. Connect / launch calls forward the resolved `laneId` so the resulting `AppControlSession` records its launching lane. -- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` mounts the chat-scoped panel, owns `appControlContextItems`, and renders App Control chips alongside file attachments. The pane polls `ade.appControl.getStatus` to gate the header toggle on platform support. When mounted as a Work tile (`hideLaneToolDrawers={true}`) the in-chat App Control drawer toggle is suppressed because the Work sidebar owns that drawer at lane scope; selections from the sidebar still flow into the chat composer through the `ade:agent-chat:add-app-control-context` window event. +- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` mounts the chat-scoped panel, owns `appControlContextItems`, and renders App Control chips alongside file attachments. The pane polls `ade.appControl.getStatus` to gate the header toggle on platform support only when lane tool drawers are visible. When mounted as a Work tile (`hideLaneToolDrawers={true}`) the in-chat App Control drawer toggle and status poll are suppressed because the Work sidebar owns that drawer at lane scope; selections from the sidebar still flow into the chat composer through the `ade:agent-chat:add-app-control-context` window event. - `apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx` mounts the lane-scoped panel under the `app-control` tab and runs its own `AppControlSession` subscription. When the active session's `laneId` differs from the sidebar's active lane it shows a `WarningBanner` ("App Control is attached to a different lane…"); the user can still control the existing session, but selections will not attach to the active lane's chat until the tool session is relaunched against the matching lane. - `apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx` reads `AppControlSession` to decorate the App Control launch terminal tab with a status tone (`active` / `warn` / `error`). diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index c44f7737d..311b06aa0 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -48,6 +48,12 @@ Runtime services (`apps/ade-cli/src/services/lanes/` and friends): `lane` action domain (CRUD, runtime isolation, branch switching, templates, diagnostics) over JSON-RPC; remote runtimes are reached over the SSH-tunneled equivalent. +- `apps/desktop/src/main/services/adeActions/registry.ts` mirrors + lane lifecycle actions into the generic ADE action registry. The + `lane.delete` action validates `laneId`, resolves the lane overlay + context before teardown, injects lane environment cleanup when an + env-init config applies, delegates to `laneService.delete`, and + releases any leased port range for the deleted lane. Desktop fallback services (`apps/desktop/src/main/services/lanes/`): @@ -69,8 +75,8 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes run selected child lanes before their selected parents, deleting independent lanes in parallel while blocking a parent if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | -| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, and parent-before-child-safe batch delete planning. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. If GitHub reports the same PR as merged/closed before the local ADE row catches up, the lane tag shows the terminal GitHub state instead of a stale open ADE state. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents, but each batch deletes one lane at a time and records per-lane failures; a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | +| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchSequentially` for serialized per-batch teardown. | | `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated lane color palette split into `LANE_CLASSIC_COLORS` and `LANE_RAINBOW_COLORS`, then combined as `LANE_COLOR_PALETTE`, plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 classic hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | @@ -297,7 +303,10 @@ default from the Lanes list (see `isMissionLaneHiddenByDefault` in `rmSync`, and the `database_cleanup` step now wraps every cascade delete inside a single `begin immediate` / `commit` transaction so a partial failure rolls back to a consistent DB state instead of - leaving lane rows half-deleted. + leaving lane rows half-deleted. Generic ADE action calls + (`lane.delete` through `ade actions run` / TUI `/ade`) use the same + teardown path, including lane-environment cleanup and port lease + release. ## Lane color @@ -390,7 +399,7 @@ Lane management (selected): | `ade.lanes.createFromUnstaged` | `(args: CreateLaneFromUnstagedArgs) => LaneSummary` | | `ade.lanes.attach` | `(args: AttachLaneArgs) => LaneSummary` | | `ade.lanes.importBranch` | `(args: { branchRef: string }) => LaneSummary` | -| `ade.lanes.rename` / `.updateAppearance` / `.reparent` / `.archive` / `.delete` | lane edit operations | +| `ade.lanes.rename` / `.updateAppearance` / `.reparent` / `.archive` / `.delete` | lane edit operations; `.delete` is also surfaced as `lane.delete` through the generic ADE action registry | | `ade.lanes.delete.risk` | `(args: { laneId }) => LaneDeleteRisk` — preflight read for the manage dialog: dirty state, unpushed commit count, remote-branch existence, active processes/PTYs/watchers, env-init flag. | | `ade.lanes.delete.cancel` | `(args: { laneId }) => { cancelled, reason? }` — cooperative cancel during the early teardown steps. After `git_worktree_remove` starts the lane is unrecoverable and cancel is a no-op. | | `ade.lanes.delete.event` (push) | `LaneDeleteEvent` carrying `LaneDeleteProgress` — `steps[]` with per-step status (`pending` / `running` / `completed` / `failed` / `skipped`) plus `overallStatus` (`running` / `completed` / `failed` / `cancelled`) and `cancellable`. | diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index fd842af3f..590e0c9c8 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -15,16 +15,28 @@ This folder documents: ## Where this runs -PR CRUD, GitHub polling, queue landing, integration proposal +PR mutations, GitHub polling, queue landing, integration proposal simulation, the Path-to-Merge orchestrator, and the issue/rebase resolver agent dispatch all run inside the **active ADE runtime** (local daemon for local-bound windows, SSH-attached remote runtime for remote-bound windows). The renderer's `window.ade.prs.*` surface -in `apps/desktop/src/preload/preload.ts` routes every PR call through -`callProjectRuntimeActionOr("pr", …)` and falls back to the legacy -in-process IPC handlers only when no runtime is bound. PR polling -fingerprints, the `prsRouteState.ts` URL-state helper, and the -PR detail panes are renderer-only — they hold no service state. +in `apps/desktop/src/preload/preload.ts` is the routing boundary: +remote-bound windows route PR service work through the remote runtime, +while local-bound windows still use selected legacy in-process IPC +paths during migration. PR polling fingerprints, the +`prsRouteState.ts` URL-state helper, and the PR detail panes are +renderer-only — they hold no service state. + +The PR bridge deliberately splits local and remote reads while the PR +service finishes its runtime migration. Remote-bound windows execute PR +tab reads on the remote runtime through `callPrReadRuntimeActionOr` +(`domain: "pr"`). Local-bound windows call the in-process PR IPC +handlers directly for high-volume reads such as `listWithConflicts`, +`getDetail`, `getStatus`, `getChecks`, `getReviews`, `getComments`, +`getFiles`, `getCommits`, `getDeployments`, `getAiSummary`, and +`getGitHubSnapshot`, so opening the PR tab does not wait on local +daemon startup. Mutations and long-running workflows still use the +project runtime route where that route owns the behavior. For remote-bound windows, GitHub polling, the queue automation loop, and the Path-to-Merge orchestrator all execute on the remote machine. @@ -63,18 +75,18 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | File | Responsibility | |------|---------------| | `PRsPage.tsx` | Top-level tab shell (GitHub vs Workflows) with URL-driven state. Consumes create-PR handoff params from either router search or hash search (`create=1`, `sourceLaneId` / `laneId`, `target=primary`) and the `prs.create` dialog bus props, then opens `CreatePrModal` with matching initial values without persisting the one-shot route as the last PR route. | -| `state/PrsContext.tsx` | PR data provider (list, selection, queue groups, rebase needs, convergence runtime state) | -| `prsRouteState.ts` | URL ↔ page state mapping | +| `state/PrsContext.tsx` | PR data provider (list, selection, queue groups, rebase needs, convergence runtime state). Selected-PR primary reads apply progressively as status/check/review/comment requests resolve, so one slow piece does not hold the whole detail pane busy; cached snapshots stay visible during GitHub rate limits. | +| `prsRouteState.ts` | URL ↔ page state mapping plus project-scoped last-route storage. When a project root is known, the PRs tab reads only that project's stored route and does not fall back to the legacy global route from another project. | | `CreatePrModal.tsx` | Draft/queue/integration PR creation with lane warnings, branch name validation, and optional initial values for single-PR handoffs from lane/chat surfaces. A `target: "primary"` handoff resolves the base branch from the primary lane (falling back to `main`). | | `tabs/NormalTab.tsx` | Normal PR list | -| `tabs/GitHubTab.tsx` | Unified repo + external PR browser with label filters, CI badges, review indicators | +| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. | | `tabs/QueueTab.tsx` | Merge queue UI. Hosts the "Automate Merging" entry point that opens `QueueAutomateMergingModal` with the queue's eligible members (everything that has not landed yet). | | `tabs/QueueAutomateMergingModal.tsx` | Stack-wide automation modal: edits one `PipelineSettings` config that applies to every queue member, then sequentially saves settings, calls `ade.prs.retargetBase` for non-leading members so each PR's base points at the queue's tracking branch, starts Path-to-Merge via `ade.prs.pathToMerge.start`, and polls `convergenceStateGet` every 4 s until the runtime status is terminal. Halts the sequence on the first `failed | cancelled | stopped`. Closing mid-sequence stops dispatching new starts but leaves already-launched orchestrators running. | | `tabs/IntegrationTab.tsx` | Integration (merge-plan) proposals and execution, including merge-into-lane selection, apply-and-resimulate, and adopted-lane cleanup messaging | | `tabs/RebaseTab.tsx` | Lane rebase needs (base + queue + PR target) and attention items | | `tabs/WorkflowsTab.tsx` | Container for queue/integration/rebase sub-tabs | | `tabs/queueWorkflowModel.ts` | Pure model for queue tab rendering (active/history bucketing, guidance computation) | -| `detail/PrDetailPane.tsx` | Selected PR detail pane: status, checks, reviews, comments, merge readiness, bypass, Path-to-Merge convergence sub-tab (labelled "Path to Merge" in the tab list), resolver modals. Switches the Overview tab between the legacy grid and the Timeline+Rails layout based on `prsTimelineRailsEnabled`. Persists the selected sub-tab (`overview | convergence | files | checks | activity`) per PR in `localStorage` under `ade:prs:detailTabs:v1`, mirrored through the `detailTab` URL param so deep links restore the last-used tab | +| `detail/PrDetailPane.tsx` | Selected PR detail pane: status, checks, reviews, comments, files, commits, merge readiness, bypass, Path-to-Merge convergence sub-tab (labelled "Path to Merge" in the tab list), resolver modals. Rich detail/files/commits/action-run reads render progressively; late cached snapshot hydration can update snapshot-owned fields but cannot overwrite richer live detail/files/commits already loaded for the selected PR. Switches the Overview tab between the legacy grid and the Timeline+Rails layout based on `prsTimelineRailsEnabled`. Persists the selected sub-tab (`overview | convergence | files | checks | activity`) per PR in `localStorage` under `ade:prs:detailTabs:v1`, mirrored through the `detailTab` URL param so deep links restore the last-used tab | | `detail/PrDetailTimelineRails.tsx` | Timeline+Rails overview: merges timeline events, commit rail (seeded from both `PrActivityEvent.commit_push` entries and the `getCommits` snapshot), status rail, deployment cards, AI summary, and command-palette navigation (`g c` / `g t` / `g f` and `[` / `]`) | | `shared/PrTimeline.tsx` | Timeline column: synthesises `PrTimelineEvent`s from detail data, handles per-PR filters (`PrTimelineFilters`), renders grouped events | | `shared/PrCommitRail.tsx`, `shared/PrStatusRail.tsx` | Right-hand rails on the timeline view: commit list, checks/reviews summary, deployment chips | @@ -105,7 +117,7 @@ Shared contracts: | `apps/desktop/src/shared/types/prs.ts` | PR DTOs and integration proposal contracts, including `preferredIntegrationLaneId`, `mergeIntoHeadSha`, `integrationLaneOrigin`, and `additionalInstructions` fields. | | `apps/desktop/src/shared/types/git.ts` | `BranchPullRequest` (branch / prNumber / title / state / url / author / updatedAt) — the lightweight PR shape returned by `prService.listOpenPullRequests` and consumed by the branch picker without going through `PrSummary`. | | `apps/desktop/src/shared/types/conflicts.ts` | Conflict resolver DTOs; `PrepareResolverSessionArgs.additionalInstructions` is appended to generated resolver prompts. | -| `apps/desktop/src/shared/ipc.ts` / `apps/desktop/src/preload/preload.ts` | PR IPC constants and renderer bridge for proposal simulation, update, commit, resolver, and cleanup flows. | +| `apps/desktop/src/shared/ipc.ts` / `apps/desktop/src/preload/preload.ts` | PR IPC constants and renderer bridge for proposal simulation, update, commit, resolver, cleanup, and read flows. Read-heavy PR tab calls route to the remote runtime only for remote-bound windows and use in-process IPC for local-bound windows. Local PR/session push subscriptions are multiplexed so multiple renderer subscribers share one IPC listener per channel. | ## Core model @@ -160,7 +172,7 @@ Selected channels exposed through `preload.ts`: - `ade.prs.pathToMerge.start`, `ade.prs.pathToMerge.stop` — drive the Path-to-Merge orchestrator (see [`path-to-merge.md`](./path-to-merge.md)) - `ade.prs.retargetBase` — re-point a PR's base branch (used by Queue Automate Merging when stacking the chain bases before PtM picks them up) - `ade.prs.pipelineSettingsGet`, `ade.prs.pipelineSettingsSave`, `ade.prs.pipelineSettingsDelete` -- `ade.prs.getGitHubSnapshot` — merged repo + external PR snapshot. The default fetch includes open external PRs only; closed/merged external history is opt-in with `includeExternalClosed`. +- `ade.prs.getGitHubSnapshot` — repository PR snapshot for the active GitHub repo. The DTO still carries `externalPullRequests` and accepts `includeExternalClosed` for compatibility, but the current service returns repo PRs only and the renderer ignores legacy cross-repo external items. - `ade.prs.simulateIntegration`, `ade.prs.createIntegrationLaneForProposal`, `ade.prs.commitIntegration`, `ade.prs.cleanupIntegrationWorkflow` Integration merge-into flow uses these existing channels with widened @@ -183,22 +195,26 @@ DTOs: ## GitHub data-loading model -The GitHub tab renders a unified list of repo PRs and external PRs -involving the current user, sorted by creation date. A scope filter -(`all` / `ade` / `external`) replaces the previous separate toggle. +The GitHub tab renders PRs from the active repository, sorted by +creation date. The scope filter (`all` / `ade` / `external`) is local +to that repository: `ade` means ADE-managed/linked PRs, while +`external` means repo PRs that are not currently managed by ADE. +Cross-repo PRs involving the viewer are not fetched or displayed. Caching layers: 1. **Runtime cache** — GitHub snapshot is cached for a short TTL - inside `prService` on the active runtime (local daemon or - remote-attached); repeated in-flight snapshot requests are - deduplicated. The default snapshot fetches open external PRs only; - closed/merged external history is requested after the user switches - to a history filter or explicitly refreshes that view. + inside `prService` on the active runtime for remote-bound windows + and in the local in-process PR service for local-bound windows. + Repeated in-flight snapshot requests are deduplicated. The snapshot + fetches repository PRs only. 2. **Renderer cache** — `PrsContext` holds the last snapshot so revisiting the tab renders immediately. Selected PR detail panes hydrate from `listSnapshots({ prId })` before live status, check, - review, and comment requests run in the background. + review, comment, file, and commit requests run in the background. + Each live piece applies as soon as it resolves; a slow comments or + action-runs request does not block status/checks/files from + rendering. 3. **Manual sync** — a "Refresh" action forces a fresh pull. Explicit multi-PR refreshes run with bounded parallelism instead of refreshing each PR serially. @@ -578,10 +594,10 @@ best-effort — failures log a warning and do not abort the tick. banner, check/review/comment sections with running indicators (`PrCiRunningIndicator`), merge readiness with bypass checkbox, PR markdown rendered with `rehype-sanitize` after `rehype-raw`. -- `GitHubTab` renders the unified repo+external list; filter tab - counts respect the active scope. Open views load open external PRs - first; switching to Closed, Merged, or All asks the runtime for the - closed external history snapshot. +- `GitHubTab` renders the active repository's PR snapshot; filter tab + counts respect the active ADE/unmanaged scope. Legacy + `externalPullRequests` entries are ignored even if an old cache + contains them. ## CTO operator tools diff --git a/docs/plans/ade-31-claude-agent-sdk-rewrite.md b/docs/plans/ade-31-claude-agent-sdk-rewrite.md deleted file mode 100644 index 104ef94e3..000000000 --- a/docs/plans/ade-31-claude-agent-sdk-rewrite.md +++ /dev/null @@ -1,1222 +0,0 @@ -# ADE-31 — Claude Agent SDK upgrade & full Claude Code parity - -**Owner:** Arul (planning) / TBD (implementation) -**Linear:** [ADE-31](https://linear.app/ade-linear/issue/ADE-31/look-into-updating-claude-agent-sdk-and-new-features) -**Status:** Plan (locked decisions from 2026-05-11 → 2026-05-12 planning session) -**Worktree:** `.ade/worktrees/updating-claude-agent-sdk-de25474d` -**Branch:** `ade-31-look-into-updating-claude-agent-sdk-and-new-features` - ---- - -## 0. Read me first - -This is a planning document for a load-bearing rewrite. It's long because: - -- The Claude chat backend in ADE today is built on a now-deprecated session API (`unstable_v2_*`) and **must** migrate before the next SDK bump removes it. -- The same rewrite is the cheapest opportunity to bring ADE chat + `ade code` TUI to feature parity with Claude Code 2.1.x, which has shipped two dozen user-visible features since ADE last upgraded. -- Decisions on subagent UI, handoff semantics, permission modal, session storage, config home, and runtime scope (Claude-only vs cross-runtime) were locked over seven rounds of Q&A. They're recorded in §6 (Locked Decisions) so this is a single source of truth, not a thinking-out-loud document. - -**Pass this plan to Codex** to drive implementation. It includes file paths, library citations, UI mockups, parallelization suggestions, and a testing strategy. - ---- - -## 1. Context - -### 1.1 Where ADE is today - -- **SDK pin:** `@anthropic-ai/claude-agent-sdk@^0.2.119` in both `apps/desktop/package.json:53` and `apps/ade-cli/package.json:28`. -- **Desktop chat backend:** `apps/desktop/src/main/services/chat/agentChatService.ts` (~18,800 lines). Uses `unstable_v2_createSession` / `unstable_v2_resumeSession` / `runtime.v2Session.send()` / `runtime.v2Session.stream()`. -- **`ade code` TUI:** `apps/ade-cli/src/tuiClient/cli.tsx` (Ink/React). Does **not** call the SDK directly — it's an Ink RPC client of the desktop main process via JSON-RPC over Unix socket. The desktop runtime owns the SDK lifecycle for both surfaces. -- **Tools registered today:** workflowTools, linearTools, ctoOperatorTools, universalTools, memoryTools (built at `agentChatService.ts:4698–4780`). `ENABLE_TOOL_SEARCH` env: `"auto"`, off for CTO sessions. -- **Hooks registered today:** **PreCompact only** (`DEFAULT_FLUSH_PROMPT`, `agentChatService.ts:11509–11522`). -- **settingSources today:** `["user","project","local"]` (`agentChatService.ts:11503`). -- **Subagent UI:** `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx:246-361`, keyed by ADE-internal `taskId` (not the SDK's `agent_id` / `parent_tool_use_id`). Derived in `chatExecutionSummary.ts:23-79` from internal `subagent_started` / `subagent_progress` / `subagent_result` events. -- **Handoff button:** `AgentChatPane.tsx:5335-5504` → IPC `ade.agentChat.handoff` → `registerIpc.ts:6317-6320` → `agentChatService.ts:12612-12711`. Today builds a 12-message brief, spawns a new chat in the same lane with the target model. -- **Settings page:** `apps/desktop/src/renderer/components/app/SettingsPage.tsx`, route `/settings`, 20+ sections. - -### 1.2 Why now - -[SDK 0.2.133](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) deprecated `unstable_v2_createSession` / `unstable_v2_resumeSession` / `unstable_v2_prompt` — the exact APIs ADE chat is built on: - -> **0.2.133:** Deprecated unstable V2 session API (`unstable_v2_createSession` / `unstable_v2_resumeSession` / `unstable_v2_prompt`) — use `query()` instead. - -They still work on 0.2.139, but they will be removed. ADE is one SDK release away from broken chat. Bundling the bump with the broader parity push is cheaper than two passes. - -### 1.3 Reference: t3code - -[pingdotgg/t3code](https://github.com/pingdotgg/t3code) is the closest open-source analogue: a multi-provider GUI on top of the Agent SDK (Claude / Codex / OpenCode). They pin `^0.2.111` (`apps/server/package.json`), use Effect + Bun + SQLite-Bun, and structure per-provider adapters in `apps/server/src/provider/Layers/` (`ClaudeAdapter.ts`, `CodexAdapter.ts`, `ProviderSessionDirectory.ts`, `ProviderSessionReaper.ts`). - -Things to borrow: - -- **Session reaper pattern.** Explicit cleanup of orphaned SDK subprocesses on crash/restart. ADE doesn't have an equivalent; we should add one (see §4.2 Subprocess Lifecycle). -- **Canonical event vocabulary across runtimes.** They normalize provider events into `content.delta` / `item.started` / `turn.completed`. ADE has this conceptually across five runtimes (Claude/Codex/Cursor/Droid/OpenCode); the migration is a chance to formalize it so the new SDK shape feeds cleanly into the existing renderer. - -Things ADE does already better than t3code: -- Newer SDK target (0.2.139 vs 0.2.111). -- Five runtimes vs three. -- TUI surface (`ade code`); t3code is web GUI only. -- Lanes/worktrees as a core concept (t3code only has session scope). -- Far richer UI: ChatSubagentsPanel, 20-section Settings, handoff button, plan-mode approval, AskUserQuestion integration, memory system, and Linear/CTO integrations. -- Effect adoption isn't a fit for ADE; we keep TS without it. - ---- - -## 2. Goals & Non-Goals - -### 2.1 Goals - -1. Migrate `agentChatService.ts` off `unstable_v2_*` to `query()` + async-iterable prompt + `streamInput` before the API is removed. -2. Bring ADE chat (Claude runtime) to feature parity with Claude Code 2.1.x on subagents, sessions, hooks, output styles, plugins, skills, permission modes. -3. Bring `ade code` TUI to full parity with Claude Code's terminal UX on keybindings + vim mode, status line, image paste, and the slash-command surface. -4. Pass it to Codex with enough context to execute. - -### 2.2 Non-goals (this pass) - -- **Sandbox isolation** (FS + network). Skipped this pass; lane-as-worktree already gives some isolation. -- **Channels** (Telegram / Discord / iMessage / webhooks). Skipped; ADE has its own multi-surface story (desktop/TUI). -- **CLI flag compatibility with `claude`** (`--continue` / `--resume` / `--print` / etc.). `ade code` is multi-runtime; flag passthrough is ambiguous. No new CLI flags. -- **Replicating the `claude agents` machine-wide view.** ADE Claude sessions auto-appear in `claude agents` via shared storage. If you want that view, run `claude agents`. -- **forkSession-as-user-feature.** The Handoff button covers branching; no `/branch` command. -- **Agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`)** as a first-class feature. Hooks are wired as no-ops; UI structurally ready. Users who set the env var get it; ADE doesn't ship it on. -- **Subagent / `/agents` / `/skills` / `/context` / new hooks for non-Claude runtimes.** Claude runtime only this pass. Codex / Cursor / Droid / OpenCode subagent stories are separate initiatives. -- **`/config` slash command.** ADE's settings page is broader and already exists at `/settings`. -- **Multi-pass migration.** Hard cutover in a single PR (no feature flag). -- **Old chat history migration.** Drop existing transcripts; users start fresh. - ---- - -## 3. Source material & references - -### 3.1 Claude Agent SDK - -- [Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview) -- [TypeScript SDK reference](https://code.claude.com/docs/en/agent-sdk/typescript) -- [Sessions guide](https://code.claude.com/docs/en/agent-sdk/sessions) -- [Hooks guide](https://code.claude.com/docs/en/agent-sdk/hooks) -- [Subagents in the SDK](https://code.claude.com/docs/en/agent-sdk/subagents) -- [Skills in the SDK](https://code.claude.com/docs/en/agent-sdk/skills) -- [Plugins in the SDK](https://code.claude.com/docs/en/agent-sdk/plugins) -- [Permissions](https://code.claude.com/docs/en/agent-sdk/permissions) -- [Migration guide (claude-code SDK → claude-agent-sdk + V2 → V1)](https://code.claude.com/docs/en/agent-sdk/migration-guide) -- [TypeScript SDK CHANGELOG](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) -- [TypeScript SDK repo](https://github.com/anthropics/claude-agent-sdk-typescript) -- [Example agents (demos repo)](https://github.com/anthropics/claude-agent-sdk-demos) - -### 3.2 Claude Code (CLI) features for parity - -- [Claude Code overview](https://code.claude.com/docs/en/overview) -- [Claude Code CLI changelog](https://code.claude.com/docs/en/changelog) -- [Output styles](https://code.claude.com/docs/en/output-styles) -- [Status line](https://code.claude.com/docs/en/statusline) -- [Keybindings](https://code.claude.com/docs/en/keybindings) -- [Agent teams](https://code.claude.com/docs/en/agent-teams) -- [Subagents (filesystem-based)](https://code.claude.com/docs/en/sub-agents) -- [Permission modes](https://code.claude.com/docs/en/permission-modes) -- [Settings](https://code.claude.com/docs/en/settings) -- [Plugins](https://code.claude.com/docs/en/plugins) -- [Plugin marketplaces](https://code.claude.com/docs/en/plugin-marketplaces) -- [Skills](https://code.claude.com/docs/en/skills) - -### 3.3 Reference implementation - -- [pingdotgg/t3code](https://github.com/pingdotgg/t3code) — `apps/server/src/provider/Layers/ClaudeAdapter.ts`, `ProviderSessionDirectory.ts`, `ProviderSessionReaper.ts` - -### 3.4 Local references - -- `apps/desktop/src/main/services/chat/agentChatService.ts` — chat backend (target of the migration) -- `apps/desktop/src/main/services/chat/buildClaudeV2Message.ts` — multimodal message builder -- `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx` — subagent UI (target of the redesign) -- `apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts` — snapshot derivation -- `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx:2217–2241` — existing clipboard image paste in desktop (already works) -- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx:5335-5504` — Handoff button (target of the split) -- `apps/desktop/src/main/services/ipc/registerIpc.ts:6317-6320` — handoff IPC entry -- `apps/desktop/src/main/services/ai/aiIntegrationService.ts:788` — `availability.claude` (target of the binary/auth split) -- `apps/desktop/src/main/packagedRuntimeSmoke.ts` — startup health probe (will simplify after dropping `pathToClaudeCodeExecutable`) -- `apps/ade-cli/src/tuiClient/cli.tsx` — TUI entry -- `apps/ade-cli/src/tuiClient/app.tsx` — TUI main state -- `apps/ade-cli/src/tuiClient/commands.ts` — TUI built-in slash command list -- `apps/ade-cli/src/tuiClient/components/SlashPalette.tsx`, `MentionPalette.tsx`, `ApprovalPrompt.tsx`, `Drawer.tsx`, `RightPane.tsx`, `ChatView.tsx` - ---- - -## 4. Target architecture - -### 4.1 Session lifecycle - -#### Today -``` -agentChatService.ts - ├─ unstable_v2_createSession(opts) → runtime.v2Session - ├─ runtime.v2Session.send(userMsg) - ├─ for await (msg of runtime.v2Session.stream()) { … } - └─ unstable_v2_resumeSession(sessionId, opts) -``` - -#### Target -``` -agentChatService.ts - ├─ asyncInputQueue: AsyncIterable (internal pump) - ├─ q = query({ prompt: asyncInputQueue, options }) → Query - ├─ q.streamInput(asyncIterable) or asyncInputQueue.push(userMsg) - ├─ for await (msg of q) { … } - ├─ q.setPermissionMode(mode) - ├─ q.setModel(model) - ├─ q.interrupt() / q.close() - └─ resume: query({ prompt: …, options: { resume: sessionId } }) -``` - -Reference: [Sessions guide → "automatic session management" + "use session options with query()"](https://code.claude.com/docs/en/agent-sdk/sessions). - -#### Key surfaces on `Query` we'll use - -| Method | Purpose | Today's equivalent | -|---|---|---| -| `streamInput(asyncIter)` | feed user turns into the live session | `session.send()` | -| `setPermissionMode(mode)` | swap mode mid-session | `session.setPermissionMode()` | -| `setModel(model?)` | swap model mid-session | n/a today | -| `setMaxThinkingTokens(n)` | force thinking budget | manual in opts today | -| `applyFlagSettings({...})` | apply settings at runtime without spawning CLI | n/a | -| `supportedCommands()` / `supportedAgents()` / `supportedModels()` | runtime introspection | partial today | -| `getContextUsage()` | context breakdown by category (drives `/context`) | n/a | -| `promptSuggestion()` | re-request prompt suggestions | implicit today | -| `interrupt()` | cancel turn | similar | -| `rewindFiles(userMessageId, { dryRun })` | roll back file changes from a turn | n/a | -| `close()` | force-terminate | similar | - -### 4.2 Subprocess lifecycle (new — borrowed from t3code's reaper) - -Each `query()` spawns a Claude Code subprocess (since 0.2.113, the SDK ships a per-platform native binary as optional dep). We need explicit reaping: - -- A new `ClaudeSubprocessReaper` service tracks `(pid, sessionId, lane, createdAt)` for every live `query()` instance. -- On process exit (ADE quits, daemon SIGTERM, crash), the reaper sends `SIGTERM` then `SIGKILL` after a grace period to every tracked subprocess. -- Reference: `pingdotgg/t3code` — `apps/server/src/provider/Layers/ProviderSessionReaper.ts`. - -### 4.3 Storage - -- **Transcripts:** SDK default `~/.claude/projects//.jsonl`. The encoding replaces every non-alphanumeric in the absolute `cwd` with `-` (see [Sessions guide → Resume by ID tip](https://code.claude.com/docs/en/agent-sdk/sessions#resume-by-id)). -- **ADE pointer table:** new SQLite table `claude_sessions` mapping `session_id ↔ lane_id ↔ title ↔ tags ↔ created_at`. Updated on `system:init` (capture session_id, title) and on rename/tag operations (call `renameSession()` / `tagSession()`, mirror into table). -- **Cross-tool compatibility:** ADE-created Claude sessions are visible to `claude --resume` from a terminal `cd`'d into the lane worktree; `claude agents` lists them automatically. Conversely, sessions a user started via `claude` are discoverable via `listSessions({ dir: laneWorktree })`. -- **Old chat history:** drop. The migration release notes call this out. No conversion tool. - -### 4.4 Config home - -| Directory | Owner | Contents | -|---|---|---| -| `~/.claude/` | Claude Code-compat | settings.json, agents/, commands/, skills/, output-styles/, statusline (script + setting), keybindings.json, plugins/, projects/ (transcripts) | -| `.claude/` | Project-level Claude-compat | settings.json, settings.local.json, agents/, commands/, skills/, output-styles/ | -| `~/.ade/` | ADE-only | lanes/, identities/, memory DB, ade-state, runtime sock | -| `.ade/` | Project-level ADE-only | lanes/, ade.db, skills/ (multi-runtime — read alongside `.claude/skills/`) | - -`settingSources` stays `["user", "project", "local"]`. - -### 4.5 Permission flow (updated) - -[Reference](https://code.claude.com/docs/en/agent-sdk/permissions#how-permissions-are-evaluated). - -``` -tool requested - │ - ▼ -Hooks (PreToolUse) ──[deny]──► blocked - │ - ▼ -Deny rules (disallowedTools + settings) - │ - ▼ -Permission mode (default / plan / acceptEdits / bypassPermissions / auto) - │ ─ auto: model-classifier decides per call (NEW) - ▼ -Allow rules (allowedTools + settings) - │ - ▼ -canUseTool callback (ADE's approval dialog lives here) - ▼ -ADE approval dialog (Allow / Allow for Session / Deny) -``` - -ADE's `canUseTool` (`agentChatService.ts:4284-4630`) stays as the **last-step gate**. The "memory orientation guard" stays inside `canUseTool`. The `EnterPlanMode` / `ExitPlanMode` / `AskUserQuestion` special-casing stays. - -What's new: -- `auto` mode added to the picker. UI explanation: "Claude judges each tool call — uses model classifier instead of asking you." -- The permission modal gets a visual refresh + adds the `auto` row. - -### 4.6 Multi-runtime canonical event vocabulary - -We formalize what t3code does. Every runtime adapter (ClaudeAdapter, CodexAdapter, CursorAdapter, DroidAdapter, OpenCodeAdapter) emits the same internal event shape so the renderer doesn't branch on runtime: - -```ts -type RuntimeEvent = - | { type: "turn.started"; turnId: string } - | { type: "content.delta"; turnId: string; text: string; agentId?: string; parentToolUseId?: string } - | { type: "tool.started"; toolUseId: string; toolName: string; input: unknown; agentId?: string } - | { type: "tool.completed"; toolUseId: string; output: unknown; durationMs?: number } - | { type: "tool.failed"; toolUseId: string; error: string } - | { type: "subagent.started"; agentId: string; parentToolUseId: string; type: string; background?: boolean } - | { type: "subagent.progress"; agentId: string; text?: string; tokens?: number } - | { type: "subagent.completed"; agentId: string; summary: string; usage: Usage } - | { type: "teammate.idle"; teamName: string; teammateName: string } // Claude-only - | { type: "task.completed"; taskId: string; subject: string; teammateName?: string; teamName?: string } // Claude-only - | { type: "turn.completed"; turnId: string; stopReason: string; usage: Usage } - | { type: "compact.boundary"; uuid: string }; -``` - -The ClaudeAdapter is the first one to migrate. Other adapters keep emitting their existing shapes; a shim translates to the canonical vocabulary as we go. - -### 4.7 Availability detection split - -Today: `availability.claude: boolean` at `aiIntegrationService.ts:788`. After: split into two: - -```ts -availability.claude = { - binary: { present: boolean; version?: string; source: "bundled" | "path" | "missing" }, - auth: { ready: boolean; mode: "api_key" | "oauth" | "bedrock" | "vertex" | "foundry" | "none" }, -}; -``` - -Integration tile shows states distinctly: -- **Binary missing** → "Claude unavailable (binary missing — should not happen with bundled install; run `/doctor`)" -- **Binary ready · awaiting auth** → "Sign in to use Claude" -- **Binary ready · authenticated** → "Ready" + last-used model - -Caller sites for `availability.claude` need a sweep — anywhere that previously treated it as a boolean now checks `availability.claude.auth.ready`. - ---- - -## 5. Phases - -### Phase 0 — SDK bump (0.5 day) - -**Goal:** trivially bump pinned version so the migration starts from a known-good baseline. - -Tasks: -1. Bump `@anthropic-ai/claude-agent-sdk` `^0.2.119` → `^0.2.139` in: - - `apps/desktop/package.json:53` - - `apps/ade-cli/package.json:28` -2. `bun install`. -3. Smoke-test desktop chat + `ade code` against today's `unstable_v2_*` API. Expect: still works; deprecation warnings may appear at runtime/logs. -4. Run typecheck, full test suite (sharded). - -Acceptance: no regressions; deprecation warnings logged but not fatal. - -### Phase 1 — Migration off `unstable_v2_*` (3–5 days) — BLOCKING, HARD CUTOVER - -**Goal:** rewrite `agentChatService.ts` to use `query()` + `Query` + `streamInput`. Delete `unstable_v2_*` call sites in one PR. No feature flag. - -#### 1.1 Build the input pump - -Create an internal async-iterable adapter: - -```ts -class InputPump { - private resolvers: Array<(v: IteratorResult) => void> = []; - private buffer: SDKUserMessage[] = []; - private closed = false; - - push(msg: SDKUserMessage) { /* … */ } - close() { /* … */ } - [Symbol.asyncIterator](): AsyncIterator { /* … */ } -} -``` - -This lives in a new module: `apps/desktop/src/main/services/chat/claudeInputPump.ts`. - -#### 1.2 Rewrite the session loop - -Replace `unstable_v2_createSession` / `unstable_v2_resumeSession`: - -```ts -import { query, type Query, type Options } from "@anthropic-ai/claude-agent-sdk"; - -function buildClaudeOptions(runtime, managed, chatConfig): Options { - return { - cwd: laneWorktreeRoot, - env: { ENABLE_TOOL_SEARCH: managed.session.identityKey === "cto" ? "0" : "auto" }, - permissionMode: managed.permissionMode, // default | plan | acceptEdits | bypassPermissions | auto - allowDangerouslySkipPermissions: managed.permissionMode === "bypassPermissions", - includePartialMessages: true, - agentProgressSummaries: true, - promptSuggestions: true, - maxBudgetUsd: chatConfig.sessionBudgetUsd, - model: chatConfig.claudeModel, - effort: chatConfig.effort, // NEW: pass directly, drop CLAUDE_EFFORT_TO_TOKENS - forwardSubagentText: true, // NEW - systemPrompt: { type: "preset", preset: "claude_code", append: ADE_SYSTEM_PROMPT_APPEND }, - settingSources: ["user", "project", "local"], - skills: "all", // NEW (replaces 'Skill' in allowedTools) - hooks: buildAdeHooks(), // EXPANDED (see Phase 2) - canUseTool: buildClaudeCanUseTool(runtime, managed), - abortController: runtime.abortController, - title: undefined, // let SDK auto-generate - // pathToClaudeCodeExecutable: REMOVED — trust bundled binary - }; -} - -const pump = new InputPump(); -const q: Query = query({ prompt: pump, options: buildClaudeOptions(...) }); - -runtime.claudeQuery = q; -runtime.claudeInputPump = pump; - -// stream -for await (const msg of q) { - handleClaudeMessage(runtime, msg); -} -``` - -For resume: - -```ts -const q = query({ - prompt: pump, - options: { ...buildClaudeOptions(...), resume: sessionId }, -}); -``` - -For the Handoff (Claude → Claude only): - -```ts -const q = query({ - prompt: pump, - options: { ...buildClaudeOptions(...), resume: sourceSessionId, forkSession: true }, -}); -``` - -#### 1.3 Translate control surfaces - -| Today | After | -|---|---| -| `runtime.v2Session.send(msg)` | `pump.push(toSDKUserMessage(msg))` | -| `runtime.v2Session.stream()` | `for await (const m of q) { … }` | -| `runtime.v2Session.setPermissionMode(m)` | `await q.setPermissionMode(m)` | -| `runtime.v2Session.interrupt()` | `await q.interrupt()` | -| `runtime.v2Session.close()` | `q.close()` | - -#### 1.4 Drop `'Skill'` from allowedTools - -Replace any `allowedTools: [..., 'Skill', ...]` with the new `skills` option (`'all'` or `string[]`). The SDK auto-enables the Skill tool when `skills` is set. - -#### 1.5 Drop `pathToClaudeCodeExecutable` - -Stop resolving and passing the path. The SDK loads its bundled per-platform binary. Convert `apps/desktop/src/main/packagedRuntimeSmoke.ts` from a path-resolver to a lightweight `query()` health probe: - -```ts -async function probeClaudeStartup() { - const abortController = new AbortController(); - const timeout = setTimeout(() => abortController.abort(), CLAUDE_PROBE_TIMEOUT_MS); - try { - const stream = query({ - prompt: "System initialization check. Respond with only the word READY.", - options: { cwd: os.tmpdir(), permissionMode: "plan", abortController }, - }); - for await (const msg of stream) { - if (msg.type === "result" && msg.subtype === "success") return { ok: true }; - } - return { ok: false, reason: "no result message" }; - } finally { - clearTimeout(timeout); - } -} -``` - -#### 1.6 Drop manual effort → token-budget mapping - -Delete `CLAUDE_EFFORT_TO_TOKENS` and any code that builds `thinking: { type: 'enabled', budget_tokens: N }`. Pass `effort` directly. Keep `xhigh` as a valid value (SDK accepts it at runtime even though the publicly-exported `EffortLevel` type since 0.2.84 is the narrower `'low' | 'medium' | 'high' | 'max'`). - -References: -- [SDK CHANGELOG 0.2.84](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md): "Exported `EffortLevel` type (`'low' | 'medium' | 'high' | 'max'`)" -- [SDK CHANGELOG 0.2.49](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md): "SDK model info includes `supportsEffort`, `supportedEffortLevels`, `supportsAdaptiveThinking`" - -#### 1.7 Build the canonical event translator - -Wherever `agentChatService.ts` today emits `subagent_started` / `subagent_progress` / `subagent_result` events (used by `chatExecutionSummary.ts:23-79`), additionally emit the new canonical event shape (§4.6). Existing renderer paths keep working during the transition. - -#### 1.8 Subprocess reaper - -Wire `ClaudeSubprocessReaper` as a new service. Register on every `query()` create. Tear down on: -- Desktop main process exit (Electron `before-quit`) -- ADE daemon SIGTERM/SIGINT -- Lane close -- Process crash recovery on launch (scan for orphans) - -#### 1.9 Fix the finicky slash command reliability bug - -User-reported: "claude chat still finicky, commands don't always come through." Almost certainly lives in the input pump / pre-expansion logic. As part of the rewrite: - -- Add an `expandSlashCommand(rawInput, registry)` step *before* anything reaches the pump. -- Log every input expansion at debug level. -- Add a test fixture exercising `/clear`, `/commit`, `/push`, multi-word commands like `/linear list`, and slash commands typed mid-sentence. - -Acceptance for Phase 1: desktop chat + `ade code` work for all current flows on the new pipeline. Old call sites of `unstable_v2_*` removed. - -### Phase 2 — SDK feature adoption (3–4 days, parallelizable) - -Each item is independent and small enough to ship in its own PR after Phase 1 merges. - -#### 2.1 `startup()` pre-warm - -When (a) a lane is selected AND (b) a Claude model is the active model: -- Call `startup({ options: buildClaudeOptions(...) })` to warm a `WarmQuery`. -- Cache per `(lane_id, model_id)`. -- Re-warm on lane switch or model change. Discard previous warm. -- First user message in the warmed session consumes the `WarmQuery`; subsequent messages create fresh `query()` instances normally. - -Reference: [CHANGELOG 0.2.89](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) ("Added `startup()` to pre-warm CLI subprocess before `query()`, ~20x faster first query"). - -#### 2.2 `getContextUsage()` + `/context` view - -Add slash command `/context`. On invocation, call `q.getContextUsage()` and render a breakdown panel: - -``` -Context usage - System prompt 4,287 3.5% - Tools 8,113 6.6% - CLAUDE.md / AGENTS.md 842 0.7% - Skills (loaded) 2,401 2.0% - Conversation 61,884 50.4% - Free 39,571 32.2% - ───────────────────────────────── - Total 122,700 100% -``` - -Both surfaces (desktop chat + TUI right pane). - -#### 2.3 Hooks expansion - -Beyond the existing PreCompact, register: - -| Hook | What it does in ADE | -|---|---| -| `PreCompact` (existing) | inject `DEFAULT_FLUSH_PROMPT` for memory save before compaction | -| `SubagentStart` | open ChatSubagentsPanel if collapsed; create snapshot row keyed by `agent_id` | -| `SubagentStop` | mark snapshot complete; render summary chip | -| `PostToolUse` | for tool outputs > 200KB, trim with a summarizer; surface `updatedToolOutput` | -| `PostToolUseFailure` | log structured error to ADE diagnostics; emit canonical `tool.failed` event | -| `Notification` | route to ADE's existing green/yellow status indicators (desktop chat list + TUI chat list) | -| `Stop` | end-of-turn metrics: turn duration, model usage, tool count | -| `TeammateIdle` (no-op) | wire so it doesn't crash if a user enables agent teams; UI tab structurally ready | -| `TaskCompleted` (no-op) | same | - -Memory and identity context **stay in systemPrompt appends**. We are not moving them to `UserPromptSubmit` / `SessionStart` in this pass. - -Reference: [Hooks guide](https://code.claude.com/docs/en/agent-sdk/hooks). - -#### 2.4 Session library - -Power the chat sidebar's session list from SDK functions instead of ADE's bespoke transcript table: - -- `listSessions({ dir: laneWorktree })` for lane-scoped listing. -- `getSessionInfo(sessionId)` for hover details. -- `getSessionMessages(sessionId, { limit, offset })` for transcript preview. -- `renameSession(sessionId, title)` and `tagSession(sessionId, tag)` for user edits. -- Maintain the ADE pointer table (`claude_sessions`) for lane association and tag history. - -References: [CHANGELOG 0.2.53, 0.2.59, 0.2.74, 0.2.75](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md). - -#### 2.5 Subagent UI re-plumb (Claude runtime only) - -The biggest visual change. See §7 (UI mockups) for the desktop panel design and §8 (TUI mockups) for the TUI side. - -In `chatExecutionSummary.ts`: -- Add second snapshot derivation keyed by `agent_id` (alongside today's `taskId`). -- New event subscribers: `SubagentStart` / `SubagentStop` hooks. -- Group nested messages by `parent_tool_use_id` for the timeline view. -- Add `agent_id` and `parent_tool_use_id` to the existing `ChatSubagentSnapshot` type. -- Add `final_summary` field populated on SubagentStop. - -In `ChatSubagentsPanel.tsx`: -- Add three tabs: **Subagents (this chat)** | **Teammates (this team)** | **Background sessions**. -- The Teammates tab is structurally present but empty unless `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is on. -- Background sessions tab lists results of `listSessions()` for the lane. -- Each row shows: status dot · name · type chip (`subagent` / `bg` / `teammate`) · runtime summary (tokens · duration) · status pill. -- Click row → detail view (existing) plus live-streamed text deltas inline when `forwardSubagentText` is on. -- Completed rows show a **final summary chip** (e.g., "All 412 tests passed", "Found 47 TODOs"). - -Show the panel only when the active runtime is Claude. Other runtimes' chat lifecycle continues to use their existing panel (or no panel). - -#### 2.6 `rewindFiles` "Undo from here" affordance - -On every past user message in the chat transcript: -- Render a small rewind-icon button that **appears on hover** of the message. -- On hover of the icon itself, an explanatory tooltip appears regardless of the user's tooltip-disable setting (this is too discoverable-critical to suppress). -- Tooltip text: *"Undo the file changes the agent made after this message. Conversation stays intact."* -- On click, open a confirmation dialog showing: - - The user message timestamp - - A `git diff --stat`-style list of files that will be reverted - - Per-file expandable diff preview (read-only) - - **Cancel** | **Revert files** -- On confirm, call `q.rewindFiles(userMessageId)` (no `dryRun`). -- Show a non-blocking toast on success: "Files restored to before [message preview]". - -Reference: [`Query.rewindFiles`](https://code.claude.com/docs/en/agent-sdk/typescript) (returns `RewindFilesResult`; supports `{ dryRun: true }` for preview). - -#### 2.7 `forwardSubagentText` default on - -Pass `forwardSubagentText: true` in options. Per-agent opt-out via `AgentDefinition` for headless specialists. Streamed text deltas render inline in the subagent's panel row. - -Reference: [CHANGELOG 0.2.119](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md). - -#### 2.8 Output styles - -Wire `/output-style` slash command. Read from: -- `~/.claude/output-styles/*.md` (user) -- `.claude/output-styles/*.md` (project) -- Plugin `output-styles/` directories - -Expose Claude Code's built-in styles via `applyFlagSettings({ outputStyle: 'Default' | 'Proactive' | 'Explanatory' | 'Learning' })`. Selection persists to `.claude/settings.local.json`. - -Reference: [Output styles](https://code.claude.com/docs/en/output-styles). - -#### 2.9 ADE CLI guidance - -ADE built-ins stay on the ADE CLI control plane. Claude SDK sessions receive the normal ADE CLI prompt guidance and environment instead of provider-side tool-server configuration. - -### Phase 3 — UI refresh (4–6 days, parallelizable across components) - -#### 3.1 Permission modal refresh - -Targets: the approval dialog rendered by `canUseTool` and the permission-mode picker. - -- Add **Auto** row to the picker, between **Accept Edits** and **Bypass Permissions**. -- Copy: "**Auto** — Claude judges each tool call. Uses a model classifier instead of asking you." -- Visual polish: typography, spacing, icon set. Match design language of the rest of ADE. -- "Allow for Session" affordance stays. -- "Memory orientation guard" warning stays. - -#### 3.2 Handoff split — two buttons - -Replace the single Handoff button with two clearly-labelled buttons (no clutter): - -- **Fork (full history)** — Claude → Claude only. Uses `query({ resume: sourceSessionId, forkSession: true })`. Source session untouched. New session inherits the entire conversation. Tooltip: "Create a new Claude chat that continues from this point. The original chat stays intact." -- **Handoff (brief)** — for cross-runtime handoffs (Claude → Codex, etc.) and as a manual choice for users who don't want full-context fork. Uses today's 12-message brief flow. Tooltip: "Send a 12-message summary to another model or runtime." - -The Fork button is only visible when source and target are both Claude runtimes. Cross-runtime defaults to Handoff. - -Code paths to touch: -- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx:5335-5504` — button group -- `apps/desktop/src/main/services/chat/agentChatService.ts:12612-12711` — backend; add `mode: 'fork' | 'brief'` to the IPC params -- `apps/desktop/src/shared/ipc.ts:182` — IPC channel signature - -#### 3.3 ChatSubagentsPanel redesign - -See §7 for ASCII mockup. Three-tab layout, agent_id-keyed snapshots, final-summary chips on completion, streamed text deltas inline. - -#### 3.4 rewindFiles UI - -See §2.6. - -#### 3.5 Availability detection split UI - -- Settings → Integrations → Claude row reflects both `binary.present` and `auth.ready` distinctly. -- Status text: "Bundled · awaiting auth" / "Bundled · authenticated" / "Binary missing (run /doctor)". -- Caller sweep: every site that read `availability.claude` as boolean now reads `availability.claude.auth.ready`. - -#### 3.6 `/context` panel - -See §2.2 mockup. Inline in chat or in right pane (desktop); right pane (TUI). - -### Phase 4 — TUI parity push (6–10 days, biggest scope) - -`ade code` becomes a Claude Code peer for keyboard-heavy users. **Claude-only** features (subagent panel, `/agents`, `/skills`, `/context`, hooks-driven indicators) light up only in Claude lanes/sessions. - -#### 4.1 Keybindings (`~/.claude/keybindings.json`) - -Verbatim adoption of the [keybindings schema](https://code.claude.com/docs/en/keybindings). Support every documented context (`Chat`, `Autocomplete`, `Confirmation`, `Tabs`, `Help`, `Transcript`, `HistorySearch`, `Task`, `ThemePicker`, `Attachments`, `Footer`, `MessageSelector`, `DiffDialog`, `ModelPicker`, `Select`, `Plugin`, `Scroll`, `Doctor`). Support every action namespace (`chat:*`, `app:*`, `history:*`, `scroll:*`, `selection:*`, etc.). Chord syntax. Reserved-key warnings. - -Implementation: -- New module `apps/ade-cli/src/tuiClient/keybindings/` with parser, validator, dispatcher. -- Hot-reload on file change. -- `/keybindings` slash command opens `~/.claude/keybindings.json` with the system editor. -- `/doctor` surfaces keybinding warnings. - -#### 4.2 Vim mode - -Toggle via `/config` → Editor mode (we noted no `/config` slash command; this lives in ADE's `/settings` instead, surfaced as **Settings → AI Features → Editor mode**). When on: -- INSERT / NORMAL modes in the chat input. -- `j` / `k` in NORMAL navigate history (and select footer pill at input boundary). -- `Space` in NORMAL moves cursor right. -- Standard vi motions, operators, NFD-safe handling. - -#### 4.3 Status line - -Verbatim contract: a shell script reading JSON on stdin, output rendered as the bottom status line. JSON schema mirrors [Claude Code's status line](https://code.claude.com/docs/en/statusline): -- `model` — { id, displayName, supportsEffort, fastMode } -- `workspace` — { cwd, gitBranch, gitWorktree } -- `context_window` — { used, total, percentage } -- `rate_limits` — { fiveHourUsed, fiveHourTotal, sevenDayUsed, sevenDayTotal, resetsAt } -- `session_id`, `session_name`, `cwd`, `lane` (ADE addition), `permission_mode` (ADE addition) -- `refreshInterval` (seconds) - -User configures via `~/.claude/settings.json` → `statusLine` setting key. Multi-line output supported. Examples shipped under `docs/examples/statuslines/`. - -#### 4.4 Plugins - -Read `~/.claude/plugins/` (so anything installed via `claude /plugin install …` works in `ade code`). Internal ADE features (ade-linear, ade-cto, ade-memory) restructure as plugins using the same `.claude-plugin/plugin.json` manifest. Plugin sources (skills, commands, agents, hooks, output styles) all flow through ADE's pipelines. - -Implementation: -- `apps/desktop/src/main/services/plugins/pluginRegistry.ts` — scan + load. -- Pass `plugins: [{ type: "local", path }]` to `query()`. -- `q.reloadPlugins()` on filesystem change. -- `/plugin` slash command for list / enable / disable / install instructions. - -Reference: [Plugins guide](https://code.claude.com/docs/en/agent-sdk/plugins). - -#### 4.5 Clipboard image paste (all runtimes) - -Keystroke handler reads clipboard via: -- macOS: `pbpaste -Prefer image` -- Linux/X11: `xclip -selection clipboard -t image/png -o` -- Linux/Wayland: `wl-paste -t image/png` -- Windows: PowerShell `Get-Clipboard -Format Image | …` - -Image becomes a content block via the existing `buildClaudeV2Message.ts` multimodal pipeline. Works for all runtimes (input-layer feature, runtime-agnostic). - -#### 4.6 Right pane integration for subagent view (Claude only) - -When a Claude chat is active and the model spawns a subagent: -- If the right pane is closed, **auto-open it** to the Subagents view. -- The right pane has a view-switcher button list (existing pattern with pane-open buttons). Add a "Subagents" entry. Users can arrow down to and click it to swap views. -- The right pane Subagents view renders the same data as the desktop's three-tab panel, in TUI layout: rows with status dot, name, type chip, runtime summary, status pill. -- Live text deltas wrap inline under the row, capped at 3 lines visible. -- Tab key cycles among Subagents / Teammates (no-op) / Background sessions tabs within the pane. - -#### 4.7 Slash command set in TUI - -**Keep all existing** ADE TUI slash commands (don't break compat): `/clear`, `/commit`, `/push`, `/pull`, `/stage all`, `/help`, `/model`, `/effort`, `/new chat`, `/new lane`, `/resume`, `/linear …`, `/remember`, `/forget`, `/diff`, `/log`, etc. - -**Add Core:** -- `/agents` — list view of installed agents (project + user + plugin sources). No Running tab — Running lives in the right-pane Subagents view. -- `/skills` — list view of installed skills with type-to-filter search; Enter pre-fills `/`; sort by token count toggle. -- `/memory` — open the canonical memory file (ADE memory: opens memory manager; Claude: opens CLAUDE.md). Dual-write: edits flow to both. -- `/context` — context-usage breakdown panel (§2.2). -- `/compact` — manual compaction trigger. -- `/init` — generate AGENTS.md (canonical) + CLAUDE.md (thin pointer `@include AGENTS.md`). - -**Add Operational:** -- `/usage` — 5-hour and 7-day rate limit usage. -- `/insights` — session analytics. -- `/fast` — fast mode toggle for Opus 4.6 (gated on `supportsFastMode`). -- `/goal` — set a completion condition; live elapsed/turns/tokens overlay (Claude 2.1.139). -- `/rename ` — calls `renameSession()`. -- `/tag <tag>` — calls `tagSession()`. - -**Do not add:** `/config` (settings page covers this), `/branch` / `/teleport` / `/scheduled` (out of scope), `/release-notes` / `/scroll-speed` (low ROI). - -#### 4.8 History search (Ctrl+R) - -Cross-session history search. `Ctrl+S` cycles scope (session → project → everywhere). Backed by `listSessions()` + an in-memory prompt index. - -#### 4.9 External editor handoff (Ctrl+G) - -Open the current prompt in `$EDITOR` (default vi). Save returns to TUI. - -### Phase 5 — Cleanups (1–2 days) - -- Delete `unstable_v2_*` import sites (Phase 1 already does this; double-check after merge). -- Delete `CLAUDE_EFFORT_TO_TOKENS` constants and helpers. -- Delete `pathToClaudeCodeExecutable` resolution code; reduce `packagedRuntimeSmoke.ts` to the new `query()` probe. -- Sweep `availability.claude` boolean usage sites; convert to `availability.claude.auth.ready` or `availability.claude.binary.present`. -- Drop ADE's session-title-derivation code (SDK auto-generates). -- Delete dead ADE-side subagent event types if everything is on the canonical vocabulary by end of Phase 4. -- Remove old chat history reader code paths. - ---- - -## 6. Locked decisions (reference) - -This is the single source of truth from Q&A. If something below conflicts with the prose above, **the locked decision wins** — please flag for an explicit override discussion. - -### SDK & migration -1. Bump `@anthropic-ai/claude-agent-sdk` `0.2.119` → `0.2.139` in `apps/desktop` and `apps/ade-cli`. -2. Rewrite `agentChatService.ts` to use `query()` + async-iterable prompt + `streamInput`. Resume via `resume: sessionId`. -3. **Hard cutover in a single PR.** No feature flag. -4. Replace `'Skill'` in `allowedTools` with `skills: 'all'`. -5. Drop `pathToClaudeCodeExecutable`; trust the SDK's bundled binary. -6. Pass `effort` directly. Drop the manual token-budget mapping. Keep `xhigh` as a value. -7. Convert `packagedRuntimeSmoke.ts` to a `query()` health probe. - -### Storage & sessions -8. Transcript storage: SDK default `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`. -9. ADE pointer table: `claude_sessions(session_id, lane_id, title, tags, created_at)`. -10. Cross-tool resume compatibility: yes. -11. Session titles: SDK auto-generates; ADE captures via system:init. Users rename via `/rename` (calls `renameSession()`). -12. Old chat history: drop. - -### Hooks -13. Keep PreCompact. -14. Add SubagentStart, SubagentStop, PostToolUse (with `updatedToolOutput` for large output trimming), PostToolUseFailure, Notification, Stop. -15. **Do not** move memory / identity / skill-discovery out of systemPrompt. Keep current append architecture. -16. Wire TeammateIdle / TaskCompleted as no-ops. - -### Subagents (Claude runtime only) -17. Desktop UI: three-tab unified panel — **Subagents | Teammates | Background sessions**. -18. TUI UI: right-pane integration; auto-open on subagent spawn; navigable view-switcher button. -19. Re-key on `agent_id` + `parent_tool_use_id`. -20. `forwardSubagentText: true` by default; per-agent opt-out. -21. Final summary chip on completion. -22. Subagent infra only — **no ADE-shipped named subagent definitions**. -23. Agent teams: skip `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`; hooks wired as no-ops; UI tab structurally ready. - -### Handoff -24. Split into two buttons: - - **Fork (full history)** — Claude→Claude, uses `forkSession: true` + `resume: sourceSessionId`. - - **Handoff (brief)** — today's 12-message brief flow; default for cross-runtime. - -### Permissions -25. Keep ADE approval dialog. -26. Add `auto` mode to the picker; ensure default/plan/acceptEdits/bypassPermissions all work. -27. Update permission modal UI (auto row + polish). -28. **Skip sandbox** this pass. - -### Config & discovery -29. `~/.claude/` and `.claude/` for Claude-compat content (skills, commands, agents, output-styles, statusline, keybindings, plugins). -30. `~/.ade/` and `.ade/` for ADE-only state (lanes, identities, memory DB). -31. Skills: read both `.ade/skills/` and `.claude/skills/` (multi-runtime support). -32. `settingSources` stays `["user", "project", "local"]`. -33. ADE CLI guidance stays the integration point for ADE-owned tools. - -### TUI parity -34. Keybindings: verbatim `~/.claude/keybindings.json` schema; vim mode via Settings. -35. Status line: verbatim Claude Code shell-script + JSON contract. -36. Plugins: read `~/.claude/plugins/`; internal ADE plugins use Claude plugin manifest format. -37. Image paste from clipboard in TUI: all runtimes. -38. No new CLI flags. -39. No `/config` slash command. - -### Slash commands -40. Keep all existing ADE slash commands. -41. Add Core: `/agents`, `/skills`, `/memory`, `/context`, `/compact`, `/init`. -42. Add Operational: `/usage`, `/insights`, `/fast`, `/goal`, `/rename`, `/tag`. -43. Skip: `/config`, `/branch`, `/teleport`, `/scheduled`, `/release-notes`, `/scroll-speed`. -44. `/agents` and `/skills` are list-only — no Running tab (Running lives in ChatSubagentsPanel). -45. Fix finicky slash command reliability bug as part of Phase 1 rewrite. - -### Memory -46. Dual-write to ADE memory + Claude `/memory` + CLAUDE.md. - -### Performance -47. `startup()` warm when (lane selected) AND (Claude model selected); cache per `(lane, model)`; refresh on either change. -48. `getContextUsage()` powers `/context` in both surfaces. - -### Files -49. `rewindFiles` UI: hover-revealed icon on past user messages; always-on tooltip on icon hover; click opens confirmation dialog enumerating files + per-file diff preview; confirm calls `q.rewindFiles(userMessageId)`. - -### Availability detection -50. Split `availability.claude` → `availability.claude.binary` + `availability.claude.auth`. - -### `/init` -51. Generate both AGENTS.md (canonical, multi-runtime) and CLAUDE.md (thin pointer: `@include AGENTS.md`). - -### Channels, lanes, scope -52. Skip channels this pass. -53. Don't replicate `claude agents` machine-wide view in ADE; ADE sessions auto-appear in `claude agents`. -54. Lanes stay runtime-agnostic worktree containers. -55. All subagent / `/agents` / `/skills` / new hooks / new UI work is **Claude runtime only** this pass. - -### Plan mode -56. Keep ADE's existing ExitPlanMode UI; absorb the new SDK `planFilePath` field. - -### Notifications -57. Wire to existing ADE green/yellow status indicators (desktop chat list + TUI chat list). - -### Output styles -58. Expose Default, Proactive, Explanatory, Learning. Read user-custom from `.claude/output-styles/` and plugin output-styles. Wire `/output-style`. - -### Architecture lessons from t3code -59. Add an explicit `ClaudeSubprocessReaper` (modeled on `ProviderSessionReaper`). -60. Formalize the canonical multi-runtime event vocabulary (§4.6) so non-Claude adapters plug in cleanly. - ---- - -## 7. Desktop chat UI mockups - -### 7.1 Three-tab subagent panel (chosen design) - -``` -┌─ Agents ───── 3 active · 1 bg · 2 done ─┐ -│ [Subagents] Teammates Background │ -├───────────────────────────────────────────┤ -│ ● research-explorer subagent 2.3k·14s │ -│ scan repo for TODO patterns │ -│ ▎ Found 47 TODOs in 12 files... ▼ │ -│ │ -│ ● code-reviewer [bg] 6.1k·1m12s │ -│ review PR #281 for security │ -│ │ -│ ✓ test-runner done 12.4k·4m20s │ -│ ┊ Summary chip: All 412 tests passed │ -└───────────────────────────────────────────┘ -``` - -Notes: -- Header: panel label, summary chip (active · bg · done · stopped · failed). -- Tab row: active tab highlighted; tabs are **Subagents** / **Teammates** / **Background**. -- Each row: status dot · agent name · type chip · runtime summary (tokens · duration) · status pill. -- Streamed text deltas appear inline (▎ prefix) when `forwardSubagentText: true`. -- Final-summary chip on completion (┊ prefix). -- Background subagents get a `[bg]` chip. -- Click row → detail timeline (existing pattern preserved). - -### 7.2 Permission modal (refresh) - -``` -┌─ Permission required ─────────────────────────┐ -│ │ -│ Tool: Bash │ -│ Command: rm -rf ./node_modules │ -│ │ -│ Mode: │ -│ ○ Default ask each time │ -│ ○ Plan read-only tools only │ -│ ○ Accept Edits auto-approve file ops │ -│ ● Auto model decides (NEW) │ -│ ○ Bypass no prompts (dangerous) │ -│ │ -│ Memory check: ✓ relevant context found │ -│ │ -│ [ Deny ] [ Allow ] [ Allow for Session ] │ -└────────────────────────────────────────────────┘ -``` - -### 7.3 Handoff button — split - -``` -┌─ Chat header ────────────────────────────────────────────┐ -│ Lane: ade-31 · Claude Opus 4.7 · effort: high │ -│ [Fork] [Handoff] │ -└──────────────────────────────────────────────────────────┘ - ▲ ▲ - │ │ - Tooltip: │ │ Tooltip: - "New Claude │ │ "Send 12-msg - chat with │ │ summary to - full history"│ │ another model" - (Claude→Claude│ │ (any direction) - only) │ │ -``` - -### 7.4 `/context` panel - -``` -┌─ Context usage ─── 122,700 / 200,000 tokens · 61% ───┐ -│ │ -│ System prompt 4,287 3.5% ▏ │ -│ Tools 8,113 6.6% ▎ │ -│ AGENTS.md / CLAUDE.md 842 0.7% ▏ │ -│ Skills (loaded) 2,401 2.0% ▎ │ -│ Conversation 61,884 50.4% ████████▌ │ -│ Free 39,571 32.2% █████▎ │ -│ ───────────────────────────────────────────── │ -│ Total 122,700 100% │ -│ │ -│ [ Compact now ] [ View transcript ] │ -└───────────────────────────────────────────────────────┘ -``` - -### 7.5 `rewindFiles` confirmation dialog - -``` -┌─ Undo file changes ──────────────────────────────────────┐ -│ │ -│ Revert to before: │ -│ "Refactor the auth module to use JWT instead of..." │ -│ sent 14 minutes ago │ -│ │ -│ Files that will be restored: │ -│ ▶ src/auth/jwt.ts +84 / -0 (new file) │ -│ ▶ src/auth/index.ts +12 / -34 │ -│ ▶ src/middleware/auth.ts +5 / -18 │ -│ ▶ tests/auth/jwt.test.ts +120 / -0 (new file) │ -│ │ -│ Conversation history is not affected. │ -│ │ -│ [ Cancel ] [ Revert 4 files ] │ -└───────────────────────────────────────────────────────────┘ -``` - ---- - -## 8. TUI mockups - -### 8.1 Right pane with subagents view (chosen design) - -``` -┌─ ade code · lane: ade-31 ─ Claude · Opus 4.7 ─────────────────┐ -│ │ -│ user: review the codebase for security issues │ -│ │ -│ ◌ Claude is using agents... │ -│ │ -└───────────────────────────────────────────┬────────────────────┘ - │ -┌─ Right pane ──── Subagents ── Teammates ──┤ Background ────────┐ -│ │ │ -│ ▶ ● research-explorer subagent 14s │ (no team active) │ -│ scan repo for TODO patterns │ │ -│ ▎ Found 47 TODOs in 12 files... │ no background │ -│ │ sessions │ -│ ▶ ● code-reviewer [bg] subagent 1m12s │ │ -│ review PR #281 │ │ -│ │ │ -│ ▶ ● feature-builder teammate 2m30s │ │ -│ implementing /context command │ │ -│ │ │ -│ ▶ ✓ test-runner subagent done │ │ -│ ┊ All 412 tests passed │ │ -└────────────────────────────────────────────┴────────────────────┘ -─[F1 help][Ctrl+J toggle pane][Tab cycle view][Esc cancel]──────── -``` - -Behavior: -- Right pane auto-opens to Subagents view when a subagent spawns AND pane is closed. -- View-switcher button in the pane chrome (arrow-navigable like the existing left-pane and right-pane toggle buttons) returns to Subagents view from other right-pane views. -- Tab key cycles tabs *within* the pane (Subagents → Teammates → Background → Subagents). -- Each row: status dot · name · type chip · runtime · duration. -- Live text deltas inline (▎ prefix), wrapped to row width, capped at 3 lines. -- Final-summary chip (┊ prefix) on completion. - -### 8.2 Status line (verbatim Claude Code contract) - -Single-line example: - -``` -opus-4.7 · main * ade-31 · 61% ctx · 4.20$ · ade-31 lane · auto -``` - -Multi-line example: - -``` -opus-4.7 · main * ade-31 · 4h 32m left in window -[████████▌ ] 61% context · $4.20 spent · auto mode -``` - -The script receives JSON on stdin, prints to stdout. ADE additions to the JSON: `lane`, `permission_mode`. - -### 8.3 `/agents` list view (TUI) - -``` -┌─ /agents · 7 installed ──────────────────────────── type-to-filter ─┐ -│ │ -│ code-reviewer project Expert code review for quality & sec │ -│ test-runner project Run tests and analyze failures │ -│ research-explorer user Broad codebase exploration │ -│ doc-writer user Write API documentation │ -│ linear-triage plugin Triage Linear issues (plugin: ade-…) │ -│ pr-summarizer plugin Summarize PRs (plugin: ade-…) │ -│ security-auditor project Static security review │ -│ │ -│ [Enter] pre-fill /<name> · [/] search · [Esc] close │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -### 8.4 `/skills` list view (TUI) - -``` -┌─ /skills · 14 installed ───── sort: name · [t] toggle token sort ───┐ -│ │ -│ ade-31-context project ~840 tok load ADE-31 context │ -│ shipLane user ~1.2k tok autonomously ship a PR │ -│ audit user ~2.1k tok audit recent work │ -│ release user ~3.0k tok cut an ADE release │ -│ finalize user ~1.8k tok final pre-ship gate │ -│ simplify user ~1.5k tok simplify changed code │ -│ review user ~1.1k tok review a PR │ -│ security-review user ~2.4k tok security review changes │ -│ hyperframes plugin ~6.2k tok HyperFrames composition │ -│ … │ -│ │ -│ [Enter] pre-fill /<name> · [/] search · [t] sort by tokens │ -└──────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 9. Parallelization with agents (suggestions) - -This rewrite is large enough to benefit from parallel agent work. Independent slices that can run concurrently after Phase 1 ships: - -### After Phase 1 lands - -**Wave 1 — pure backend SDK adoptions (parallelizable, low UI coupling)** -1. `startup()` pre-warm + cache (Phase 2.1) -2. `getContextUsage()` + emit canonical context-usage event (Phase 2.2) -3. Hooks wiring: SubagentStart/Stop, PostToolUse trim, PostToolUseFailure, Notification, Stop (Phase 2.3) -4. Session library wiring: `listSessions`, `renameSession`, `tagSession`, `getSessionMessages` + ADE pointer table (Phase 2.4) -5. ADE CLI guidance (Phase 2.9) -6. ClaudeSubprocessReaper (§4.2) - -These six tickets touch different modules in the backend and have minimal UI overlap. - -**Wave 2 — UI work (parallelizable across components)** -1. Permission modal refresh + auto mode row (Phase 3.1) -2. Handoff split (Phase 3.2) — backend + frontend touches but isolated to handoff path -3. ChatSubagentsPanel three-tab redesign (Phase 3.3 + Phase 2.5 subagent re-plumb) — single agent owns both since they're tightly coupled -4. rewindFiles UI + backend exposure (Phase 3.4 / Phase 2.6) -5. `availability.claude` split + caller sweep (Phase 3.5) -6. `/context` panel (Phase 3.6) - -**Wave 3 — TUI parity (parallelizable across modules)** -1. Keybindings + vim mode (Phase 4.1 / 4.2) -2. Status line (Phase 4.3) -3. Plugins discovery + `q.reloadPlugins()` (Phase 4.4) -4. Clipboard image paste cross-runtime (Phase 4.5) -5. Right-pane subagent integration (Phase 4.6) -6. Slash command set additions (Phase 4.7) -7. Ctrl+R history search (Phase 4.8) -8. External editor handoff (Phase 4.9) - -### Suggested agent assignment - -- **Agent A — Migration captain (Phase 1).** Single-threaded; this is the highest-risk piece. Cannot parallelize. -- **Agents B/C — Backend wave (Phase 2.1, 2.4 and Phase 2.2, 2.9, reaper).** Two agents, three tickets each. -- **Agent D — Hooks wave (Phase 2.3).** Owns all hook registrations + canonical-event translator. -- **Agents E/F — UI wave (Phase 3).** One owns permission modal + handoff + availability split. The other owns subagent panel + rewindFiles + `/context`. -- **Agents G/H — TUI wave (Phase 4).** One owns keybindings + vim + status line. The other owns plugins + clipboard + right-pane + slash commands. -- **Agent I — Cleanups (Phase 5).** Sweeps last-call removals; pairs with each PR landing. - -### Coordination rules - -- Phase 1 merges *first*, full stop. Every other agent waits for that merge. -- Agents in the same wave coordinate via the shared canonical event vocabulary (§4.6) — define it as a TypeScript interface in `apps/desktop/src/main/services/chat/runtimeEvents.ts` before Phase 2 starts. -- Daily 15-min sync (or async via Linear comments) on: - - Type-shape changes that touch multiple agents. - - SDK-version surprises (changelog drift). - - Subagent / agent-team scoping (Claude-only constraint). - -### What *not* to parallelize - -- Phase 1 itself — keep it single-threaded. -- The subagent re-plumb (Phase 2.5) + ChatSubagentsPanel redesign (Phase 3.3) — same agent owns both because the type changes ripple. -- Any work touching `agentChatService.ts` should be sequenced through one captain to avoid merge conflicts on the largest file in the repo. - ---- - -## 10. Testing strategy - -### 10.1 Per-phase smoke checks - -- **Phase 0:** `bun run typecheck` + sharded test suite green. Manual desktop chat smoke (one message, one tool call, exit). -- **Phase 1:** end-to-end chat flow on the new pipeline. Specific tests: - - send / receive / streaming (assistant + partial messages) - - tool approval through `canUseTool` (Allow / Deny / Allow for Session) - - resume by session_id from sidebar - - permission mode switch mid-turn - - compact_boundary + identity continuity preserved - - PreCompact hook fires DEFAULT_FLUSH_PROMPT - - slash command reliability — `/clear`, `/commit`, `/push`, `/linear list`, mid-sentence `/help` - - subprocess teardown on tab close -- **Phase 2:** - - `startup()` warm — first-chat latency < 500ms when warmed - - `getContextUsage()` numbers reasonable - - SubagentStart/Stop hooks fire; panel reflects state - - PostToolUse trim — supply a 5MB tool output; verify trimmed to <200KB before reaching model context - - Notification hook triggers green/yellow indicator - - `listSessions()` round-trip with rename / tag -- **Phase 3:** - - Permission modal renders all 5 modes; auto row works - - Handoff Fork — confirm full transcript inheritance - - Handoff Brief — confirm 12-msg brief works - - rewindFiles — dryRun shows diffs; confirm reverts files -- **Phase 4:** - - Keybindings — every documented action namespace mapped - - Vim mode — INSERT/NORMAL transitions; j/k history; Space-right - - Status line — JSON contract round-trips; refreshInterval honored - - Plugins — install a fixture plugin, see its commands/agents/skills appear - - Clipboard image paste — macOS + Linux + Windows - - Right pane auto-open on subagent spawn - -### 10.2 Cross-tool resume test - -After Phase 1+2: -1. Start a chat in ADE desktop in lane `test-lane`. -2. Send some messages, edit a file. -3. Close ADE. -4. From terminal: `cd .ade/worktrees/test-lane && claude --resume`. -5. Expect: the session appears in the picker; resuming it yields the same conversation. - -### 10.3 Test scoping - -Per CLAUDE.md / project memory: **always shard test runs; suite is too large for single-process execution.** After focused changes, run only related test files — never the full suite unless asked. - -Targeted test files for this initiative: -- `apps/desktop/src/main/services/chat/agentChatService.test.ts` -- `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx` -- `apps/desktop/src/renderer/components/chat/AgentChatPane.handoff.test.tsx` -- `apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx` -- `apps/ade-cli/src/tuiClient/*.test.tsx` -- New: `apps/desktop/src/main/services/chat/claudeInputPump.test.ts` -- New: `apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts` - ---- - -## 11. Risk & rollback - -### 11.1 Risks - -- **Phase 1 is a hard cutover.** If a regression slips, the only path back is `git revert`. Mitigation: heavy local smoke before opening the PR; require a second engineer's review specifically of the input-pump and stream loop. -- **Cross-tool resume edge cases.** If session JSONL files end up under unexpected `cwd` encodings, `claude --resume` won't find them. Test on path with spaces, unicode, deep nesting. -- **Subprocess orphans on Electron crash.** The reaper handles this only if it had a chance to register PIDs. Add a scan-on-startup for orphan PIDs matching the claude-code binary path. -- **Project setting surprises.** A user may have stale Claude project settings in their lane. Surface parse or compatibility errors through `/doctor`. -- **Bundled binary version skew.** The bundled binary version is pinned to the SDK release (`claudeCodeVersion` in SDK package.json since 0.2.6). If a user's project has a `.claude/` config that requires a newer Claude Code feature, surface this in `/doctor`. -- **Subagent UI re-key (`taskId` → `agent_id`).** During the rewrite, old events may coexist with new ones. Solution: dual-key snapshots (both fields populated) for one release. -- **Handoff Fork inheriting too much.** A 200-message session forked yields a 200-message new session that costs full context on first turn. Mitigation: warn in the Fork tooltip; add a "Fork from message…" variant later if usage shows this matters. - -### 11.2 Rollback - -- **Phase 0:** revert the package.json bump. -- **Phase 1:** `git revert` the migration PR. Old `unstable_v2_*` paths are gone, so this is a real revert (not flag flip). Plan for a known-good tag before merging. -- **Phase 2+:** each subticket is independently revertable. - -### 11.3 Telemetry - -- Phase 0 ships a tag in OTEL spans: `claude_sdk.version=0.2.139`. -- Phase 1 ships a tag: `claude_sdk.api=v1_query` vs old `v2_session`. -- Track first-turn latency, time-to-first-token, time-to-result, tool-call counts. Compare before/after. - ---- - -## 12. Open implementation questions - -These don't block the plan; they get resolved during implementation review: - -1. **Pump backpressure.** If the user sends three messages while the model is mid-turn, do we queue or reject? Today `unstable_v2_session.send` queues. We should match. Confirm shape of `Query.streamInput` re: queueing. -2. **Where do we render compact boundary events?** Existing UI inserts a divider; verify with the new system message type. -3. **`session_state_changed` events.** Opt-in via `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` since 0.2.83. Useful for telemetry; do we enable? -4. **`title` option for the *very first* chat in a fresh lane.** Should ADE prepend something like `[ade-31] …` to make sessions identifiable in `claude --resume`? Or let SDK auto-generate cleanly? -5. **Effort default for new chats.** Today ADE picks per-identity. SDK 0.2.49 added `supportsEffort` per-model. If a model doesn't support effort, hide the picker for that model. Plumb this. -6. **`agentProgressSummaries` cache miss issue (CHANGELOG 0.2.128).** Verify post-migration we don't regress on cache hits for subagent progress. -7. **`AskUserQuestion.previewFormat`.** Today ADE sets `markdown` for non-lightweight sessions (`agentChatService.ts:11461`). Keep that. -8. **`includeHookEvents` option (CHANGELOG 0.2.89).** For diagnostics, useful to emit hook lifecycle messages. Off by default; surface via `/doctor`. -9. **Settings hot-reload after Phase 4.** Plugin install / settings.json edit → does `q.reloadPlugins()` plus `q.applyFlagSettings()` cover all reactive cases? -10. **Plan mode + `planFilePath`.** Today's UI shows the plan inline. If `planFilePath` is set, do we open the file in the editor? Show side-by-side? -11. **`/memory` UI in desktop chat.** Opens a memory pane vs opening the settings Memory section? Spec needed. -12. **`/init` interactive flow.** Walk the user through generating AGENTS.md vs auto-generate from repo content vs both? Spec needed. - ---- - -## 13. Glossary - -- **Agent SDK** — `@anthropic-ai/claude-agent-sdk`. The npm package ADE depends on. -- **Claude Code** — Anthropic's terminal CLI / IDE plugin / web product built on top of the SDK. The bundled binary in `node_modules` is the Claude Code binary, shipped as an optional dep per platform. -- **Lane** — ADE's concept: a git worktree under `.ade/worktrees/<name>/` plus its own chat sessions. Runtime-agnostic. -- **Subagent (in-session)** — spawned via the `Agent` tool inside one Claude Code session. Shares the process. -- **Background agent** — a full separate Claude Code session running in parallel on the same machine. Listed in `claude agents`. -- **Agent team** — a Team Lead session orchestrating Teammate sessions via SendMessage + shared mailbox under `~/.claude/tasks/<team-name>/`. Experimental (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`). -- **Skill** — a Markdown-defined capability under `.claude/skills/<name>/SKILL.md`. Model autonomously invokes when description matches request. Also user-invocable via `/<skill-name>`. -- **Plugin** — a directory with `.claude-plugin/plugin.json` bundling skills / commands / agents / hooks. -- **Output style** — a Markdown file under `.claude/output-styles/` that modifies the system prompt for tone / format. -- **Handoff (ADE-specific)** — explicit user action that spawns a new chat from the current one. Two flavors: Fork (Claude→Claude, full history) and Brief (any direction, 12-message summary). - ---- - -## 14. Sign-off checklist (Codex: tick when done) - -- [ ] Phase 0: SDK bump merged. No regressions. -- [ ] Phase 1: `unstable_v2_*` call sites removed. New `query()` pipeline live. Cross-tool resume working. Slash command reliability fixed. -- [ ] Phase 2.1–2.9: backend SDK adoptions shipped. -- [ ] Phase 3.1–3.6: UI refresh shipped. -- [ ] Phase 4.1–4.9: TUI parity shipped. -- [ ] Phase 5: cleanups merged. -- [ ] Testing: phase smoke checks all green. Cross-tool resume test passes. -- [ ] Telemetry: new tags emitting; latency before/after captured. -- [ ] Docs: README + ARCHITECTURE.md updated. -- [ ] Memory: project memory updated to reflect new architecture. diff --git a/docs/transcript.txt b/docs/transcript.txt deleted file mode 100644 index 56d4800b0..000000000 --- a/docs/transcript.txt +++ /dev/null @@ -1,410 +0,0 @@ -[upbeat music] -Benjamin Poulain: Hi everyone, and welcome to our session about virtualization. -This is what we are going to do together today. -We'll see how you can run macOS and Linux inside virtual machines, -on Apple silicon. -By the end of this session, you will be able to do the same on your own Mac. -This may seem a little ambitious, but stick with us, -and we'll do it together. -Here is our agenda for today. -We will start with an overview of virtualization technologies, -and we'll see how to use Virtualization framework to build virtual machines. -Then we'll do a deep dive into macOS. -We'll see how we can set up a virtual Mac and install macOS into it. -And finally, we'll do a second deep dive, this time into Linux. -We'll see how to run full Linux distributions -and some cool new features. -Let’s get started with the overview. -We'll first look into the stack that enables virtualization. -It all starts with hardware. -Apple silicon has special hardware that enables the virtualization -of CPUs and memory. -This means you can run multiple operating systems on top of a single SoC. -Next, we need software to take advantage of this hardware. -And this is built right into the macOS kernel. -You no longer need to write kernel extensions, or KEXTs. -It's all built in. -To use those capabilities from your application, -you can use Hypervisor framework. -Hypervisor framework is a low-level API that lets you virtualize CPUs and memory. -But, because it's a low-level framework, -you need to write every single detail of the virtual environment. -Oftentimes, we want to run full operating systems. -For this, there is a higher-level API, which is Virtualization framework. -Virtualization framework enables the creation of virtual machines -running macOS on Apple silicon or Linux on both Apple silicon and Intel. -Today, our session will focus on Virtualization framework. -When using Virtualization framework, we'll deal with two kinds of objects. -The first kind are configuration objects. -They define all the properties of our virtual machines. -The second kind are virtual machine objects. -Those objects abstract virtual machines and how to interact with them. -We'll start with looking at the configuration. -The configuration defines the hardware. -Creating a configuration is like configuring a Mac on the Apple Store. -We define how many CPUs we want, how much memory, what kind of devices. -We can start from a simple configuration. -We can add a display, and we get to see the content. -We can add a keyboard, and we can type. -We can add a trackpad, and we can interact with the UI. -Configuring a virtual machine is just like that. -But since we are dealing with virtual machines, -we'll do this in code. -Let’s see how we can write the configuration in Swift. -Defining the hardware is very simple. -We start with an object of type VZVirtualMachineConfiguration. -This is the root object of all configurations. -Next, we define how many CPUs our machine should have. -Here we give four CPUs. -Then, we set how much memory we want. -In this case, we give four gigabytes of memory. -Finally, we define the devices our machine will have. -In this example, we set a single storage device, -the disk to boot from, and a pointing device, like a mouse. -There are many devices available. -The ones you set up depend on the problem you want to solve. -Now we've seen the configuration. -It starts with VZVirtualMachineConfiguration, -on which we add the CPUs, the memory, and the devices. -Next, we'll look into the virtual machine objects. -After we have configured our Mac, we get it by the mail. -It's time to unbox it and start it. -But since we are dealing with virtual machine, -we need to do that in code. -Let’s see how we can do it in Swift. -First, we'll create an instance of VZVirtualMachine -from our configuration. -A VZVirtualMachine abstracts an instance of the virtual hardware. -Now that we have the virtual machine, we can operate on it. -For example, in this case, we call start() to start it. -We'll often want to interact with our virtual machines. -For this, we have other objects to help us. -For example, if we want to show our virtual display, -we can use an object of type VZVirtualMachineView. -We start by creating a view. -Then we set our virtual machine as the virtualMachine property on the view, -and it's ready. -Now we can use this VZVirtualMachineView object like any NSView. -We can integrate it in our app to see the content of the virtual machine. -To wrap up, we've seen the configuration. -The configuration starts with VZVirtualMachineConfiguration, -from which we define the CPUs, memory, and our devices. -From the configuration, we will create a virtual machine, -and we will use virtual machine objects. -We've seen VZVirtualMachine to abstract the VM itself, -VZVirtualMachineView to display content, -and there are other objects that can help us use the VM. -We have seen that the configuration gives a lot of flexibility -in how we define virtual machines. -Unfortunately, there are too many features to cover in one session. -In this session, we will look into some of the core capabilities. -For everything else, we have documentation, -and I invite you to check it out. -In the overview, we just saw how to build virtual machines. -Now it is time to look into how we can run a full operating system in them. -And we will start with macOS. -Virtualization framework supports macOS on Apple silicon. -When we built Virtualization framework on Apple silicon, -we've developed macOS and Virtualization framework together. -What this gives us is incredible efficiency -when running macOS inside virtual machines. -Here is what we are going to see: -First, we will look into what we need -to turn a virtual machine into a virtual Mac. -Then we'll look into the steps to install macOS on our virtual Mac. -Next, we'll see some of the special devices we have for macOS. -And finally, we will look into a very important use case, -which is sharing files between the host system and the virtual Mac. -Let’s start with the configuration. -We have seen before how to build a generic virtual machine. -Now we want to add the special properties that will make a virtual machine a Mac. -So how do we make a virtual Mac? -First, we will define a special platform. -A platform is an object that holds all the properties -of a particular type of virtual machine. -There are three properties that are unique to the virtual Mac hardware. -First, we have the hardware model. -The hardware model specifies which version of the virtual Mac we want. -Second, there is the auxiliary storage. -The auxiliary storage is a form of non-volatile memory used by the system. -And third, there is the machine identifier. -The machine identifier is a unique number representing the machine, -just like a physical Mac has a unique serial number. -Once we have the platform, we have all the pieces to describe the hardware, -but we need one more piece, which is a way to boot macOS. -For this, we will use a special boot loader, -the macOS boot loader. -Let’s see how to do all of this in Swift. -We start from the same base as before. -This code is what we have seen in the overview. -Then we create a VZMacPlatformConfiguration. -This is our platform object for virtual Macs. -We need a hardware model for this Mac. -Here we use one we previously saved. -In virtual machines, the auxiliary storage is backed -by a file on the local filesystem. -Here, we initialize our auxiliary storage from a file URL. -For the unique identifier, we initialize a VZMacMachineIdentifier -from one we previously saved. -For a new install, we can also create a new identifier. -We have set all three properties. Our platform is ready. -All we have to do is set it on the configuration object. -This gives us the hardware. Next we need a way to boot it. -To do that, we set up the boot loader with VZMacBootLoader. -Now our machine is ready to boot. -What we have done so far is define the virtual Mac and how to start it. -But we still need to get software on it, -which brings us to the installation. -Installing macOS is done in three steps. -First, we need to download a restore image with the version -of macOS we want to install. -Then we need to create a configuration -that is compatible with that version of macOS. -And finally, we’ll install our restore image -in the compatible virtual machine. -So first, we need to download a restore image. -You can download restore images from the developer website, -but Virtualization can also help us. -You can call VZMacOSRestoreImage.latestSupported -to get a restore image object for the latest stable version of macOS. -This object has a URL property that we can use to download the file. -Then we want to create a virtual machine that is compatible -with the version of macOS we downloaded. -Virtualization can also help us here. -We can ask the restore image object for the configuration requirements. -If the restore image can be run on the current system, -we get an object listing the requirements. -From the requirements, we can obtain the hardware model needed -needed to run this version of macOS. -We have seen previously how to restore a hardware model. -This is how we obtain a new one. -The requirements also contain two useful properties. -The object can tell us how many CPUs and how much memory is required -to run this version of macOS. -Finally, we are ready to start installation. -We start by creating a new virtual machine from our configuration. -Then we create an installer. -The installer takes two arguments, -the compatible virtual machine we created -and the path to the restore image we downloaded. -Now we can just call install(), and voilà, we are ready to run macOS. -Now that we can set up a virtual Mac and install macOS, -let’s see some of the special devices for the Mac. -A first cool capability is GPU acceleration. -We have built a graphic device that exposes the GPU capabilities -to the virtual Mac. -This means you can run Metal in the virtual machine, -and get great graphics performance in macOS. -Let’s see how to set it up. -We start by creating the graphics device configuration. -Here, we will use the VZMacGraphicsDeviceConfiguration. -Next, we want to give it a display. -We set up the display by defining its size and pixel density. -Now our device configuration is ready. -As usual, we set it on the main configuration object. -We set it as the graphics device for our virtual machine. -Next, we have a new device for interacting with the Mac. -In macOS Ventura, we are adding the Mac trackpad support -to the virtual Mac. -With the new trackpad, it is possible to use gestures -like rotation, pinch to zoom, and so on. -This new device uses new drivers in macOS. -So to use it, you will need macOS 13 -both on the host system and in the virtual machine. -Let’s see how to set it up. -It’s very easy. -We create a new object of type VZMacTrackpadConfiguration. -Then we set it as the pointing device on the virtual machine. -Now when we’ll use the view with our virtual Mac, we can use gestures. -Finally, let’s look into a common use case for many of us, -sharing files between the host system and the virtual machine. -In macOS 12, we introduced the Virtio file-system device -to share files on Linux. -In macOS Ventura, we are adding support for macOS. -You can now pick folders that you want to share with the virtual machine. -Any change you make from the host system is instantly reflected -within the virtual machine and vice versa. -Let’s see how to set it up. -First, we create a VZShareDirectory with a directory we want to share. -Then we create a share object. -Here we'll use VZSingleDirectoryShare to share a single directory. -You can also share multiple directories by using VZMultipleDirectoryShare. -Now that we have the share, we need to create a device. -But we will start we something special. -File system devices are identified by a tag. -In macOS Ventura, we have added a special tag -to tell the virtual machine to automount this device. -Here, we take this special tag, macOSGuestAutomountTag. -Then we create the device and use our special tag. -We set the share from the single directory we configured. -And finally, we add the device to the configuration as usual. -Finally, let’s look at everything together in a demo. -We start from a basic configuration. -We have a VZVirtualMachineConfiguration -with just CPU, memory, a keyboard, and a disk. -We want a virtual Mac. -To do that, we need to start by setting up the platform. -We'll use createMacPlatform that is defined above to do that. -The second piece of a virtual Mac is the boot loader. -We need a boot loader that knows how to boot macOS. -To get that, we set the platform's boot loader -to VZMacOSBootLoader(). -Next, we want to set up the devices. -We want accelerated graphics. -To get it, we will set up a VZMacGraphicsConfiguration. -We create the object, -define the display size and pixel density, -and we add it to the configuration. -Next, we want to use the new trackpad. -All we need to do is set the pointing device -to VZMacTrackpadConfiguration. -That's it. -Now, we could start the VM, but let's add the cherry on top. -We have seen how we can share directories. -Let's do it here. -We start by creating the filesystem device configuration. -Here, notice we use the special tag to automount it into macOS. -Then we define our share. -Here we use a single directory share from a path on the file system. -Here, we will share this project we are editing right now. -We add the device to our configuration, and we are done. -Everything is ready. We launch our app. -Since we configured the Mac graphics device, -the VZVirtualMachineView can show the content. -This is what you see here in the window. -And here it is. We have configured macOS from scratch. -We can see the shared directory and the project we were editing right now. -Finally, we will turn our eyes onto Linux. -Virtualization framework has supported Linux -since the very beginning in macOS Big Sur. -In macOS Ventura, we have added some pretty cool new features, -and we want to share some of them with you. -First, we will see how we can install full Linux distributions, -completely unmodified, in virtual machines. -Then we will look at a new device we are adding to show UI from Linux. -And finally, we will look at how we can take advantage of Rosetta 2 -to run Linux binaries in virtual machines. -Let’s start with installation. -If we wanted to install Linux on a physical machine, -we'd start by downloading an ISO file with the installer. -Then we'd erase a flash drive with the ISO. -And finally, we'd plug the drive in the computer and boot from it. -When dealing with virtual machines, we will go through the same flow. -But instead of using a physical USB drive, we will use a virtual one. -Let’s see how it works. -We start by creating an URL from the path to the ISO file we downloaded. -Then we create a disk image attachment from the file. -A disk image attachment represents a piece of storage that we can attach to a device. -Next, we configure a virtual storage device. -In this case, we want USB storage, -so we use VZUSBMassStorageDeviceConfiguration. -Finally, as always, -we add our device in the main configuration. -Here, the USB device appears next to another storage device, -the main disk on which we will install Linux. -Now we have a USB drive, but we need a way to boot from it. -In macOS Ventura, we have added support for EFI. -EFI is an industry standard for booting both ARM and Intel hardware. -We are bringing the same support to virtual machines. -EFI has a boot discovery mechanism. -What this will allow is discovering the installer on our USB drive. -EFI looks at each drive for one that can be booted. -It will find the installer and start from there. -The installer itself will tell EFI what drive to use next. -After the installation, EFI can then start the Linux distribution. -Let’s see how to set up EFI in code. -First, we create a boot loader of type VZEFIBootLoader. -EFI requires non-volatile memory to store information between boots. -This is called the EFI variable store. -With virtual machines, we can back such storage -by a file on the filesystem. -Here, we create a new variable store from scratch. -Now EFI is ready. -We just need to set it as the boot loader on the configuration. -Next, we will look into a new capability for Linux VMs, graphics. -In macOS Ventura, we have added support for Virtio GPU 2D. -Virtio GPU 2D is a paravirtualized device that allows Linux -to provide surfaces to the host macOS. -Linux renders the content, gives the rendered frame -to Virtualization framework, which can then display it. -You can now show this content in your app with VZVirtualMachineView -just like on macOS. -Let’s see how to set it up. -Setting up the device is similar to what we did for macOS. -We start by creating a VZVirtioGraphicsDeviceConfiguration. -We need to define the size of our virtual display. -In Virtio terminology, a virtual display is a "scanout." -So we create one scanout with the size of the display. -Finally, we set the new device as the graphics device -of our configuration. -Now our VM is ready to display content with VZVirtualMachineView. -Next, let’s see everything together in a demo. -We start from where we left off. -Let's delete the code that is specific to the Mac. -Then let's change the disk we are booting from. -We'll swap the path from our Mac drive to our Linux drive. -Next, we need a boot loader. -We set up EFI with VZEFIBootLoader. -We first create the EFI boot loader object. -Then we load the variable store from its file. -And finally, we set EFI as the boot loader on our configuration. -Now we can boot, but it'd be nice to show the UI. -Let's add Virtio GPU to our configuration. -We simply create a graphics device -of type VZVirtioGraphicsDeviceConfiguration. -Then we define a scanout with the size of the virtual display. -And we set the Virtio GPU as a graphics device on our configuration. -The last touch is getting the mouse to work. -We just use a virtual USB screen coordinate pointer device, -and we'll have a mouse in Linux. -That's it. We can run the project. -EFI looks at the disk and finds it bootable. -Then Linux shows the content of the UI through the Virtio GPU device. -And we can use the mouse to interact with Linux. -Last but not least, we'll see how we can take advantage -of the Rosetta 2 technology inside Linux. -For many of us, we love developing services on our Mac, -but once our work is ready, -the binaries we create may need to run on x86 servers. -x86 instruction emulation has been great for this, -but we can do better. -In macOS Ventura, we are bringing the power -of Rosetta 2 to Linux binaries. -What Rosetta 2 does is translate the Linux x86-64 binaries -inside your virtual machine. -This means you can run your favorite ARM Linux distribution, -and its x86-64 apps can run with Rosetta. -And it's fast. -It's the same technology we have been using on the Mac, -which means we have incredible performance. -Let’s see how to use it. -First, we need to give Linux access to Rosetta. -To do this, we use the same file sharing technology we have seen on macOS. -Instead of sharing a folder, we use a special kind of object, -a VZLinuxRosettaDirectoryShare. -Then we create a sharing device and set up Rosetta directory share. -Finally, we set up our device on the configuration as usual. -Now our virtual machine is ready to use Rosetta. -Next, let’s see how Linux can take advantage of it. -In Linux, we start by mounting the shared directory in the file system. -What we see from Linux is a Rosetta binary that can translate applications. -Then we can use update-binfmts to tell the system to use Rosetta -to handle any x86-64 binary. -Don’t worry about remembering this command. -It's all in the documentation. -Now Linux is ready. -Every x86-64 binary launched will be translated by Rosetta. -Before we end our Linux section, let’s see everything together. -Here, we have a full Linux distribution installed from scratch. -We can show its UI with Virtio GPU 2D. -From within the VM, we run a PHP server with Rosetta. -And we can just connect to it from macOS host. -We've seen that creating virtual machines has never been simpler. -With Virtualization framework, you can get virtual machines running -with just a few lines of code. -We have also seen that virtual machines are ridiculously fast on macOS. -To learn more about Virtualization, -I invite you to check out the code samples and documentation. -And on behalf of the team, we cannot wait -to see what you will do next with this technology. -[upbeat music]