diff --git a/.agents/skills/ade-web/SKILL.md b/.agents/skills/ade-web/SKILL.md new file mode 100644 index 000000000..706617efd --- /dev/null +++ b/.agents/skills/ade-web/SKILL.md @@ -0,0 +1,127 @@ +--- +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 start or manage the ADE socket directly.** The Vite-only preview uses + `browserMock.ts` to stub `window.ade` — it does not need a runtime connection. + The `--socket` flag on `ade actions run` above is fine; it connects to the + running desktop instance rather than managing the socket itself. +- **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..7c79ac5c0 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,276 @@ 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, + itemIndex: number, + ): AgentChatSubagentTranscriptMessage[] => { + const itemId = typeof item.id === "string" && item.id.length > 0 ? item.id : `no-id:${turnId ?? "?"}:${itemIndex}`; + 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 (let idx = 0; idx < items.length; idx++) { + const item = items[idx]; + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + collected.push(...codexThreadItemToTranscriptMessages(item as Record, threadId, turnId, idx)); + } + } + cursor = typeof response?.nextCursor === "string" ? response.nextCursor : null; + pages += 1; + } while (cursor && pages < MAX_PAGES); + if (cursor && pages >= MAX_PAGES) { + logger.warn("agent_chat.codex_transcript_truncated", { threadId, pages: MAX_PAGES, itemCount: collected.length }); + } + } 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 +22917,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 +22995,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..629cc9e7f 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,241 @@ 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() + ?? ""; + const status = (event as { status?: unknown }).status as string | undefined; + const failed = status === "failed" || status === "error"; + const stopped = status === "stopped" || status === "cancelled"; + const borderClass = failed ? "border-red-400/15" : stopped ? "border-amber-400/15" : "border-emerald-400/15"; + const bgClass = failed ? "bg-red-500/[0.04]" : stopped ? "bg-amber-500/[0.04]" : "bg-emerald-500/[0.04]"; + const labelColor = failed ? "text-red-200/75" : stopped ? "text-amber-200/75" : "text-emerald-200/75"; + const textColor = failed ? "text-red-50/90" : stopped ? "text-amber-50/90" : "text-emerald-50/90"; + const label = failed ? "Failed" : stopped ? "Stopped" : "Final result"; + return ( +
    +
    + {label} +
    + {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 +3915,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 +3955,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 ? ( +