From d7dac0b878a5bef357a1692459bd254657c0d676 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 27 May 2026 16:29:26 -0400 Subject: [PATCH 1/2] Web app launch via CLI: work surface refactor and Codex integration Refactor Work tab surfaces to support web app launching from CLI sessions. Consolidate chat panel components, add Codex goal card rendering, fix CLI service command resolution, and sync plan-mode transitions from SDK status messages. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/skills/ade-web/SKILL.md | 125 ++++++ AGENTS.md | 16 + apps/ade-cli/src/cli.ts | 2 + apps/ade-cli/src/stdioRpcDaemon.test.ts | 1 + apps/ade-cli/src/tuiClient/connection.ts | 5 +- .../export-browser-mock-ade-snapshot.mjs | 14 + apps/desktop/src/main/main.ts | 12 +- .../builtInBrowserService.test.ts | 76 ++++ .../builtInBrowser/builtInBrowserService.ts | 143 ++++++ .../services/chat/agentChatService.test.ts | 378 ++++++++++++++++ .../main/services/chat/agentChatService.ts | 334 +++++++++++++- .../src/main/services/cli/adeCliService.ts | 7 +- .../src/main/services/ipc/registerIpc.ts | 75 ++-- .../main/services/macosVm/macosVmService.ts | 4 +- .../src/main/services/pty/ptyService.ts | 4 + .../remoteRuntime/remoteBootstrap.test.ts | 5 +- .../components/chat/AgentChatMessageList.tsx | 332 ++++++++++++-- .../components/chat/AgentChatPane.tsx | 89 ++-- .../components/chat/ChatAppControlPanel.tsx | 60 +-- .../chat/ChatBuiltInBrowserPanel.test.tsx | 5 +- .../chat/ChatBuiltInBrowserPanel.tsx | 128 ++---- .../components/chat/ChatIosSimulatorPanel.tsx | 92 ++-- .../components/chat/ChatSubagentsPanel.tsx | 21 +- .../chat/codex/CodexGoalCard.test.tsx | 117 +++++ .../components/chat/codex/CodexGoalCard.tsx | 257 +++++++++++ .../CliSessionWorkSurfaceHeader.test.tsx | 133 ++++++ .../terminals/CliSessionWorkSurfaceHeader.tsx | 173 +++++++ .../components/terminals/TerminalsPage.tsx | 23 +- .../terminals/WorkCliSessionHeader.test.tsx | 109 ----- .../terminals/WorkCliSessionHeader.tsx | 185 -------- .../terminals/WorkViewArea.test.tsx | 5 +- .../components/terminals/WorkViewArea.tsx | 425 +++++++++--------- .../work/WorkSurfaceHeader.test.tsx | 88 ++++ .../components/work/WorkSurfaceHeader.tsx | 104 +++++ apps/desktop/src/renderer/index.css | 27 +- docs/ARCHITECTURE.md | 2 +- docs/features/chat/README.md | 4 +- .../features/terminals-and-sessions/README.md | 31 +- .../terminals-and-sessions/ui-surfaces.md | 10 +- 39 files changed, 2776 insertions(+), 845 deletions(-) create mode 100644 .agents/skills/ade-web/SKILL.md create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.test.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx delete mode 100644 apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.test.tsx delete mode 100644 apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx create mode 100644 apps/desktop/src/renderer/components/work/WorkSurfaceHeader.test.tsx create mode 100644 apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx diff --git a/.agents/skills/ade-web/SKILL.md b/.agents/skills/ade-web/SKILL.md new file mode 100644 index 000000000..ee03490f6 --- /dev/null +++ b/.agents/skills/ade-web/SKILL.md @@ -0,0 +1,125 @@ +--- +name: ade-web +description: >- + Launch the ADE desktop app's renderer as a standalone web app (Vite-only preview) + seeded with real data from the ADE database. Works from any lane worktree without + interfering with running ADE sockets or runtimes. Use when asked to start, run, or + preview the ADE desktop web renderer, open the ADE web app, or view ADE UI in a browser. +metadata: + author: ADE + version: 0.1.0 +--- + +# ade-web — Launch the ADE Desktop Web Renderer + +Starts the ADE desktop renderer as a browser-accessible web app on `http://localhost:5173`, +seeded with a snapshot of the real ADE database. Safe to run alongside the ADE beta or +any other running ADE runtime — it does **not** touch sockets or start new runtimes. + +## When to use + +- User asks to run, start, preview, or open the ADE web app / desktop web renderer +- User wants to visually inspect or iterate on ADE desktop UI changes in a browser +- User asks to launch ADE web from a specific lane or worktree + +## Procedure + +### 1. Resolve the workspace root + +The web renderer must run from the **current lane's worktree**, not the main project checkout. + +``` +WORKTREE_ROOT="$(pwd)" +``` + +If `pwd` is not already inside `.ade/worktrees//`, resolve it: + +``` +# If inside a worktree, pwd is already correct. +# If at the project root, there is no lane context — ask the user which lane. +``` + +Confirm the desktop app exists at `$WORKTREE_ROOT/apps/desktop/package.json`. + +### 2. Kill any stale Vite on port 5173 + +```bash +lsof -ti :5173 2>/dev/null | xargs kill 2>/dev/null +``` + +Do **not** kill processes on any other port. Do **not** touch ADE runtime sockets +(`/tmp/ade-runtime-dev.sock`, `~/.ade-beta/sock/ade.sock`, etc.). + +### 3. Seed the database snapshot + +Export real data from the global ADE database into the browser mock: + +```bash +cd "$WORKTREE_ROOT/apps/desktop" && node ./scripts/export-browser-mock-ade-snapshot.mjs +``` + +This reads `.ade/ade.db` from the primary project root (auto-detected even from worktrees) +and writes `src/renderer/browser-mock-ade-snapshot.generated.json`. + +If this fails with "No database", the user hasn't opened the project in ADE desktop yet. +The renderer will still work with built-in demo data. + +### 4. Start the Vite dev server + +```bash +cd "$WORKTREE_ROOT/apps/desktop" && npm run dev:vite +``` + +This runs `vite --port 5173 --strictPort`. The `predev:vite` hook re-exports the +snapshot automatically, so step 3 is optional if you go straight here. + +Wait for the `VITE ready` message confirming it's listening. + +### 5. Open in the ADE browser (optional) + +If the user wants it in ADE's built-in browser: + +```bash +ade actions run built_in_browser createTab --socket --text --arg url=http://localhost:5173/work +``` + +Or navigate an existing tab: + +```bash +ade actions run built_in_browser navigate --socket --text --arg url=http://localhost:5173/work +``` + +Use `--socket` to communicate with the running ADE instance. This does **not** start a +new runtime or interfere with the existing socket. + +## Important constraints + +- **Never start a runtime or bridge.** Do not run `dev:vite:live`, `dev:browser-bridge`, + or `ensureRuntime`. These may detect version mismatches and restart the user's running + ADE beta/dev runtime. +- **Never touch the ADE socket.** The Vite-only preview uses `browserMock.ts` to stub + `window.ade` — it does not need a runtime connection. +- **Always run from the worktree.** All `cd` commands, file reads, and file edits must + target paths under `$WORKTREE_ROOT`, never the main project checkout. When `grep` or + `find` returns absolute paths rooted at the main checkout, translate them to the + worktree before editing. +- **Port 5173 only.** Do not change the port. The desktop app's Vite config uses + `--strictPort` so it will error if the port is taken rather than silently picking another. + +## Cleanup + +When done, kill the Vite server: + +```bash +lsof -ti :5173 2>/dev/null | xargs kill 2>/dev/null +``` + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `Port 5173 is already in use` | Kill the stale process: `lsof -ti :5173 \| xargs kill` | +| `No database at ...` | Run `export-browser-mock-ade-snapshot.mjs` with `ADE_PROJECT_ROOT=/path/to/ADE` pointing at the main checkout | +| `ERR_CONNECTION_REFUSED` in ADE browser | Vite died — restart with `npm run dev:vite` from the worktree | +| Mock data instead of real data | Re-run the export script, then restart Vite or hard-refresh the browser | +| `proxy error: /health ECONNREFUSED 127.0.0.1:18765` | Expected — this is the browser bridge port. Vite-only mode doesn't use it. Ignore. | diff --git a/AGENTS.md b/AGENTS.md index 3ee87c62a..c2784ef3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,22 @@ Desktop release: - Validation commands are documented in the "Validation" section above. - The desktop test suite is large; CI shards it. For local iteration, run a single file or one CI-style shard rather than the full suite. +### Working in ADE lanes (worktrees) + +- When an agent session runs inside an ADE lane, its working directory is the lane's worktree (e.g. `/path/to/ADE/.ade/worktrees//`). **All file reads, edits, and writes MUST target paths under that worktree, never under the main project-root checkout.** +- `grep`, `find`, and Explore agents may return absolute paths rooted at the main checkout. Before editing, translate those paths to the worktree: replace the project root prefix with the worktree root. For example, `/Users/admin/Projects/ADE/apps/desktop/src/foo.ts` becomes `/apps/desktop/src/foo.ts`. +- Use relative paths from your working directory whenever possible — they resolve to the worktree automatically. +- If `ADE_REPO_ROOT` is set in the environment, use it as the canonical base for all file operations. +- When launching dev servers (Vite, Electron, etc.) for a lane, run them from the worktree, not the main checkout: `cd /apps/desktop && npm run dev:vite`. + +### Running the ADE desktop web renderer (Vite-only preview) + +- The desktop renderer can run standalone in a browser without Electron via `npm run dev:vite` in `apps/desktop`. This starts Vite on port 5173 with a browser mock for `window.ade`. +- To seed the mock with real data from the ADE database, run `npm run export:browser-mock-ade` in `apps/desktop` first, or let the `predev:vite` hook do it automatically. The export script reads `.ade/ade.db` from the primary project root and writes a snapshot to `src/renderer/browser-mock-ade-snapshot.generated.json`. +- This works from any lane worktree: `cd /apps/desktop && npm run dev:vite`. The export script detects worktree paths and resolves the `.ade/ade.db` location from the parent project root. +- For live data (connected to the ADE runtime socket instead of mock data), use `npm run dev:vite:live`. This starts both Vite and a browser-runtime bridge. Note: this calls `ensureRuntime` which may restart a stale dev runtime — avoid if the ADE beta or another runtime is already running on the target socket. +- Open `http://localhost:5173/work` in a browser or ADE's built-in browser to view the Work tab. + ### Inspecting the local Electron desktop app with Codex Computer Use on macOS - To inspect ADE desktop parity locally with Codex Computer Use, launch the dev app from the worktree with `npm run dev` in `apps/desktop`. diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index bbc55cf30..fc90310c6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10515,6 +10515,8 @@ async function spawnMachineRuntimeDaemon( } if (runtimeBuildHash) { env.ADE_RUNTIME_BUILD_HASH = runtimeBuildHash; + } else { + delete env.ADE_RUNTIME_BUILD_HASH; } const child = spawn(serviceCommand.command, args, { diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index 759e758e1..f84844151 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -416,6 +416,7 @@ describe("ade rpc --stdio daemon bridge", () => { ADE_HOME: adeHome, NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), ADE_CLI_VERSION: "2.0.0", + ADE_RUNTIME_BUILD_HASH: "", }; const tcpDaemon = startServeProcess({ cliPath, diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 7c9318597..13e15b4db 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -454,12 +454,13 @@ function spawnDaemon(socketPath: string): boolean { const daemonArgs = cliEntrypoint ? [cliEntrypoint, "serve", "--socket", socketPath] : ["serve", "--socket", socketPath]; - const env = { + const env: NodeJS.ProcessEnv = { ...process.env, ADE_DEFAULT_ROLE: "cto", ADE_RPC_SOCKET_PATH: socketPath, - ...(buildHash ? { ADE_RUNTIME_BUILD_HASH: buildHash } : {}), }; + if (buildHash) env.ADE_RUNTIME_BUILD_HASH = buildHash; + else delete env.ADE_RUNTIME_BUILD_HASH; const child = spawn( process.execPath, daemonArgs, diff --git a/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs b/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs index 2701c8323..ac83fe419 100644 --- a/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs +++ b/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs @@ -28,6 +28,18 @@ const args = process.argv.slice(2); const optional = args.includes("--optional"); const positionalRoot = args.find((arg) => !arg.startsWith("-")); +function resolveWorktreeParentRoot(dir) { + const sep = path.sep; + const parts = dir.split(sep); + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (parts[i] === "worktrees" && i > 0 && parts[i - 1] === ".ade") { + const root = parts.slice(0, i - 1).join(sep) || sep; + return path.resolve(root); + } + } + return null; +} + function resolveProjectRoot() { if (process.env.ADE_PROJECT_ROOT) { return path.resolve(process.env.ADE_PROJECT_ROOT); @@ -43,6 +55,8 @@ function resolveProjectRoot() { path.resolve(cwd, "../../.."), REPO_ROOT_FROM_SCRIPT, ]; + const worktreeParent = resolveWorktreeParentRoot(cwd) ?? resolveWorktreeParentRoot(REPO_ROOT_FROM_SCRIPT); + if (worktreeParent) candidates.push(worktreeParent); for (const candidate of candidates) { if (existsSync(path.join(candidate, ".ade", "ade.db"))) { return candidate; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 5f6ab7ba9..b4416eaa8 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1119,7 +1119,17 @@ app.whenReady().then(async () => { const builtInBrowserService = createBuiltInBrowserService({ getLogger: () => getActiveContext().logger, - onEvent: (payload) => broadcast(IPC.builtInBrowserEvent, payload), + onEvent: (payload, targetWindow) => { + if (targetWindow && !targetWindow.isDestroyed()) { + try { + targetWindow.webContents.send(IPC.builtInBrowserEvent, payload); + } catch { + // ignore stale window sends + } + return; + } + broadcast(IPC.builtInBrowserEvent, payload); + }, }); // Side-channel JSON-RPC server that lets the runtime daemon proxy diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index 3aba12cc8..fce682fad 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -246,9 +246,12 @@ function captureStatusEvents(): { }; } +let fakeWindowId = 1; + function fakeBrowserWindow() { const children: unknown[] = []; return { + id: fakeWindowId++, isDestroyed: () => false, contentView: { children, @@ -270,6 +273,7 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { beforeEach(() => { collector = captureStatusEvents(); + fakeWindowId = 1; fakes.clearWebContentsInstances(); fakes.clearBeforeSendHeadersHandlers(); fakes.clearPermissionHandlers(); @@ -374,6 +378,78 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { expect(wc?.audioMutedCalls.at(-1)).toBe(true); }); + it("keeps a visible browser view attached to its owner window when another ADE window focuses", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + const winA = fakeBrowserWindow(); + const winB = fakeBrowserWindow(); + const browserWinA = winA as unknown as Parameters[0]; + const browserWinB = winB as unknown as Parameters[0]; + + service.attachToWindow(browserWinA); + await service.createTab({ url: "https://a.example.test", activate: true }, browserWinA); + await service.setBounds({ x: 12, y: 24, width: 640, height: 360, visible: true }, browserWinA); + + expect(winA.contentView.children).toHaveLength(1); + expect(winB.contentView.children).toHaveLength(0); + expect(service.getStatus(browserWinA).visible).toBe(true); + + service.attachToWindow(browserWinB); + + expect(winA.contentView.children).toHaveLength(1); + expect(winB.contentView.children).toHaveLength(0); + expect(service.getStatus(browserWinA).visible).toBe(true); + expect(service.getStatus(browserWinB).visible).toBe(false); + expect(service.getStatus(browserWinB).tabs).toEqual([]); + }); + + it("scopes browser tabs and commands to the sender window", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + const winA = fakeBrowserWindow(); + const winB = fakeBrowserWindow(); + const browserWinA = winA as unknown as Parameters[0]; + const browserWinB = winB as unknown as Parameters[0]; + + service.attachToWindow(browserWinA); + await service.createTab({ url: "https://a.example.test", activate: true }, browserWinA); + service.attachToWindow(browserWinB); + await service.createTab({ url: "https://b.example.test", activate: true }, browserWinB); + + expect(service.getStatus(browserWinA).tabs).toHaveLength(1); + expect(service.getStatus(browserWinA).url).toBe("https://a.example.test/"); + expect(service.getStatus(browserWinB).tabs).toHaveLength(1); + expect(service.getStatus(browserWinB).url).toBe("https://b.example.test/"); + + await service.navigate({ url: "https://b-2.example.test" }, browserWinB); + + expect(service.getStatus(browserWinA).url).toBe("https://a.example.test/"); + expect(service.getStatus(browserWinB).url).toBe("https://b-2.example.test/"); + }); + + it("targets browser events to the owning ADE window", async () => { + const targetedEvents: Array<{ payload: BuiltInBrowserEventPayload; targetWindow: unknown }> = []; + const service = createBuiltInBrowserService({ + onEvent: (payload, targetWindow) => targetedEvents.push({ payload, targetWindow }), + }); + const winA = fakeBrowserWindow(); + const winB = fakeBrowserWindow(); + const browserWinA = winA as unknown as Parameters[0]; + const browserWinB = winB as unknown as Parameters[0]; + + service.attachToWindow(browserWinA); + targetedEvents.length = 0; + await service.createTab({ url: "https://a.example.test", activate: true }, browserWinA); + + expect(targetedEvents.length).toBeGreaterThan(0); + expect(targetedEvents.every((event) => event.targetWindow === browserWinA)).toBe(true); + + service.attachToWindow(browserWinB); + targetedEvents.length = 0; + await service.createTab({ url: "https://b.example.test", activate: true }, browserWinB); + + expect(targetedEvents.length).toBeGreaterThan(0); + expect(targetedEvents.every((event) => event.targetWindow === browserWinB)).toBe(true); + }); + it("keeps Google account sign-in inside ADE browser tabs", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); const googleAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=test"; diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index b77396510..cac1b1037 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -92,6 +92,149 @@ type BrowserTabState = { }; export function createBuiltInBrowserService(args: { + getLogger?: () => Logger; + onEvent?: ((payload: BuiltInBrowserEventPayload, targetWindow?: BrowserWindow | null) => void) | null; +}) { + type WindowBrowserService = ReturnType; + type WindowBrowserEntry = { + win: BrowserWindow; + service: WindowBrowserService; + closedListener: () => void; + }; + + const windowServices = new Map(); + let activeWindowId: number | null = null; + let fallbackService: WindowBrowserService | null = null; + + const createServiceForWindow = (win: BrowserWindow): WindowBrowserService => + createBuiltInBrowserWindowService({ + getLogger: args.getLogger, + onEvent: (payload) => args.onEvent?.(payload, win), + }); + + const serviceForWindow = (win: BrowserWindow): WindowBrowserService => { + const existing = windowServices.get(win.id); + if (existing) return existing.service; + + fallbackService?.dispose(); + fallbackService = null; + const service = createServiceForWindow(win); + const closedListener = () => { + windowServices.delete(win.id); + if (activeWindowId === win.id) activeWindowId = null; + service.dispose(); + }; + windowServices.set(win.id, { win, service, closedListener }); + win.once("closed", closedListener); + return service; + }; + + const activeService = (): WindowBrowserService => { + if (activeWindowId != null) { + const active = windowServices.get(activeWindowId); + if (active) return active.service; + } + const first = windowServices.values().next().value as WindowBrowserEntry | undefined; + if (first) return first.service; + if (!fallbackService) { + fallbackService = createBuiltInBrowserWindowService({ + getLogger: args.getLogger, + onEvent: (payload) => args.onEvent?.(payload, null), + }); + } + return fallbackService; + }; + + const isLiveWindow = (value: BrowserWindow | null | undefined): value is BrowserWindow => + Boolean( + value + && typeof (value as { id?: unknown }).id === "number" + && typeof (value as { isDestroyed?: unknown }).isDestroyed === "function" + && !value.isDestroyed() + ); + + const serviceFor = (win?: BrowserWindow | null): WindowBrowserService => + isLiveWindow(win) ? serviceForWindow(win) : activeService(); + + return { + attachToWindow(nextWin: BrowserWindow): void { + activeWindowId = nextWin.id; + serviceForWindow(nextWin).attachToWindow(nextWin); + }, + getStatus(sourceWindow?: BrowserWindow | null): BuiltInBrowserStatus { + return serviceFor(sourceWindow).getStatus(); + }, + showPanel(input: BuiltInBrowserOpenPanelArgs = {}, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).showPanel(input); + }, + setBounds(nextBounds: BuiltInBrowserBoundsArgs, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).setBounds(nextBounds); + }, + attachWebview(input: BuiltInBrowserAttachWebviewArgs, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).attachWebview(input); + }, + navigate(input: BuiltInBrowserNavigateArgs, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).navigate(input); + }, + createTab(input: BuiltInBrowserCreateTabArgs = {}, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).createTab(input); + }, + switchTab(input: BuiltInBrowserTabArgs, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).switchTab(input); + }, + closeTab(input: BuiltInBrowserTabArgs, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).closeTab(input); + }, + reload(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).reload(); + }, + goBack(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).goBack(); + }, + goForward(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).goForward(); + }, + stop(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).stop(); + }, + startInspect(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).startInspect(); + }, + stopInspect(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).stopInspect(); + }, + captureScreenshot(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).captureScreenshot(); + }, + selectPoint(input: BuiltInBrowserSelectPointArgs, sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).selectPoint(input); + }, + selectCurrent(sourceWindow?: BrowserWindow | null): Promise { + return serviceFor(sourceWindow).selectCurrent(); + }, + clearSelection(sourceWindow?: BrowserWindow | null): Promise<{ ok: true }> { + return serviceFor(sourceWindow).clearSelection(); + }, + dispose(): void { + for (const entry of windowServices.values()) { + if (!entry.win.isDestroyed()) { + try { + entry.win.removeListener("closed", entry.closedListener); + } catch { + // ignore stale window links + } + } + entry.service.dispose(); + } + windowServices.clear(); + fallbackService?.dispose(); + fallbackService = null; + activeWindowId = null; + }, + }; +} + +function createBuiltInBrowserWindowService(args: { getLogger?: () => Logger; onEvent?: ((payload: BuiltInBrowserEventPayload) => void) | null; }) { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f701e4ebf..37416b17d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -8437,6 +8437,229 @@ describe("createAgentChatService", () => { expect(empty).toEqual([]); }); + it("pulls codex subagent transcript live from the app-server via thread/turns/list", async () => { + // When the codex runtime is alive, getSubagentTranscript should ask + // codex's app-server for the subagent thread's own turns/items — + // matching what the Codex desktop app does — instead of falling back + // to filtering ADE's parent event history. + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + // Track every request to the codex app-server so we can prove we + // actually called `thread/turns/list` instead of staying in the + // event-history fallback. + const appServerCalls: Array<{ method: string; params: unknown }> = []; + mockState.codexResponseOverrides.set("thread/turns/list", (payload) => { + appServerCalls.push({ method: "thread/turns/list", params: payload.params }); + return { + data: [ + { + id: "turn-sub-1", + startedAt: 1, + completedAt: 2, + durationMs: 1000, + status: "completed", + error: null, + itemsView: "full", + items: [ + { + id: "item-reasoning", + type: "reasoning", + summary: ["Mapping the dependency graph."], + content: ["Need to confirm the call sites use the new helper."], + }, + { + id: "item-command", + type: "commandExecution", + command: "rg --files-with-matches \"oldFn\"", + cwd: "/Users/admin/Projects/ADE", + aggregatedOutput: "src/foo.ts\nsrc/bar.ts\n", + exitCode: 0, + durationMs: 35, + status: "completed", + commandActions: [], + source: "shell", + processId: null, + }, + { + id: "item-file", + type: "fileChange", + status: "completed", + changes: [ + { path: "src/foo.ts", unifiedDiff: "--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -1 +1 @@\n-foo\n+bar\n", kind: "modify" }, + ], + }, + { + id: "item-text", + type: "agentMessage", + text: "Investigation complete. Two call sites updated.", + phase: null, + memoryCitation: null, + }, + ], + }, + ], + nextCursor: null, + backwardsCursor: null, + }; + }); + + // Announce the subagent thread on the parent stream so ADE registers + // an active subagent the client can drill into. ADE only needs the + // threadId — the actual transcript will be pulled from the app-server. + await service.sendMessage({ + sessionId: session.id, + text: "Spawn an investigation agent.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-live"], + prompt: "Investigate dependencies.", + agentsStates: {}, + }, + }, + }); + + const transcript = await service.getSubagentTranscript({ + sessionId: session.id, + agentId: "agent-thread-live", + }); + + expect(appServerCalls.length).toBeGreaterThanOrEqual(1); + expect(appServerCalls[0].method).toBe("thread/turns/list"); + expect((appServerCalls[0].params as { threadId: string }).threadId).toBe("agent-thread-live"); + expect((appServerCalls[0].params as { itemsView: string }).itemsView).toBe("full"); + + expect(transcript).not.toBeNull(); + const types = transcript!.map((m) => (m.message as { type: string }).type); + expect(types).toEqual(["reasoning", "command", "file_change", "text"]); + const commandEvent = transcript!.find((m) => (m.message as { type: string }).type === "command")!.message as { + type: "command"; + command: string; + output: string; + status: string; + exitCode: number; + }; + expect(commandEvent.command).toContain("oldFn"); + expect(commandEvent.output).toContain("src/foo.ts"); + expect(commandEvent.status).toBe("completed"); + expect(commandEvent.exitCode).toBe(0); + const fileEvent = transcript!.find((m) => (m.message as { type: string }).type === "file_change")!.message as { + type: "file_change"; + path: string; + diff: string; + kind: string; + }; + expect(fileEvent.path).toBe("src/foo.ts"); + expect(fileEvent.diff).toContain("+bar"); + expect(fileEvent.kind).toBe("modify"); + }); + + it("falls back to event-history filter when codex app-server fails on thread/turns/list", async () => { + // Older codex builds may not support `thread/turns/list` for spawned + // subagent threads. The transcript pipe must still return data — fall + // back to ADE's aggregated `subagent_*` envelopes from the parent + // stream. + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + mockState.codexResponseOverrides.set("thread/turns/list", () => ({ + error: { code: -32601, message: "Method not found" }, + })); + + await service.sendMessage({ + sessionId: session.id, + text: "Spawn an investigation agent.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-fallback"], + prompt: "Investigate.", + agentsStates: {}, + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "collab-2", + type: "collabAgentToolCall", + tool: "wait", + status: "completed", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-fallback"], + agentsStates: { + "agent-thread-fallback": { + status: "completed", + message: "Investigation summary recorded.", + }, + }, + }, + }, + }); + + const transcript = await service.getSubagentTranscript({ + sessionId: session.id, + agentId: "agent-thread-fallback", + }); + expect(transcript).not.toBeNull(); + expect(transcript!.length).toBeGreaterThanOrEqual(2); + const types = transcript!.map((m) => (m.message as { type: string }).type); + expect(types).toContain("subagent_started"); + expect(types).toContain("subagent_result"); + }); + it("coalesces Codex spawn placeholders when the app-server reveals the agent thread later", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -9184,6 +9407,161 @@ describe("createAgentChatService", () => { expect(summary?.claudePermissionMode).toBe("acceptEdits"); }); + it("syncs session permissionMode and emits a plan-mode notice when the SDK status message reports a transition", async () => { + // The Claude Agent SDK handles EnterPlanMode/ExitPlanMode internally in + // the bundled `claude` binary and signals the host via an SDKStatusMessage + // (type: "system", subtype: "status") carrying the new permissionMode. + // ADE must update its session state and emit the standard plan-mode + // notice from this branch — without it, the renderer's prompt-box + // permission badge never reflects the SDK-side transition. + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-status-plan", + slash_commands: [], + }; + return; + } + + // SDK reports the internal EnterPlanMode transition via a status + // message instead of routing through canUseTool. + yield { + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + }; + + // SDK later reports ExitPlanMode the same way. + yield { + type: "system", + subtype: "status", + status: null, + permissionMode: "default", + }; + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Plan flow completed via status." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-status-plan", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Drive plan mode via status messages.", + }); + + const planTransitionNotices = events + .map((envelope) => envelope.event) + .filter((event): event is Extract => + event.type === "system_notice" + && (event.detail as { permissionModeTransition?: string } | undefined)?.permissionModeTransition !== undefined, + ); + expect(planTransitionNotices.map((notice) => + (notice.detail as { permissionModeTransition: string }).permissionModeTransition, + )).toEqual(["entered_plan_mode", "exited_plan_mode"]); + + const summary = await service.getSessionSummary(session.id); + expect(summary?.permissionMode).not.toBe("plan"); + }); + + it("ignores SDK status messages whose permissionMode matches the session's current mode", async () => { + // Status messages can arrive frequently. Only the transitions should + // emit notices — a redundant `permissionMode: "default"` while the + // session is already in a non-plan mode must be a no-op. + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-status-noop", + slash_commands: [], + }; + return; + } + + yield { + type: "system", + subtype: "status", + status: null, + permissionMode: "default", + }; + + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-status-noop", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Status message must not spuriously toggle plan mode.", + }); + + const planTransitionNotices = events + .map((envelope) => envelope.event) + .filter((event): event is Extract => + event.type === "system_notice" + && (event.detail as { permissionModeTransition?: string } | undefined)?.permissionModeTransition !== undefined, + ); + expect(planTransitionNotices).toHaveLength(0); + }); + it("emits todo_update events for Claude TodoWrite tool uses", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7aae46338..da88e413a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -10054,9 +10054,36 @@ export function createAgentChatService(args: { continue; } - // system:status — permission mode changes + // system:status — permission mode changes and turn-status signals. + // + // The SDK CLI emits SDKStatusMessage with the new permissionMode when + // it switches modes internally (e.g. after EnterPlanMode / ExitPlanMode + // resolve inside the bundled `claude` binary rather than through the + // host's canUseTool callback). The canUseTool interception above only + // catches the cases where the SDK routes plan-mode tools through the + // host; this branch is the safety net that keeps ADE's session state + // and the renderer's permission-mode badge in sync no matter which + // path the SDK takes. if (msg.type === "system" && (msg as any).subtype === "status") { const statusMsg = msg as any; + const reportedMode = typeof statusMsg.permissionMode === "string" + ? statusMsg.permissionMode + : null; + if (reportedMode) { + const wasPlan = managed.session.permissionMode === "plan"; + const nowPlan = reportedMode === "plan"; + if (wasPlan !== nowPlan) { + applyClaudePlanModeTransition(managed.session, nowPlan ? "plan" : "default"); + persistChatState(managed); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: nowPlan ? "Session entered plan mode" : "Session exited plan mode", + detail: buildClaudePlanModeNoticeDetail(nowPlan ? "entered_plan_mode" : "exited_plan_mode"), + turnId, + }); + } + } if (statusMsg.status === "compacting") { emitChatEvent(managed, { type: "system_notice", @@ -22609,6 +22636,271 @@ export function createAgentChatService(args: { }); }; + /** + * Convert a raw codex ThreadItem (from `thread/turns/items/list` / + * `thread/turns/list?itemsView=full`) into one or more + * AgentChatSubagentTranscriptMessage entries whose `message` is the same + * AgentChatEvent shape the renderer's typed-card timeline already knows how + * to render. Returning multiple entries handles fileChange items that carry + * a batch of per-file changes. + * + * Unknown / low-signal item types (hookPrompt, enteredReviewMode, + * exitedReviewMode, contextCompaction) are dropped so the transcript stays + * focused on the agent's actual work product. + */ + const codexThreadItemToTranscriptMessages = ( + item: Record, + threadId: string, + turnId: string | null, + ): AgentChatSubagentTranscriptMessage[] => { + const itemId = typeof item.id === "string" && item.id.length > 0 ? item.id : randomUUID(); + const itemType = typeof item.type === "string" ? item.type : ""; + const baseUuid = `codex-thread-item:${threadId}:${itemId}`; + const baseMessage = (event: AgentChatEvent, role: AgentChatSubagentTranscriptMessage["type"], extras?: { uuidSuffix?: string; text?: string }): AgentChatSubagentTranscriptMessage => ({ + type: role, + uuid: extras?.uuidSuffix ? `${baseUuid}:${extras.uuidSuffix}` : baseUuid, + sessionId: threadId, + parentToolUseId: null, + message: event, + ...(extras?.text ? { text: extras.text } : {}), + }); + + const mapCommandStatusLocal = (value: unknown): "running" | "completed" | "failed" => { + const status = typeof value === "string" ? value.toLowerCase() : ""; + if (status === "failed" || status === "error") return "failed"; + if (status === "inprogress" || status === "running" || status === "in_progress") return "running"; + return "completed"; + }; + const mapFileChangeKind = (kind: unknown): "create" | "modify" | "delete" => { + const value = typeof kind === "string" ? kind.toLowerCase() : ""; + if (value === "create" || value === "add" || value === "added") return "create"; + if (value === "delete" || value === "remove" || value === "removed") return "delete"; + return "modify"; + }; + + switch (itemType) { + case "agentMessage": { + const text = typeof item.text === "string" ? item.text : ""; + if (!text.trim()) return []; + return [baseMessage({ type: "text", text, itemId, ...(turnId ? { turnId } : {}) }, "assistant", { text })]; + } + case "userMessage": { + const content = Array.isArray(item.content) ? item.content : []; + const text = content + .map((entry: unknown) => { + if (typeof entry === "string") return entry; + const record = entry as { text?: unknown; content?: unknown } | null; + if (record && typeof record.text === "string") return record.text; + return ""; + }) + .filter(Boolean) + .join("\n") + .trim(); + if (!text) return []; + return [baseMessage({ type: "user_message", text, messageId: itemId, ...(turnId ? { turnId } : {}) }, "user", { text })]; + } + case "reasoning": { + const summary = Array.isArray(item.summary) ? item.summary.filter((s): s is string => typeof s === "string") : []; + const content = Array.isArray(item.content) ? item.content.filter((s): s is string => typeof s === "string") : []; + const text = [...summary, ...content].join("\n").trim(); + if (!text) return []; + return [baseMessage({ type: "reasoning", text, itemId, ...(turnId ? { turnId } : {}) }, "system", { text })]; + } + case "plan": { + const text = typeof item.text === "string" ? item.text : ""; + if (!text.trim()) return []; + return [ + baseMessage( + { + type: "plan", + steps: [], + streamingText: text, + explanation: text, + state: "complete", + itemId, + ...(turnId ? { turnId } : {}), + }, + "system", + { text }, + ), + ]; + } + case "commandExecution": { + const command = typeof item.command === "string" ? item.command : "command"; + const cwd = typeof item.cwd === "string" ? item.cwd : ""; + const output = typeof item.aggregatedOutput === "string" ? item.aggregatedOutput : ""; + const exitCode = typeof item.exitCode === "number" ? item.exitCode : null; + const durationMs = typeof item.durationMs === "number" ? item.durationMs : null; + const status = mapCommandStatusLocal(item.status); + return [ + baseMessage( + { + type: "command", + command, + cwd, + output, + itemId, + status, + ...(exitCode != null ? { exitCode } : {}), + ...(durationMs != null ? { durationMs } : {}), + ...(turnId ? { turnId } : {}), + }, + "system", + { text: command }, + ), + ]; + } + case "fileChange": { + const changes = Array.isArray(item.changes) ? item.changes : []; + const status = mapCommandStatusLocal(item.status); + return changes + .map((change: unknown, index: number): AgentChatSubagentTranscriptMessage | null => { + const record = change as { path?: unknown; unifiedDiff?: unknown; kind?: unknown; type?: unknown } | null; + if (!record) return null; + const path = typeof record.path === "string" ? record.path : ""; + if (!path) return null; + const diff = typeof record.unifiedDiff === "string" ? record.unifiedDiff : ""; + const kind = mapFileChangeKind(record.kind ?? record.type); + return baseMessage( + { + type: "file_change", + path, + diff, + kind, + itemId, + status, + ...(turnId ? { turnId } : {}), + }, + "system", + { uuidSuffix: `fc-${index}` }, + ); + }) + .filter((entry): entry is AgentChatSubagentTranscriptMessage => entry !== null); + } + case "webSearch": { + const query = typeof item.query === "string" ? item.query : ""; + if (!query.trim()) return []; + const actionRecord = (item.action ?? null) as { kind?: unknown } | null; + const action = actionRecord && typeof actionRecord.kind === "string" ? actionRecord.kind : undefined; + return [ + baseMessage( + { + type: "web_search", + query, + itemId, + status: "completed", + ...(action ? { action } : {}), + ...(turnId ? { turnId } : {}), + }, + "system", + { text: query }, + ), + ]; + } + case "imageGeneration": { + const status = mapCommandStatusLocal(item.status); + return [ + baseMessage( + { + type: "codex_image_generation", + itemId, + status, + prompt: typeof item.prompt === "string" ? item.prompt : null, + revisedPrompt: typeof item.revisedPrompt === "string" ? item.revisedPrompt : null, + result: typeof item.result === "string" ? item.result : null, + savedPath: typeof item.savedPath === "string" ? item.savedPath : null, + ...(turnId ? { turnId } : {}), + }, + "system", + ), + ]; + } + case "mcpToolCall": + case "dynamicToolCall": { + const tool = typeof item.tool === "string" ? item.tool : itemType; + const server = typeof item.server === "string" ? item.server : null; + const slug = server ? `${server}:${tool}` : tool; + return [ + baseMessage( + { + type: "tool_call", + tool: slug, + args: (item.arguments ?? null) as unknown, + itemId, + ...(turnId ? { turnId } : {}), + }, + "system", + { text: `tool: ${slug}` }, + ), + ]; + } + default: + // hookPrompt, enteredReviewMode, exitedReviewMode, contextCompaction, + // collabAgentToolCall, imageView — all low-signal for a subagent + // transcript view, so we drop them rather than synthesizing fake events. + return []; + } + }; + + /** + * Pull the subagent's transcript directly from the Codex app-server by + * listing the turns of the subagent thread with `itemsView: "full"`. Each + * returned Turn already carries its items, so a single round-trip per page + * covers the whole thread. Returns the converted transcript messages, or + * `null` if Codex didn't return usable data (so the caller can fall back to + * the event-history filter). + */ + const fetchCodexSubagentTranscriptFromAppServer = async ( + runtime: CodexRuntime, + threadId: string, + options: { limit?: number; offset?: number } = {}, + ): Promise => { + type CodexTurnsListResponse = { + data?: Array<{ id?: unknown; items?: unknown; startedAt?: unknown }>; + nextCursor?: unknown; + }; + const collected: AgentChatSubagentTranscriptMessage[] = []; + let cursor: string | null = null; + let pages = 0; + const MAX_PAGES = 10; + try { + do { + const params: Record = { + threadId, + itemsView: "full", + limit: 50, + // Ascending so we accumulate in chronological order; the protocol + // default is descending (newest first). + sortDirection: "ascending", + }; + if (cursor) params.cursor = cursor; + const response = await runtime.request("thread/turns/list", params); + const turns = Array.isArray(response?.data) ? response.data : []; + for (const turn of turns) { + const turnId = typeof turn?.id === "string" ? turn.id : null; + const items = Array.isArray(turn?.items) ? turn.items : []; + for (const item of items) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + collected.push(...codexThreadItemToTranscriptMessages(item as Record, threadId, turnId)); + } + } + cursor = typeof response?.nextCursor === "string" ? response.nextCursor : null; + pages += 1; + } while (cursor && pages < MAX_PAGES); + } catch { + // Codex app-server may not support thread/turns/list for spawned + // subagent threads in older builds, or the runtime may be busy. Signal + // a fallback to the event-history filter instead of crashing the + // drill-in view. + return null; + } + + if (collected.length === 0) return null; + + const sliced = options.offset !== undefined ? collected.slice(options.offset) : collected; + return options.limit !== undefined ? sliced.slice(0, options.limit) : sliced; + }; + /** * Fetch the transcript of a subagent run within an existing chat session. * @@ -22620,9 +22912,11 @@ export function createAgentChatService(args: { * `runtime.handle.client.session.messages({ path: { id }, query: { directory }})` * and translate the returned `{info, parts}[]` rows into the renderer's * transcript message shape. - * - **Codex**: codex's app-server never streams per-thread activity into the - * parent session, so the transcript is the subset of parent envelopes whose - * `subagent_*` event carries `taskId === threadId` (the codex agentId). + * - **Codex**: prefer a live app-server pull of the subagent's own thread + * via `thread/turns/list?itemsView=full` (this is what the Codex desktop + * app does). Falls back to filtering the parent session's + * `eventHistoryBySession` by `taskId === threadId` when the runtime is + * idle or the call fails. * - **Cursor**: SDK `task` events tag every lifecycle envelope with the * subagent's `agentId`; we filter the parent stream by that value. * - **Everything else (droid, lmstudio, …)**: `null`. @@ -22696,9 +22990,37 @@ export function createAgentChatService(args: { } } - if (runtimeKind === "codex" || runtimeKind === "cursor") { + // Codex/Cursor: walk `eventHistoryBySession` and surface every event + // tagged with the subagent's taskId/agentId. We accept the branch when + // EITHER the live runtime is codex/cursor OR the persisted session is + // codex/cursor (runtime may be idle when a chat is opened from history, + // but the events were buffered when the runtime was live and are still + // sufficient to reconstruct the subagent transcript). + const treatAsCodexLike = + runtimeKind === "codex" + || runtimeKind === "cursor" + || (runtimeKind === null && (provider === "codex" || provider === "cursor")); + + // When the codex runtime is live we can ask the app-server directly for + // the subagent's own thread. This matches the Codex desktop app behaviour + // and surfaces every reasoning/command/file_change/web_search item, not + // just ADE's aggregated `subagent_*` envelopes on the parent stream. + if (managed?.runtime?.kind === "codex") { + const liveTranscript = await fetchCodexSubagentTranscriptFromAppServer( + managed.runtime, + normalizedAgentId, + { + ...(normalizedLimit !== undefined ? { limit: normalizedLimit } : {}), + ...(normalizedOffset !== undefined ? { offset: normalizedOffset } : {}), + }, + ); + if (liveTranscript) return liveTranscript; + } + + if (treatAsCodexLike) { const envelopes = eventHistoryBySession.get(normalizedSessionId) ?? []; - const matchKey = runtimeKind === "codex" ? "taskId" : "agentId"; + const matchKey: "taskId" | "agentId" = + runtimeKind === "cursor" || provider === "cursor" ? "agentId" : "taskId"; const matched: AgentChatSubagentTranscriptMessage[] = []; for (const envelope of envelopes) { const event = envelope.event as Record & { type: string }; diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index 5304be734..de5b06305 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -98,10 +98,11 @@ function sanitizeCommandName(value: unknown): string | null { } function resolveCommandName(args: CreateAdeCliServiceArgs): string { - const explicit = sanitizeCommandName(args.env?.ADE_CLI_INSTALL_NAME ?? process.env.ADE_CLI_INSTALL_NAME); + const env = args.env ?? process.env; + const explicit = sanitizeCommandName(env.ADE_CLI_INSTALL_NAME); if (explicit) return explicit; - const channel = normalizePackageChannel(args.env?.ADE_PACKAGE_CHANNEL ?? process.env.ADE_PACKAGE_CHANNEL); - if (channel) return `ade-${channel}`; + const channel = normalizePackageChannel(env.ADE_PACKAGE_CHANNEL); + if (args.isPackaged && channel) return `ade-${channel}`; return args.isPackaged ? "ade" : "ade-dev"; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 999c0c02c..2c288e610 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1935,7 +1935,7 @@ export function registerIpc({ event: IpcMainInvokeEvent, channel: string, limit: { windowMs: number; max: number } = { windowMs: 10_000, max: 60 }, - ): void => { + ): BrowserWindow => { const win = BrowserWindow.fromWebContents(event.sender); const senderUrl = event.senderFrame?.url || event.sender.getURL(); if (!win || win.isDestroyed() || !isTrustedAppControlRendererUrl(senderUrl)) { @@ -1947,6 +1947,7 @@ export function registerIpc({ throw new Error("Built-in browser is only available to the ADE renderer."); } assertBuiltInBrowserRateLimit(event, channel, limit); + return win; }; const guardMacosVmIpc = ( @@ -6722,93 +6723,93 @@ export function registerIpc({ }); ipcMain.handle(IPC.builtInBrowserGetStatus, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserGetStatus, { windowMs: 10_000, max: 120 }); - return ensureBuiltInBrowser().getStatus(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserGetStatus, { windowMs: 10_000, max: 120 }); + return ensureBuiltInBrowser().getStatus(win); }); ipcMain.handle(IPC.builtInBrowserShowPanel, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserShowPanel, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().showPanel(parseBuiltInBrowserOpenPanelArgs(arg, IPC.builtInBrowserShowPanel)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserShowPanel, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().showPanel(parseBuiltInBrowserOpenPanelArgs(arg, IPC.builtInBrowserShowPanel), win); }); ipcMain.handle(IPC.builtInBrowserSetBounds, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserSetBounds, { windowMs: 10_000, max: 900 }); - return ensureBuiltInBrowser().setBounds(parseBuiltInBrowserBoundsArgs(arg, IPC.builtInBrowserSetBounds)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserSetBounds, { windowMs: 10_000, max: 900 }); + return ensureBuiltInBrowser().setBounds(parseBuiltInBrowserBoundsArgs(arg, IPC.builtInBrowserSetBounds), win); }); ipcMain.handle(IPC.builtInBrowserAttachWebview, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserAttachWebview, { windowMs: 10_000, max: 120 }); - return ensureBuiltInBrowser().attachWebview(parseBuiltInBrowserAttachWebviewArgs(arg, IPC.builtInBrowserAttachWebview)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserAttachWebview, { windowMs: 10_000, max: 120 }); + return ensureBuiltInBrowser().attachWebview(parseBuiltInBrowserAttachWebviewArgs(arg, IPC.builtInBrowserAttachWebview), win); }); ipcMain.handle(IPC.builtInBrowserNavigate, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserNavigate, { windowMs: 60_000, max: 40 }); - return ensureBuiltInBrowser().navigate(parseBuiltInBrowserNavigateArgs(arg, IPC.builtInBrowserNavigate)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserNavigate, { windowMs: 60_000, max: 40 }); + return ensureBuiltInBrowser().navigate(parseBuiltInBrowserNavigateArgs(arg, IPC.builtInBrowserNavigate), win); }); ipcMain.handle(IPC.builtInBrowserCreateTab, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserCreateTab, { windowMs: 60_000, max: 40 }); - return ensureBuiltInBrowser().createTab(parseBuiltInBrowserCreateTabArgs(arg, IPC.builtInBrowserCreateTab)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserCreateTab, { windowMs: 60_000, max: 40 }); + return ensureBuiltInBrowser().createTab(parseBuiltInBrowserCreateTabArgs(arg, IPC.builtInBrowserCreateTab), win); }); ipcMain.handle(IPC.builtInBrowserSwitchTab, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserSwitchTab, { windowMs: 10_000, max: 120 }); - return ensureBuiltInBrowser().switchTab(parseBuiltInBrowserTabArgs(arg, IPC.builtInBrowserSwitchTab)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserSwitchTab, { windowMs: 10_000, max: 120 }); + return ensureBuiltInBrowser().switchTab(parseBuiltInBrowserTabArgs(arg, IPC.builtInBrowserSwitchTab), win); }); ipcMain.handle(IPC.builtInBrowserCloseTab, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserCloseTab, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().closeTab(parseBuiltInBrowserTabArgs(arg, IPC.builtInBrowserCloseTab)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserCloseTab, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().closeTab(parseBuiltInBrowserTabArgs(arg, IPC.builtInBrowserCloseTab), win); }); ipcMain.handle(IPC.builtInBrowserReload, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserReload, { windowMs: 10_000, max: 60 }); - return ensureBuiltInBrowser().reload(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserReload, { windowMs: 10_000, max: 60 }); + return ensureBuiltInBrowser().reload(win); }); ipcMain.handle(IPC.builtInBrowserGoBack, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserGoBack, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().goBack(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserGoBack, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().goBack(win); }); ipcMain.handle(IPC.builtInBrowserGoForward, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserGoForward, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().goForward(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserGoForward, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().goForward(win); }); ipcMain.handle(IPC.builtInBrowserStop, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserStop, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().stop(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserStop, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().stop(win); }); ipcMain.handle(IPC.builtInBrowserStartInspect, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserStartInspect, { windowMs: 10_000, max: 40 }); - return ensureBuiltInBrowser().startInspect(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserStartInspect, { windowMs: 10_000, max: 40 }); + return ensureBuiltInBrowser().startInspect(win); }); ipcMain.handle(IPC.builtInBrowserStopInspect, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserStopInspect, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().stopInspect(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserStopInspect, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().stopInspect(win); }); ipcMain.handle(IPC.builtInBrowserCaptureScreenshot, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserCaptureScreenshot, { windowMs: 10_000, max: 30 }); - return ensureBuiltInBrowser().captureScreenshot(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserCaptureScreenshot, { windowMs: 10_000, max: 30 }); + return ensureBuiltInBrowser().captureScreenshot(win); }); ipcMain.handle(IPC.builtInBrowserSelectPoint, async (event, arg) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserSelectPoint, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().selectPoint(parseBuiltInBrowserSelectPointArgs(arg, IPC.builtInBrowserSelectPoint)); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserSelectPoint, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().selectPoint(parseBuiltInBrowserSelectPointArgs(arg, IPC.builtInBrowserSelectPoint), win); }); ipcMain.handle(IPC.builtInBrowserSelectCurrent, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserSelectCurrent, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().selectCurrent(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserSelectCurrent, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().selectCurrent(win); }); ipcMain.handle(IPC.builtInBrowserClearSelection, async (event) => { - guardBuiltInBrowserIpc(event, IPC.builtInBrowserClearSelection, { windowMs: 10_000, max: 80 }); - return ensureBuiltInBrowser().clearSelection(); + const win = guardBuiltInBrowserIpc(event, IPC.builtInBrowserClearSelection, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().clearSelection(win); }); ipcMain.handle(IPC.macosVmGetStatus, async (event, arg = {}): Promise => { diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.ts b/apps/desktop/src/main/services/macosVm/macosVmService.ts index 7acf3d76e..34c1ec6e4 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.ts @@ -901,7 +901,7 @@ export function createMacosVmService(args: CreateMacosVmServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); const storeDir = path.join(layout.cacheDir, "macos-vms"); const storePath = path.join(storeDir, MACOS_VM_STATE_FILE); - const adeHome = env.ADE_HOME?.trim() || process.env.ADE_HOME?.trim() || path.join(layout.cacheDir, "runtime-home"); + const adeHome = env.ADE_HOME?.trim() || path.join(layout.cacheDir, "runtime-home"); const globalLeasePath = path.join(adeHome, "cache", "macos-vms", MACOS_VM_GLOBAL_LEASE_FILE); const vncCredentialStorePath = path.join(layout.secretsDir, MACOS_VM_VNC_CREDENTIALS_FILE); const projectRoot = path.resolve(args.projectRoot); @@ -964,7 +964,7 @@ export function createMacosVmService(args: CreateMacosVmServiceArgs) { }); const leaseMatchesCurrentProjectLane = (lease: MacosVmGlobalLease, lane: LaneContext): boolean => ( - path.resolve(lease.projectRoot) === projectRoot && lease.laneId === lane.id + path.resolve(lease.projectRoot) === path.resolve(projectRoot) && lease.laneId === lane.id ); const reconcileGlobalLease = ( diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 51f7eab2a..d3cba2ebb 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3054,11 +3054,15 @@ export function createPtyService({ const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {}; const explicitNoColor = hasEnvKey(args.env ?? {}, "NO_COLOR") || hasEnvKey(laneRuntimeEnv, "NO_COLOR"); + const explicitForceColor = hasEnvKey(args.env ?? {}, "FORCE_COLOR") || hasEnvKey(laneRuntimeEnv, "FORCE_COLOR"); const baseLaunchEnv = { ...process.env, ...laneRuntimeEnv, ...(args.env ?? {}) }; + if (explicitNoColor && !explicitForceColor) { + delete baseLaunchEnv.FORCE_COLOR; + } const contextLaunchEnv = withAdeTerminalContextEnv(baseLaunchEnv, { projectRoot, laneId, diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 9b300d278..90ef10e57 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -129,6 +129,7 @@ describe("buildRemoteRuntimeEnvironmentPrefix", () => { expect(buildRemoteRuntimeEnvironmentPrefix({ archLabel: "linux-x64", nativeDepsReady: false, + layout: resolveRemoteRuntimeLayout({} as NodeJS.ProcessEnv), })).toBe('ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" '); }); @@ -136,6 +137,7 @@ describe("buildRemoteRuntimeEnvironmentPrefix", () => { expect(buildRemoteRuntimeEnvironmentPrefix({ archLabel: "darwin-arm64", nativeDepsReady: true, + layout: resolveRemoteRuntimeLayout({} as NodeJS.ProcessEnv), })).toContain('NODE_PATH="$HOME/.ade/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}"'); }); @@ -298,8 +300,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { const originalPackageChannel = process.env.ADE_PACKAGE_CHANNEL; beforeEach(() => { - if (originalPackageChannel === undefined) delete process.env.ADE_PACKAGE_CHANNEL; - else process.env.ADE_PACKAGE_CHANNEL = originalPackageChannel; + delete process.env.ADE_PACKAGE_CHANNEL; connectSshWithRouteMock.mockReset(); execSshMock.mockReset(); openSshRuntimeTransportMock.mockReset(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 01449f50d..ef1c17b0f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { motion } from "motion/react"; @@ -28,6 +28,8 @@ import { CopySimple, Brain, Image, + ImageSquare, + Sparkle, Code, Paperclip, } from "@phosphor-icons/react"; @@ -3648,6 +3650,233 @@ function extractSubagentMessageText( return ""; } +/** + * Returns the AgentChatEvent embedded in a subagent transcript entry, or null + * if the entry came from a Claude SDK session message (different shape). + * + * Codex/Cursor sessions store the full ADE event (with type/itemId/turnId/…) + * inside transcript.message so the drill-in can render typed cards. Claude SDK + * transcripts use the upstream session message shape and fall back to the + * simpler role+text rendering. + */ +function readSubagentEvent( + transcript: import("../../../shared/types").AgentChatSubagentTranscriptMessage, +): AgentChatEvent | null { + const message = transcript.message; + if (!message || typeof message !== "object" || Array.isArray(message)) return null; + const candidate = message as { type?: unknown }; + if (typeof candidate.type !== "string" || candidate.type.length === 0) return null; + // The codex transcript pipe stamps `message: event` directly. Anything with + // a string `type` is assumed to be an AgentChatEvent. The render switch + // below tolerates unknown types by skipping them. + return message as AgentChatEvent; +} + +function SubagentTimelineRow({ + icon, + tone, + children, +}: { + icon: ReactNode; + tone: "violet" | "amber" | "emerald" | "rose" | "cyan" | "fg"; + children: ReactNode; +}) { + const railClass = { + violet: "bg-[color:var(--color-accent,#A78BFA)]/55", + amber: "bg-amber-400/55", + emerald: "bg-emerald-400/55", + rose: "bg-rose-400/55", + cyan: "bg-cyan-400/55", + fg: "bg-fg/20", + }[tone]; + + return ( +
  • +
    + + + {icon} + +
    +
    {children}
    +
  • + ); +} + +function SubagentReasoningCard({ + event, +}: { + event: Extract; +}) { + const text = event.text.trim(); + if (!text) return null; + return ( +
    +
    + Reasoning +
    +
    + {text} +
    +
    + ); +} + +function SubagentTextCard({ + event, +}: { + event: Extract; +}) { + const text = (event.text ?? "").trim(); + if (!text) return null; + return ( +
    +
    + Agent +
    +
    + {text} +
    +
    + ); +} + +function SubagentWebSearchCard({ + event, +}: { + event: Extract; +}) { + const isActive = event.status === "running"; + const tone = event.status === "failed" ? "text-rose-200/85" : "text-fg/80"; + return ( +
    + + + {isActive ? "Searching" : event.status === "failed" ? "Search failed" : "Searched"} + + {event.query || "(no query)"} +
    + ); +} + +function SubagentSpawnedRow({ + event, +}: { + event: Extract; +}) { + const description = ( + event as { description?: unknown } + ).description as string | undefined; + const agentType = (event as { agentType?: unknown }).agentType as string | undefined; + const label = (description ?? agentType ?? "subagent").trim() || "subagent"; + return ( +
    + Spawned {label} +
    + ); +} + +function SubagentResultRow({ + event, +}: { + event: Extract; +}) { + const summary = ((event as { summary?: unknown }).summary as string | undefined)?.trim() + ?? ((event as { description?: unknown }).description as string | undefined)?.trim() + ?? ""; + return ( +
    +
    + Final result +
    + {summary ? ( +
    + {summary} +
    + ) : ( +
    No summary recorded.
    + )} +
    + ); +} + +function SubagentTimelineCard({ + event, +}: { + event: AgentChatEvent; +}) { + switch (event.type) { + case "reasoning": + return ( + } tone="violet"> + + + ); + case "text": + return ( + A} + tone="violet" + > + + + ); + case "plan": + return ( + } tone="violet"> + + + ); + case "command": + return ( + } tone={event.status === "failed" ? "rose" : "fg"}> + + + ); + case "file_change": + return ( + } tone="cyan"> + + + ); + case "web_search": + return ( + } tone="fg"> + + + ); + case "codex_image_generation": + return ( + } tone="violet"> + + + ); + case "subagent_started": + return ( + } tone="fg"> + + + ); + case "subagent_result": + return ( + } tone="emerald"> + + + ); + default: + // Skip noisy/internal events (activity, tokens, status, etc.). When in + // doubt show a tiny dim row so we know data was recorded but isn't yet + // typed — that prevents transcripts from looking falsely empty. + return null; + } +} + function SubagentTranscriptView({ snapshotName, messages, @@ -3678,7 +3907,30 @@ function SubagentTranscriptView({ ); } - if (messages.length === 0) { + // Partition into rich (typed-card) entries and legacy (role+text) entries. + // Codex/Cursor transcripts ship the full AgentChatEvent in `message`, so + // they render through the typed switch. Claude SDK transcripts have a + // different `message` shape and fall back to the role+text layout. + const richEntries: Array<{ + key: string; + event: AgentChatEvent; + }> = []; + const legacyEntries: Array<{ + key: string; + message: import("../../../shared/types").AgentChatSubagentTranscriptMessage; + }> = []; + for (let i = 0; i < messages.length; i += 1) { + const m = messages[i]; + const key = m.uuid ?? `idx-${i}`; + const event = readSubagentEvent(m); + if (event) { + richEntries.push({ key, event }); + } else { + legacyEntries.push({ key, message: m }); + } + } + + if (richEntries.length === 0 && legacyEntries.length === 0) { return (

    @@ -3695,46 +3947,50 @@ function SubagentTranscriptView({ className, )} > -

      - {messages.map((message, index) => { - const text = extractSubagentMessageText(message); - const role: string = message.type; - const isUser = role === "user"; - const isSystem = role === "system"; - const roleLabel = isUser ? "you" : isSystem ? "system" : "agent"; - const accentClass = isUser - ? "text-fg/65" - : isSystem - ? "text-fg/40" - : "text-[color:var(--color-accent-bright,#C4B5FD)]"; + {richEntries.length ? ( +
        + {richEntries.map((entry) => ( + + ))} +
      + ) : null} + {legacyEntries.length ? ( +
        + {legacyEntries.map((entry, index) => { + const text = extractSubagentMessageText(entry.message); + const role: string = entry.message.type; + const isUser = role === "user"; + const isSystem = role === "system"; + const roleLabel = isUser ? "you" : isSystem ? "system" : "agent"; + const accentClass = isUser + ? "text-fg/65" + : isSystem + ? "text-fg/40" + : "text-[color:var(--color-accent-bright,#C4B5FD)]"; - return ( -
      1. -
        - +
        + + {roleLabel} + +
        +
        - {roleLabel} - -
        -
        - {text || No content recorded.} -
        -
      2. - ); - })} -
      + {text || No content recorded.} +
    + + ); + })} + + ) : null} ); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 042512f6f..53827ce8e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -103,7 +103,6 @@ import { ChatAppControlPanel } from "./ChatAppControlPanel"; import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; import { ChatTasksPanel } from "./ChatTasksPanel"; import { ChatFileChangesPanel } from "./ChatFileChangesPanel"; -import { CodexGoalBanner } from "./codex/CodexGoalBanner"; import { CodexOpenInCliButton } from "./codex/CodexOpenInCliButton"; import { RewindFilesConfirmDialog, type RewindFilesConfirmDialogState } from "./RewindFilesConfirmDialog"; import { buildRewindPreviewFiles, deriveRewindDiffSummaries } from "./rewindFilesPreview"; @@ -133,6 +132,7 @@ import { type WorkPtyLaunchResult, } from "../terminals/cliLaunch"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; +import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; @@ -2788,8 +2788,12 @@ export function AgentChatPane({ const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); // The pane is runtime-agnostic — Codex emits subagent_started/progress/result // events for delegation and collabToolCall items (spawn_agent, etc.) just - // like Claude. Gate on whether we actually have snapshots to display. - const selectedSubagentPaneAvailable = selectedSubagentSnapshots.length > 0; + // like Claude. Gate on whether we have anything to display: snapshots OR an + // active Codex thread goal (so the pane hosts the goal card even before any + // subagents are spawned). + const selectedSubagentPaneAvailable = + selectedSubagentSnapshots.length > 0 + || (selectedSession?.provider === "codex" && Boolean(selectedCodexGoal?.objective)); // Latest snapshot for the currently drilled-in subagent — keeps the // breadcrumb status in sync as the agent transitions running → completed. const subagentViewSnapshot = useMemo(() => { @@ -7218,6 +7222,23 @@ export function AgentChatPane({ }); }} selectedTaskId={subagentView?.taskId ?? null} + goal={selectedSession?.provider === "codex" ? selectedCodexGoal : null} + onEditGoal={ + selectedSession?.provider === "codex" && selectedSessionId + ? (next) => { + const objective = next.replace(/\s*[\r\n]+\s*/g, " ").trim(); + if (!objective) return; + void sendCodexControlMessage(selectedSessionId, `/goal set ${objective}`); + } + : undefined + } + onClearGoal={ + selectedSession?.provider === "codex" && selectedSessionId + ? () => { + void sendCodexControlMessage(selectedSessionId, "/goal clear"); + } + : undefined + } /> ) : (
    @@ -7517,30 +7538,8 @@ export function AgentChatPane({
    ); - const shellHeader = ( -
    - {/* Single-row header: title + git toolbar + actions */} -
    -
    - - {resolvedTitle} - - {showWorkspaceChrome && laneId ? ( - navigate(openLaneInLanesTabPath(laneId))} - aria-label={`Open ${chatHeaderLaneName} in Lanes tab`} - /> - ) : null} - {showClaudeCacheTimer ? ( - - ) : null} -
    - - {showWorkspaceChrome && laneId ? : null} - -
    + const chatHeaderTrailingActions = ( + <> {laneToolsVisible && iosSimulatorAvailable ? ( ) : null} -
    -
    + + ); + const shellHeader = ( +
    + navigate(openLaneInLanesTabPath(laneId)) : undefined} + showCacheBadge={showClaudeCacheTimer} + cacheIdleSinceAt={selectedSession?.idleSinceAt ?? null} + showGitToolbar={showWorkspaceChrome} + trailingActions={chatHeaderTrailingActions} + className="space-y-0 p-0" + /> {!lockSessionId && !hideSessionTabs ? (
    @@ -8618,19 +8632,10 @@ export function AgentChatPane({ Live view of Cursor Cloud agent. Replies run in cloud.
    ) : null} - {selectedSession?.provider === "codex" && selectedCodexGoal?.objective && selectedSessionId ? ( - { - const objective = next.replace(/\s*[\r\n]+\s*/g, " ").trim(); - if (!objective) return; - void sendCodexControlMessage(selectedSessionId, `/goal set ${objective}`); - }} - onClear={() => { - void sendCodexControlMessage(selectedSessionId, "/goal clear"); - }} - /> - ) : null} + {/* Codex thread goal is rendered in the Agents tab via + ChatSubagentsPanel; the in-chat banner was removed so + the chat header stays clean and goal context lives next + to subagents + progress where it belongs. */} {subagentView ? (
    ) : ( -
    +
    @@ -1133,7 +1133,7 @@ export function ChatAppControlPanel({ type="button" disabled={Boolean(busy) || controlsDisabled} onClick={focusWindow} - className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/[0.07] bg-white/[0.03] px-2 text-[10px] font-medium text-fg/65 transition-colors hover:bg-white/[0.06] hover:text-fg/85 disabled:cursor-not-allowed disabled:opacity-45" + className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 text-[10px] font-medium text-fg/65 transition-colors hover:bg-white/[0.06] hover:text-fg/85 disabled:cursor-not-allowed disabled:opacity-45" title="Show the controlled app window" aria-label="Show controlled app window" > @@ -1144,7 +1144,7 @@ export function ChatAppControlPanel({ type="button" disabled={Boolean(busy) || controlsDisabled} onClick={minimizeWindow} - className="inline-flex h-7 shrink-0 items-center justify-center rounded-md border border-white/[0.07] bg-white/[0.03] px-2 text-fg/65 transition-colors hover:bg-white/[0.06] hover:text-fg/85 disabled:cursor-not-allowed disabled:opacity-45" + className="inline-flex h-7 shrink-0 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.03] px-2 text-fg/65 transition-colors hover:bg-white/[0.06] hover:text-fg/85 disabled:cursor-not-allowed disabled:opacity-45" title="Minimize the controlled app window" aria-label="Minimize controlled app window" > @@ -1170,8 +1170,8 @@ export function ChatAppControlPanel({ {/* Compact CDP attach row */} {!hasActiveSession ? ( -
    - Or attach +
    + Or attach setCdpPort(event.target.value)} @@ -1179,7 +1179,7 @@ export function ChatAppControlPanel({ aria-label="CDP port" inputMode="numeric" disabled={controlsDisabled} - className="w-[100px] shrink-0 rounded-md border border-white/[0.07] bg-black/20 px-2 py-1.5 text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/40 focus:border-sky-300/30" + className="w-[80px] shrink-0 rounded border border-white/[0.08] bg-black/20 px-1.5 py-1 text-[10px] text-fg/80 outline-none placeholder:text-muted-fg/40 focus:border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)]" onKeyDown={(event) => { if (event.key === "Enter" && cdpPort.trim()) void connectPort(); }} @@ -1188,7 +1188,7 @@ export function ChatAppControlPanel({ type="button" disabled={Boolean(busy) || !cdpPort.trim() || controlsDisabled} onClick={connectPort} - className="inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md border border-white/[0.07] bg-white/[0.03] px-2 text-[10px] font-medium text-fg/72 transition-colors hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-45" + className="inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 text-[10px] font-medium text-fg/72 transition-colors hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-45" title="Connect to a running Electron app via CDP" > {busy === "connect" ? : } @@ -1198,7 +1198,7 @@ export function ChatAppControlPanel({
    setMode(nextMode)} className={cn( - "h-6 rounded px-2 text-[10px] font-medium transition-colors disabled:cursor-not-allowed", + "h-6 rounded-[3px] px-2 text-[10px] font-medium transition-colors disabled:cursor-not-allowed", mode === nextMode - ? "bg-white/[0.10] text-sky-50 shadow-sm" + ? "bg-[color-mix(in_srgb,var(--color-accent)_18%,transparent)] text-fg/90 shadow-sm" : "text-muted-fg/60 hover:bg-white/[0.06] hover:text-fg/80", )} > @@ -1341,7 +1341,7 @@ export function ChatAppControlPanel({ {snapshot?.url ? (
    {snapshot.title ?? snapshot.url} @@ -1478,8 +1478,8 @@ export function ChatAppControlPanel({
    {/* Selection details + actions */} -
    -
    +
    +
    {mode === "control" ? ( screenshotBlank ? (
    @@ -1499,7 +1499,7 @@ export function ChatAppControlPanel({ {elementLabel(focusElement)} {elementSubLabel(focusElement) ? ( - + {elementSubLabel(focusElement)} ) : null} @@ -1550,14 +1550,14 @@ export function ChatAppControlPanel({
    {mode === "control" ? ( -
    - +
    + setTypeText(event.target.value)} placeholder="Type into focused element" aria-label="Text to type into the focused app element" - className="h-8 min-w-0 flex-1 bg-transparent text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/40" + className="h-7 min-w-0 flex-1 bg-transparent text-[10px] text-fg/80 outline-none placeholder:text-muted-fg/40" onKeyDown={(event) => { if (event.key === "Enter") void typeIntoApp(); }} @@ -1566,7 +1566,7 @@ export function ChatAppControlPanel({ type="button" disabled={Boolean(busy) || !canType} onClick={typeIntoApp} - className="inline-flex h-8 shrink-0 items-center justify-center rounded-r-md border-l border-white/[0.06] px-2 text-[11px] font-medium text-fg/75 transition-colors hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-45" + className="inline-flex h-7 shrink-0 items-center justify-center rounded-r border-l border-white/[0.06] px-1.5 text-[10px] font-medium text-fg/75 transition-colors hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-45" title="Send keystrokes to the focused element" aria-label="Type into focused app element" > @@ -1580,7 +1580,7 @@ export function ChatAppControlPanel({ "inline-flex h-8 shrink-0 items-center rounded-md border px-2 text-[10px] font-medium", attachmentAck ? "border-emerald-300/25 bg-emerald-500/10 text-emerald-100/85" - : "border-white/[0.07] bg-white/[0.03] text-muted-fg/60", + : "border-white/[0.08] bg-white/[0.03] text-muted-fg/60", )} > {attachmentAck ? `Inserted ${attachmentAck} context` : "Inspect mode inserts clicked element context"} @@ -1591,7 +1591,7 @@ export function ChatAppControlPanel({ onClick={() => { if (selectedPoint) void runBusy("select", () => attachSelection(selectedPoint.x, selectedPoint.y)); }} - className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/[0.07] bg-white/[0.03] px-2.5 text-[11px] font-medium text-fg/75 transition-colors hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-45" + className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/[0.08] bg-white/[0.03] px-2.5 text-[11px] font-medium text-fg/75 transition-colors hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-45" title="Attach the selected element again" > {busy === "select" ? : } diff --git a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.test.tsx index f7327005d..099f02f6e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.test.tsx @@ -231,8 +231,9 @@ describe("ChatBuiltInBrowserPanel", () => { render(); - expect(await screen.findByText("Submit")).toBeTruthy(); - fireEvent.click(screen.getByTitle("Insert the selected browser element as context")); + const attachButton = await screen.findByTitle("Insert the selected browser element as context"); + expect(attachButton.textContent).toContain("Attach"); + fireEvent.click(attachButton); await waitFor(() => { expect(api.selectCurrent).toHaveBeenCalled(); diff --git a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx index abf43becb..8cbdd1400 100644 --- a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx @@ -1484,8 +1484,8 @@ export function ChatBuiltInBrowserPanel({ return (
    -
    -
    +
    +
    {browserTabs.map((tab) => { const active = tab.id === activeTabId; const label = tab.title ?? tab.url ?? "New tab"; @@ -1493,10 +1493,10 @@ export function ChatBuiltInBrowserPanel({
    @@ -1508,19 +1508,23 @@ export function ChatBuiltInBrowserPanel({ className="inline-flex min-w-0 flex-1 items-center gap-1.5 text-left" aria-current={active ? "page" : undefined} > - - {label} + {tab.isLoading ? ( + + ) : ( + + )} + {label}
    ); @@ -1529,31 +1533,21 @@ export function ChatBuiltInBrowserPanel({ type="button" disabled={Boolean(busy) || !apiAvailable} onClick={handleNewTab} - className="inline-flex h-7 w-8 shrink-0 items-center justify-center rounded-md border border-white/[0.07] bg-white/[0.035] text-fg/72 transition-colors hover:bg-white/[0.07] hover:text-fg/85 disabled:cursor-not-allowed disabled:opacity-45" + className="mb-px ml-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-fg/50 transition-colors hover:bg-white/[0.06] hover:text-fg/75 disabled:cursor-not-allowed disabled:opacity-45" title="New tab" aria-label="New tab" > - {busy === "new-tab" ? : } + {busy === "new-tab" ? : } -
    - - {statusInfo.label} - - {sessionLabel ? ( - - {sessionLabel} - - ) : null} -
    -
    -
    +
    +
    - ) : null} - {onInsertDraft ? ( - - ) : null} -
    ); diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx index 6e47baac9..27e7f342b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx @@ -3052,37 +3052,37 @@ export function ChatIosSimulatorPanel({ }, [shutdownSimulator]); return ( -
    -
    -
    -
    +
    +
    +
    +
    -
    - {activeSurface === "simulator" ? "Simulator mode" : "Preview mode"} +
    + {activeSurface === "simulator" ? "Simulator" : "Preview"}
    {activeSurface === "simulator" && hasActiveSession && !contextControlsBlocked ? (
    @@ -3092,7 +3092,7 @@ export function ChatIosSimulatorPanel({ "inline-flex h-7 items-center gap-1.5 rounded-md border px-2 font-sans text-[10px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-45", simulatorWindowModeEnabled ? "border-cyan-300/25 bg-cyan-400/12 text-cyan-50/85 hover:bg-cyan-400/18" - : "border-white/[0.07] bg-white/[0.03] text-muted-fg/62 hover:text-fg/86", + : "border-white/[0.08] bg-white/[0.03] text-muted-fg/62 hover:text-fg/86", )} onClick={toggleSimulatorWindowMode} disabled={busy || launchBusy} @@ -3120,9 +3120,9 @@ export function ChatIosSimulatorPanel({ {activeSurface === "simulator" ? ( <> -
    +
    ) : mode === "preview" ? (
    -
    +
    {snapshotRefreshing ? (
    -
    +
    Loading inspector...
    @@ -3886,11 +3886,11 @@ export function ChatIosSimulatorPanel({ )}
    - {!mediaExpanded ?
    + {!mediaExpanded ?
    {mode === "interact" && !simulatorMutationBlocked && !showSetupChecklist ? ( -
    +
    setTypedText(event.currentTarget.value)} onKeyDown={(event) => { @@ -3900,7 +3900,7 @@ export function ChatIosSimulatorPanel({ />
    ) : null} {simulatorWindowWarning ? ( -
    - +
    + {simulatorWindowWarning}
    ) : null} {footerStatus ? ( -
    +
    {footerStatus}
    ) : null} {mode === "interact" && !controlAvailable && !showSetupChecklist && !simulatorMutationBlocked ? ( -
    +
    Install a supported full Xcode for native touch input, or idb + idb_companion for fallback tap, drag, and text.
    ) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 122071397..c44db3e03 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -13,8 +13,9 @@ import { cn } from "../ui/cn"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { derivePlan } from "./chatExecutionSummary"; import type { ChatInfoPlanStep } from "../../../shared/chatSubagents"; -import type { AgentChatEventEnvelope } from "../../../shared/types"; +import type { AgentChatEventEnvelope, CodexThreadGoal } from "../../../shared/types"; import { BottomDrawerSection } from "./BottomDrawerSection"; +import { CodexGoalCard } from "./codex/CodexGoalCard"; /* ── Formatting helpers ── */ @@ -279,6 +280,9 @@ export function ChatSubagentsPanel({ className, variant = "drawer", onClose, + goal, + onEditGoal, + onClearGoal, }: { snapshots: ChatSubagentSnapshot[]; events: AgentChatEventEnvelope[]; @@ -288,6 +292,9 @@ export function ChatSubagentsPanel({ className?: string; variant?: "drawer" | "pane"; onClose?: () => void; + goal?: CodexThreadGoal | null; + onEditGoal?: (nextObjective: string) => void; + onClearGoal?: () => void; }) { const [expanded, setExpanded] = useState(false); @@ -346,10 +353,20 @@ export function ChatSubagentsPanel({ const planTotal = plan?.steps.length ?? 0; const planPercent = planTotal > 0 ? Math.round((planComplete / planTotal) * 100) : 0; - const hasAnything = Boolean(plan) || foreground.length > 0 || background.length > 0; + const hasGoal = Boolean(goal?.objective?.trim()); + const hasAnything = hasGoal || Boolean(plan) || foreground.length > 0 || background.length > 0; const body = (
    + {/* ── Goal (Codex thread goal) ─────────────────────────────── */} + {hasGoal && goal ? ( + + ) : null} + {/* ── Progress ─────────────────────────────────────────────── */} {plan && plan.steps.length > 0 ? (
    diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.test.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.test.tsx new file mode 100644 index 000000000..319d329c3 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.test.tsx @@ -0,0 +1,117 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { CodexGoalCard } from "./CodexGoalCard"; + +afterEach(() => cleanup()); + +describe("CodexGoalCard", () => { + it("renders nothing when objective is blank", () => { + const { container } = render( + undefined} + onClear={() => undefined} + />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders objective, status label, and tokens used when no budget is set", () => { + render( + , + ); + expect(screen.getByText("Refactor auth")).toBeTruthy(); + expect(screen.getByText(/^active$/i)).toBeTruthy(); + expect(screen.getByText(/12\.3k/)).toBeTruthy(); + }); + + it("renders a filled progress bar and tokens used/budget when budget is set", () => { + render( + , + ); + const progressbar = screen.getByRole("progressbar"); + expect(progressbar).toBeTruthy(); + expect(progressbar.getAttribute("aria-valuenow")).toBe("250000"); + expect(progressbar.getAttribute("aria-valuemax")).toBe("1000000"); + expect(screen.getByText(/250\.0k\s*\/\s*1\.0M/)).toBeTruthy(); + }); + + it("submits an edited objective via onEdit when the user presses Enter", () => { + const onEdit = vi.fn(); + render( + undefined} + />, + ); + + fireEvent.click(screen.getByText("Refactor auth middleware")); + const textarea = screen.getByLabelText("Edit goal objective") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "Refactor auth for compliance" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect(onEdit).toHaveBeenCalledWith("Refactor auth for compliance"); + }); + + it("does not invoke onEdit when Escape cancels the edit", () => { + const onEdit = vi.fn(); + render( + undefined} + />, + ); + + fireEvent.click(screen.getByText("Refactor auth")); + const textarea = screen.getByLabelText("Edit goal objective"); + fireEvent.change(textarea, { target: { value: "Discarded change" } }); + fireEvent.keyDown(textarea, { key: "Escape" }); + + expect(onEdit).not.toHaveBeenCalled(); + }); + + it("invokes onClear when the clear button is pressed", () => { + const onClear = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText("Clear goal")); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("disables editing when onEdit is not provided (read-only card)", () => { + render( + , + ); + expect(screen.queryByLabelText("Edit goal")).toBeNull(); + const button = screen.getByText("Read-only goal").closest("button"); + expect(button?.hasAttribute("disabled")).toBe(true); + }); + + it("uses 'budget hit' label for budget_limited status", () => { + render( + , + ); + expect(screen.getByText(/budget hit/i)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx new file mode 100644 index 000000000..c48f13457 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx @@ -0,0 +1,257 @@ +import { useEffect, useRef, useState } from "react"; +import { PencilSimple, Target, X } from "@phosphor-icons/react"; +import type { CodexThreadGoal } from "../../../../shared/types"; +import { cn } from "../../ui/cn"; + +const AMBER = "#F59E0B"; + +type CodexGoalCardProps = { + goal: CodexThreadGoal; + onEdit?: (nextObjective: string) => void; + onClear?: () => void; +}; + +function formatTokens(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "0"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + +function formatElapsed(seconds: number | null | undefined): string | null { + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return null; + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`; +} + +function statusTone( + status: CodexThreadGoal["status"], +): { pill: string; rail: string; dot: string; label: string } { + switch (status) { + case "complete": + return { + pill: "bg-emerald-500/12 text-emerald-200/90 ring-1 ring-inset ring-emerald-400/30", + rail: "bg-emerald-400/55", + dot: "bg-emerald-300/85", + label: "complete", + }; + case "paused": + return { + pill: "bg-fg/8 text-fg/65 ring-1 ring-inset ring-fg/20", + rail: "bg-fg/30", + dot: "bg-fg/50", + label: "paused", + }; + case "cancelled": + return { + pill: "bg-fg/8 text-fg/45 ring-1 ring-inset ring-fg/15", + rail: "bg-fg/20", + dot: "bg-fg/40", + label: "cancelled", + }; + case "budget_limited": + return { + pill: "bg-amber-500/15 text-amber-100 ring-1 ring-inset ring-amber-400/40", + rail: "bg-amber-400/70", + dot: "bg-amber-300/95", + label: "budget hit", + }; + case "active": + default: + return { + pill: "bg-amber-500/12 text-amber-100 ring-1 ring-inset ring-amber-400/30", + rail: "bg-amber-400/55", + dot: "bg-amber-300/85", + label: "active", + }; + } +} + +export function CodexGoalCard({ goal, onEdit, onClear }: CodexGoalCardProps) { + const objective = (goal.objective ?? "").trim(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(objective); + const textareaRef = useRef(null); + + useEffect(() => { + if (!editing) setDraft(objective); + }, [editing, objective]); + + useEffect(() => { + if (editing && textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.select(); + } + }, [editing]); + + if (!objective) return null; + + const tokensUsed = Math.max(0, goal.tokensUsed ?? 0); + const tokenBudget = typeof goal.tokenBudget === "number" && goal.tokenBudget > 0 + ? goal.tokenBudget + : null; + const tokenPercent = tokenBudget ? Math.min(100, Math.round((tokensUsed / tokenBudget) * 100)) : null; + const elapsed = formatElapsed(goal.timeUsedSeconds); + const tone = statusTone(goal.status ?? "active"); + + const submitEdit = () => { + const next = draft.replace(/\s*[\r\n]+\s*/g, " ").trim(); + setEditing(false); + if (!next || next === objective) return; + onEdit?.(next); + }; + + const cancelEdit = () => { + setEditing(false); + setDraft(objective); + }; + + return ( +
    +
    + + +
    + + + Goal + + + + {tone.label} + +
    + + {editing ? ( +