diff --git a/scripts/build-runtime-tarball.mjs b/scripts/build-runtime-tarball.mjs index 2203941b..8f01a26f 100644 --- a/scripts/build-runtime-tarball.mjs +++ b/scripts/build-runtime-tarball.mjs @@ -224,15 +224,15 @@ async function stageParcelWatcher(outRoot) { } // Stage @parcel/watcher's JS dependency closure. wrapper.js requires // picomatch + is-glob (→ is-extglob); index.js requires detect-libc on linux. - // Resolve EACH from @parcel/watcher's own resolution root rather than assuming - // a fixed location: npm may nest a dep under @parcel/watcher/node_modules (a - // version-pin conflict) OR hoist it to the top-level node_modules (no conflict) - // — and which one happens varies by the full install tree. A previous version - // copied the nested dir if present, else a hardcoded list that OMITTED - // picomatch; when npm hoisted picomatch the nested dir was absent AND it wasn't - // in the list, so it shipped missing and the daemon crashed at startup with - // `Cannot find module 'picomatch'` (require'd by wrapper.js). createRequire - // finds the exact copy node would load, at whatever depth, every time. + // Resolve EACH from @parcel/watcher's own resolution root so we copy the exact + // installed version, but always stage it at the tarball's top-level + // node_modules. `require()` from @parcel/watcher resolves that location. + // + // Do not derive a destination with path.relative(repoRoot, pkgDir): in a git + // worktree node_modules is commonly a symlink to the primary checkout, and + // createRequire resolves the REAL path. Relativizing that path contains `..` + // segments and silently copies outside stageDir, producing a tarball that + // crashes with `Cannot find module 'picomatch'`. const requireFrom = createRequire(path.join(src, 'package.json')) for (const dep of ['picomatch', 'is-glob', 'is-extglob', 'detect-libc']) { let pkgDir @@ -243,17 +243,18 @@ async function stageParcelWatcher(outRoot) { if (dep === 'detect-libc') continue throw new Error(`@parcel/watcher dependency "${dep}" not resolvable from ${src} — run \`npm install\` first`) } - // Stage into the SAME relative location node resolved it from (nested under - // @parcel/watcher or hoisted to the top level) so resolution matches at runtime. - const rel = path.relative(repoRoot, pkgDir) - cpSync(pkgDir, path.join(outRoot, rel), { recursive: true, dereference: true }) + cpSync(pkgDir, path.join(nm, dep), { recursive: true, dereference: true }) } // Fail loudly if the staged tree can't resolve wrapper.js's requires — the exact // crash that shipped before. Mirrors the watcher.node assert below. const stagedRequire = createRequire(path.join(dest, 'wrapper.js')) for (const dep of ['picomatch', 'is-glob']) { try { - stagedRequire.resolve(dep) + const resolved = stagedRequire.resolve(dep) + const stagedModules = path.resolve(nm) + path.sep + if (!path.resolve(resolved).startsWith(stagedModules)) { + throw new Error(`resolved outside stage: ${resolved}`) + } } catch { throw new Error( `staged @parcel/watcher cannot resolve "${dep}" from ${path.join(dest, 'wrapper.js')}. ` + diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts new file mode 100644 index 00000000..0fb244e2 --- /dev/null +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import registerOrchestrator from "./index" + +const TOOL_NAMES = [ + "create_coding_agent", + "send_to_coding_agent", + "wait_for_coding_agents", + "inspect_coding_agent", + "stop_coding_agent", +] + +function makeApi() { + const tools = new Map() + const commands = new Map() + const handlers = new Map Promise>() + let activeTools = ["read", "bash"] + let setActiveToolsCalls = 0 + const pi = { + registerTool: (tool: any) => { + tools.set(tool.name, tool) + activeTools = [...activeTools, tool.name] + }, + registerCommand: (name: string, command: any) => commands.set(name, command), + on: (event: string, handler: (value: any) => Promise) => + handlers.set(event, handler), + getActiveTools: () => [...activeTools], + setActiveTools: (names: string[]) => { + setActiveToolsCalls += 1 + activeTools = [...names] + }, + } + registerOrchestrator(pi as any) + return { + tools, + commands, + handlers, + getActiveTools: () => [...activeTools], + getSetActiveToolsCalls: () => setActiveToolsCalls, + } +} + +function registeredTools() { + return makeApi().tools +} + +beforeEach(() => { + process.env.CATE_API = "http://127.0.0.1:1234" + process.env.CATE_TOKEN = "supervisor-token" + process.env.CATE_CODING_AGENT_IDS = JSON.stringify(["codex", "pi"]) + vi.unstubAllGlobals() +}) + +describe("cate-orchestrator", () => { + it("registers the complete worker lifecycle surface", () => { + const tools = registeredTools() + expect([...tools.keys()]).toEqual(TOOL_NAMES) + expect(tools.get("wait_for_coding_agents").parameters.properties.timeoutSeconds) + .toMatchObject({ minimum: 15, maximum: 120, default: 60 }) + expect(tools.get("create_coding_agent").parameters.properties.agentId.anyOf) + .toEqual([{ const: "codex", type: "string" }, { const: "pi", type: "string" }]) + expect(tools.get("wait_for_coding_agents").parameters.required).toContain("runIds") + }) + + it("keeps orchestration tools inactive until orchestration mode is enabled", async () => { + const api = makeApi() + const setStatus = vi.fn() + const ctx = { ui: { setStatus } } + + expect(api.getSetActiveToolsCalls()).toBe(0) + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) + await api.handlers.get("session_start")!({}) + expect(api.getActiveTools()).toEqual(["read", "bash"]) + expect(api.getSetActiveToolsCalls()).toBe(1) + expect(await api.handlers.get("before_agent_start")!({ systemPrompt: "base" })) + .toBeUndefined() + expect(await api.handlers.get("tool_call")!({ toolName: "create_coding_agent" })) + .toEqual({ + block: true, + reason: "Coding-agent orchestration mode is not active.", + }) + + await api.commands.get("orchestrate").handler("", ctx) + + expect(setStatus).toHaveBeenCalledWith("orchestrator-mode", "Orchestration mode") + // The command only flips mode state. Tool changes happen at the next real + // prompt so selecting the mode cannot itself start the agent loop. + expect(api.getActiveTools()).toEqual(["read", "bash"]) + const prompt = await api.handlers.get("before_agent_start")!({ systemPrompt: "base" }) + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) + expect(prompt.systemPrompt).toContain("Orchestration mode is ACTIVE") + expect(prompt.systemPrompt).toContain("Act as the mission lead") + expect(await api.handlers.get("tool_call")!({ toolName: "create_coding_agent" })) + .toBeUndefined() + + await api.commands.get("orchestrate").handler("", ctx) + + expect(setStatus).toHaveBeenLastCalledWith("orchestrator-mode", undefined) + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) + expect(await api.handlers.get("before_agent_start")!({ systemPrompt: "base" })) + .toBeUndefined() + expect(api.getActiveTools()).toEqual(["read", "bash"]) + }) + + it("creates workers without another confirmation after orchestration mode is selected", async () => { + const fetch = vi.fn(async (_url: string, init: RequestInit) => ({ + ok: true, + status: 200, + json: async () => ({ result: { id: "run-1", panelId: "panel-1" } }), + init, + })) + vi.stubGlobal("fetch", fetch) + const tool = registeredTools().get("create_coding_agent") + + await tool.execute("call-1", { agentId: "codex", prompt: "Implement it" }) + await tool.execute("call-2", { agentId: "codex", prompt: "Test it" }) + + expect(fetch).toHaveBeenCalledTimes(2) + const [, init] = fetch.mock.calls[0] + expect(init.headers).toMatchObject({ Authorization: "Bearer supervisor-token" }) + expect(JSON.parse(String(init.body))).toEqual({ + method: "cate.codingAgent.create", + args: { agentId: "codex", prompt: "Implement it" }, + }) + }) + + it("teaches the supervisor to switch CLIs for CLI-specific failures", () => { + const guidelines = registeredTools().get("create_coding_agent").promptGuidelines + expect(guidelines.join("\n")).toContain("failureReason") + expect(guidelines.join("\n")).toContain("different registered agentId") + }) +}) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts new file mode 100644 index 00000000..e5ade843 --- /dev/null +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -0,0 +1,213 @@ +// Native coding-agent orchestration tools for Cate Agent. These call a +// dedicated, panel-bound CATE_API endpoint. Ordinary terminals and the workers +// created by these tools receive a different token without this capability. + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +const STATUS_KEY = "orchestrator-mode" +const TOOL_NAMES = [ + "create_coding_agent", + "send_to_coding_agent", + "wait_for_coding_agents", + "inspect_coding_agent", + "stop_coding_agent", +] as const +const TOOL_NAME_SET: ReadonlySet = new Set(TOOL_NAMES) + +const ORCHESTRATOR_PROMPT = ` + +Orchestration mode is ACTIVE. Act as the mission lead for the user's coding +task. Create coding agents only for bounded work that benefits from delegation, +give each one a self-contained prompt and isolated worktree when appropriate, +then supervise, steer, inspect, and verify their results. You retain ownership +of architecture, integration, and the final answer. + +`.trim() + +function agentIdSchema() { + let ids: string[] = [] + try { + const parsed = JSON.parse(process.env.CATE_CODING_AGENT_IDS || "[]") + if (Array.isArray(parsed)) { + ids = [...new Set(parsed.filter((id): id is string => typeof id === "string" && id.length > 0))] + } + } catch { + // Renderer validation remains authoritative if a hand-run extension has a + // malformed environment; Cate-managed sessions always provide this value. + } + return ids.length > 0 + ? Type.Union(ids.map((id) => Type.Literal(id)) as [ReturnType, ...ReturnType[]]) + : Type.String({ minLength: 1, description: "A coding agent registered by Cate." }) +} + +async function invoke( + method: string, + args: Record, + signal?: AbortSignal, +): Promise { + const api = process.env.CATE_API + const token = process.env.CATE_TOKEN + if (!api || !token) throw new Error("Cate orchestration is unavailable in this session") + const response = await fetch(api, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ method, args }), + signal, + }) + const body = await response.json() as { result?: unknown; error?: string } + if (!response.ok) throw new Error(body.error || `Cate API failed (${response.status})`) + const result = body.result + if (result && typeof result === "object" && "error" in result) { + throw new Error(String((result as { error: unknown }).error)) + } + return result +} + +function toolResult(result: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: result, + } +} + +export default function (pi: ExtensionAPI) { + let active = false + + const setMode = ( + enabled: boolean, + ctx: { ui: { setStatus: (key: string, value: string | undefined) => void } }, + ): void => { + active = enabled + ctx.ui.setStatus(STATUS_KEY, enabled ? "Orchestration mode" : undefined) + } + + const syncActiveTools = (): void => { + const current = pi.getActiveTools() + const next = active + ? [...new Set([...current, ...TOOL_NAMES])] + : current.filter((name) => !TOOL_NAME_SET.has(name)) + if (next.length === current.length && next.every((name, index) => name === current[index])) { + return + } + pi.setActiveTools(next) + } + + pi.registerTool({ + name: "create_coding_agent", + label: "Create coding agent", + description: + "Create a visible coding-agent terminal, bind it to a registered worktree, and give it an initial implementation task. Omit agentId to use Cate's first hook-ready registered agent. An explicit choice is accepted only when its hooks are ready in the target checkout. Returns a runId and panelId.", + promptSnippet: + "create_coding_agent - start a visible, registered coding-agent worker in a Cate worktree with an initial task.", + promptGuidelines: [ + "Delegate bounded implementation or investigation tasks when parallel work materially helps; keep architectural ownership and final verification yourself.", + "Give each worker a self-contained prompt with scope, constraints, and concrete success criteria. Never ask a worker to create more workers.", + "After delegation, call wait_for_coding_agents once and let it block until worker state changes. Do not repeatedly inspect a worker that is still working; inspect after a change or timeout, then send a targeted follow-up only when needed.", + "Never create more than five live workers. Reuse a run with send_to_coding_agent when follow-up belongs to the same task.", + "Respect followUpSupported in each run result; create a fresh run when that capability is false.", + "When a run fails, use its failureReason or inspect it for full output. If the failure is specific to that CLI, such as quota, authentication, or service availability, create a fresh run with a different registered agentId.", + ], + parameters: Type.Object({ + agentId: Type.Optional(agentIdSchema()), + prompt: Type.String({ minLength: 1, description: "Self-contained task, constraints, and success criteria." }), + worktreeId: Type.Optional( + Type.String({ description: "Registered Cate worktree id. Omit to inherit this Cate Agent panel's worktree or use the primary checkout." }), + ), + newWorktree: Type.Optional( + Type.String({ description: "Create a new isolated branch/worktree with this name before launching. Mutually exclusive with worktreeId." }), + ), + baseRef: Type.Optional( + Type.String({ description: "Optional git base ref for newWorktree. Omit to use the repository default." }), + ), + }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.create", params, signal)) + }, + }) + + pi.registerTool({ + name: "send_to_coding_agent", + label: "Prompt coding agent", + description: + "Send a follow-up prompt to a live Cate-owned coding agent. The prompt is pasted atomically and submitted to that worker's terminal.", + parameters: Type.Object({ + runId: Type.String(), + prompt: Type.String({ minLength: 1 }), + }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.send", params, signal)) + }, + }) + + pi.registerTool({ + name: "wait_for_coding_agents", + label: "Wait for coding agents", + description: + "Efficiently monitor one or more Cate-owned coding agents. Blocks for up to 60 seconds by default but returns immediately when a worker changes state or needs input. Prefer one long wait over repeated polling; inspect only after this reports a change or timeout.", + parameters: Type.Object({ + runIds: Type.Array(Type.String(), { minItems: 1, maxItems: 5 }), + timeoutSeconds: Type.Optional(Type.Number({ minimum: 15, maximum: 120, default: 60 })), + }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.wait", params, signal)) + }, + }) + + pi.registerTool({ + name: "inspect_coding_agent", + label: "Inspect coding agent", + description: + "Inspect a Cate-owned coding agent's live state and recent visible terminal output without focusing or moving the user's canvas.", + parameters: Type.Object({ runId: Type.String() }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.inspect", params, signal)) + }, + }) + + pi.registerTool({ + name: "stop_coding_agent", + label: "Stop coding agent", + description: + "Stop a Cate-owned coding agent process. Use for obsolete, stuck, or explicitly cancelled work.", + parameters: Type.Object({ runId: Type.String() }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.stop", params, signal)) + }, + }) + + pi.registerCommand("orchestrate", { + description: "Toggle coding-agent orchestration mode.", + handler: async (_args, ctx) => { + setMode(!active, ctx) + }, + }) + + pi.on("before_agent_start", async (event) => { + // Changing Pi's active tools inside the /orchestrate command can resume the + // agent loop. Defer that rebuild until a real user prompt is about to start. + syncActiveTools() + if (!active) return + return { systemPrompt: `${event.systemPrompt}\n\n${ORCHESTRATOR_PROMPT}` } + }) + + // Inactive tools are absent from the provider payload. This hook is a + // defensive backstop for a stale tool call already emitted while mode changed. + pi.on("tool_call", async (event) => { + if (!active && TOOL_NAME_SET.has(event.toolName)) { + return { + block: true, + reason: "Coding-agent orchestration mode is not active.", + } + } + }) + + // Extension loading happens before Pi's runtime action methods are available, + // so the initial gate belongs in session_start rather than module setup. + pi.on("session_start", async () => { + syncActiveTools() + }) +} diff --git a/src/cateAgent/extensions/cate-orchestrator/package.json b/src/cateAgent/extensions/cate-orchestrator/package.json new file mode 100644 index 00000000..05eb83f4 --- /dev/null +++ b/src/cateAgent/extensions/cate-orchestrator/package.json @@ -0,0 +1,8 @@ +{ + "name": "cate-orchestrator", + "description": "Native tools for Cate Agent to create, prompt, inspect, wait for, and stop visible coding agents.", + "private": true, + "pi": { + "extensions": ["./index.ts"] + } +} diff --git a/src/cateAgent/main/codingManager.test.ts b/src/cateAgent/main/codingManager.test.ts index 946196ea..a8eb55d7 100644 --- a/src/cateAgent/main/codingManager.test.ts +++ b/src/cateAgent/main/codingManager.test.ts @@ -18,6 +18,7 @@ vi.mock('./piRpcClient', () => ({ PiRpcClient: vi.fn() })) vi.mock('./installPlanMode', () => ({ installPlanModeExtension: vi.fn() })) vi.mock('./installCanvasMode', () => ({ installCanvasModeExtension: vi.fn() })) vi.mock('./installAskUser', () => ({ installAskUserExtension: vi.fn() })) +vi.mock('./installOrchestrator', () => ({ installOrchestratorExtension: vi.fn() })) vi.mock('./installMcpAdapter', () => ({ installMcpAdapter: vi.fn() })) vi.mock('./codingDir', () => ({ hostCodingDir: vi.fn(() => '/agent'), @@ -31,7 +32,7 @@ vi.mock('./codingDir', () => ({ vi.mock('./customModels', () => ({ mirrorModelsToWorkspace: vi.fn() })) vi.mock('../../skills/main/skillsMirror', () => ({ syncWorkspaceSkills: vi.fn() })) vi.mock('../../main/extensions/workspaceCateApi', () => ({ - workspaceCateApi: { ensureEndpoint: vi.fn().mockResolvedValue(null) }, + workspaceCateApi: { ensureCateAgentEndpoint: vi.fn().mockResolvedValue(null) }, })) vi.mock('../../main/workspaceStateStore', () => ({ isProjectTrusted: vi.fn(() => false) })) @@ -41,6 +42,8 @@ import { runtimes } from '../../main/runtime/runtimeManager' import { PiRpcClient } from './piRpcClient' import { prepareCodingDir } from './codingDir' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' +import { CODING_EVENT } from '../../shared/ipc-channels' +import { workspaceCateApi } from '../../main/extensions/workspaceCateApi' const fakeAuthManager = { setOnChange: vi.fn() } as unknown as AuthManager @@ -65,6 +68,7 @@ describe('CodingManager worktree skill preparation', () => { }) const manager = new CodingManager(fakeAuthManager) + const sender = { id: 4, isDestroyed: () => false, send: vi.fn() } as never await manager.create( { panelId: 'panel-worktree', @@ -72,7 +76,7 @@ describe('CodingManager worktree skill preparation', () => { workspaceRoot: '/repo/base', cwd: '/repo/worktree', }, - { id: 4, isDestroyed: () => false, send: vi.fn() } as never, + sender, ) expect(syncWorkspaceSkills).toHaveBeenCalledWith('/repo/base', '/repo/worktree') @@ -80,6 +84,73 @@ describe('CodingManager worktree skill preparation', () => { vi.mocked(prepareCodingDir).mock.invocationCallOrder[0], ) expect(client.start).toHaveBeenCalledOnce() + expect(workspaceCateApi.ensureCateAgentEndpoint).toHaveBeenCalledWith( + 'workspace-1', + 'panel-worktree', + '/repo/worktree', + sender, + ) + expect(PiRpcClient).toHaveBeenCalledWith(runtime, expect.objectContaining({ + env: expect.objectContaining({ + CATE_CODING_AGENT_IDS: JSON.stringify([ + 'claude-code', + 'codex', + 'cursor', + 'grok', + 'opencode', + 'pi', + ]), + }), + })) + }) + + it('logs process diagnostics but sends only a bounded error to the panel', async () => { + const runtime = { agent: { ensurePi: vi.fn().mockResolvedValue(undefined) } } + vi.mocked(runtimes.resolve).mockReturnValue(runtime as never) + let exitListener: ((code: number | null, stderr: string) => void) | undefined + const client = { + start: vi.fn().mockResolvedValue(undefined), + onEvent: vi.fn(() => vi.fn()), + onExit: vi.fn((listener: typeof exitListener) => { + exitListener = listener + return vi.fn() + }), + } + vi.mocked(PiRpcClient).mockImplementation(() => client as never) + vi.mocked(syncWorkspaceSkills).mockResolvedValue({ + copied: [], + updated: [], + removed: [], + preserved: [], + warnings: [], + }) + const send = vi.fn() + const manager = new CodingManager(fakeAuthManager) + + await manager.create( + { + panelId: 'panel-crash', + workspaceId: 'workspace-1', + workspaceRoot: '/repo/base', + cwd: '/repo/worktree', + }, + { id: 4, isDestroyed: () => false, send } as never, + ) + exitListener?.( + 1, + 'Failed to load extension "/Users/anton/Dev/repo/private-extension.ts": ' + + 'Extension runtime not initialized.', + ) + + expect(send).toHaveBeenCalledWith(CODING_EVENT, { + panelId: 'panel-crash', + event: { + type: 'error', + message: 'Cate couldn’t load its agent tools. Restart Cate and start a new chat.', + }, + }) + expect(JSON.stringify(send.mock.calls)).not.toContain('/Users/') + expect(JSON.stringify(send.mock.calls)).not.toContain('Extension runtime') }) }) diff --git a/src/cateAgent/main/codingManager.ts b/src/cateAgent/main/codingManager.ts index d707cb1e..bc9e665f 100644 --- a/src/cateAgent/main/codingManager.ts +++ b/src/cateAgent/main/codingManager.ts @@ -38,6 +38,7 @@ import { broadcastToAll } from '../../main/windowRegistry' import { installPlanModeExtension } from './installPlanMode' import { installCanvasModeExtension } from './installCanvasMode' import { installAskUserExtension } from './installAskUser' +import { installOrchestratorExtension } from './installOrchestrator' import { installMcpAdapter } from './installMcpAdapter' import { isProjectTrusted } from '../../main/workspaceStateStore' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' @@ -49,9 +50,12 @@ import { getSetting } from '../../main/settingsFile' import { workspaceCateApi } from '../../main/extensions/workspaceCateApi' import { KeyedLock } from '../../main/keyedLock' import { agentMessageText, lastAssistantMessage } from '../../shared/agentMessages' +import { agentErrorMessage } from '../../shared/agentErrorMessage' +import { AGENTS } from '../../shared/agents' interface AgentSession { panelId: string + workspaceId: string /** The runtime hosting this session (local or remote). */ runtime: Runtime /** Runtime-absolute workspace path (the locator's path part). */ @@ -177,6 +181,7 @@ export class CodingManager { await installPlanModeExtension(runtime, cwd) await installCanvasModeExtension(runtime, cwd) await installAskUserExtension(runtime, cwd) + await installOrchestratorExtension(runtime, cwd) // Register pi-mcp-adapter in /.cate/cate-agent/settings.json so pi // auto-installs + loads it on session start (MCP driven by /.pi/mcp.json). // @@ -194,12 +199,20 @@ export class CodingManager { const env: Record = { PI_CODING_AGENT_DIR: hostCodingDir(runtimeId, cwd), + // The standalone Pi extension cannot import Cate's source tree. Feed + // it the canonical registry so its tool schema cannot drift. + CATE_CODING_AGENT_IDS: JSON.stringify(AGENTS.map((agent) => agent.id)), } - // First-party CATE_API endpoint: give pi CATE_API/CATE_TOKEN so a `cate` - // CLI run from a tool can reach the dispatch core. Null when the CLI - // setting is disabled (the gate) — then nothing is injected (fail closed). - const cateApi = await workspaceCateApi.ensureEndpoint(opts.workspaceId) + // The embedded supervisor gets a panel-bound endpoint with orchestration + // scope. Worker terminals receive the ordinary first-party endpoint and + // therefore cannot recursively spawn agents. + const cateApi = await workspaceCateApi.ensureCateAgentEndpoint( + opts.workspaceId, + opts.panelId, + cwd, + sender, + ) if (cateApi) { env.CATE_API = `http://127.0.0.1:${cateApi.port}` env.CATE_TOKEN = cateApi.token @@ -223,7 +236,11 @@ export class CodingManager { } catch (err) { const message = err instanceof Error ? err.message : String(err) log.warn('[codingManager] failed to start pi for %s: %s', opts.panelId, message) - this.sendErrorEvent(sender, opts.panelId, `Failed to start pi: ${message}`) + this.sendErrorEvent( + sender, + opts.panelId, + agentErrorMessage(message, 'Cate couldn’t start the agent. Start a new chat and try again.'), + ) throw err } @@ -246,7 +263,11 @@ export class CodingManager { const disposeExitWatcher = client.onExit((code, stderr) => { const reason = stderr ? `\n${stderr}` : '' log.warn('[codingManager] pi exited unexpectedly panel=%s code=%s%s', opts.panelId, code, reason) - this.sendErrorEvent(sender, opts.panelId, `Agent process exited (code ${code}).${reason}`) + this.sendErrorEvent( + sender, + opts.panelId, + agentErrorMessage(`Agent process exited (code ${code}).${reason}`), + ) }) // Watch the host's auth.json so OAuth token refreshes written by pi @@ -255,6 +276,7 @@ export class CodingManager { this.sessions.set(opts.panelId, { panelId: opts.panelId, + workspaceId: opts.workspaceId, runtime, cwd, client, @@ -312,7 +334,7 @@ export class CodingManager { } catch (err) { const message = err instanceof Error ? err.message : String(err) log.warn('[codingManager] prompt failed for %s: %s', panelId, message) - this.sendErrorEvent(session.sender, panelId, message) + this.sendErrorEvent(session.sender, panelId, agentErrorMessage(message)) throw err } } @@ -512,6 +534,7 @@ export class CodingManager { try { session.client.rejectAllPending('Pi session disposed') } catch { /* noop */ } try { await session.client.stop() } catch { /* noop */ } this.sessions.delete(panelId) + workspaceCateApi.disposeCateAgentEndpoint(session.workspaceId, panelId) // Drop any extension handle that routed to this session (e.g. the owning // window went away) so a stale handle can't outlive its client. for (const [handle, ext] of this.extSessions) { diff --git a/src/cateAgent/main/extensionInstall.test.ts b/src/cateAgent/main/extensionInstall.test.ts new file mode 100644 index 00000000..0274ad30 --- /dev/null +++ b/src/cateAgent/main/extensionInstall.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getAppPath: () => process.cwd() }, +})) +vi.mock('./codingDir', () => ({ + hostCodingDir: () => '/host/.cate/cate-agent', + hostJoin: (_runtimeId: string, ...segments: string[]) => segments.join('/'), +})) +vi.mock('../../main/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn() }, +})) + +import { createBundledExtensionInstaller } from './extensionInstall' + +function runtime(writeFile = vi.fn(async () => undefined)) { + return { + id: 'local', + file: { + stat: vi.fn(async () => { throw new Error('missing') }), + readFile: vi.fn(), + mkdir: vi.fn(async () => undefined), + writeFile, + }, + } as any +} + +describe('createBundledExtensionInstaller', () => { + beforeEach(() => vi.clearAllMocks()) + + it('shares concurrent work and skips a completed install', async () => { + const host = runtime() + const install = createBundledExtensionInstaller('cate-plan-mode', '[test]') + + await Promise.all([install(host, '/repo'), install(host, '/repo')]) + await install(host, '/repo') + + expect(host.file.writeFile).toHaveBeenCalledTimes(2) + expect(host.file.writeFile.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + '/host/.cate/cate-agent/extensions/cate-plan-mode/index.ts', + '/host/.cate/cate-agent/extensions/cate-plan-mode/package.json', + ]) + }) + + it('does not poison the install-once cache after a transient write failure', async () => { + const writeFile = vi.fn() + .mockRejectedValueOnce(new Error('remote disconnected')) + .mockResolvedValue(undefined) + const host = runtime(writeFile) + const install = createBundledExtensionInstaller('cate-plan-mode', '[test]') + + await install(host, '/repo') + await install(host, '/repo') + + expect(writeFile).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/cateAgent/main/extensionInstall.ts b/src/cateAgent/main/extensionInstall.ts index 1852df46..5c0b6d39 100644 --- a/src/cateAgent/main/extensionInstall.ts +++ b/src/cateAgent/main/extensionInstall.ts @@ -10,8 +10,10 @@ import fs from 'fs' import fsp from 'fs/promises' +import path from 'path' +import { app } from 'electron' import log from '../../main/logger' -import { hostJoin } from './codingDir' +import { hostCodingDir, hostJoin } from './codingDir' import type { Runtime } from '../../main/runtime/types' /** Install-once gate keyed on an arbitrary string. */ @@ -41,6 +43,47 @@ export function findSourceDir(candidates: string[]): string | null { return null } +/** + * Build an installer for a Cate-managed extension bundled as index.ts + + * package.json. Concurrent sessions share one in-flight copy, successful + * installs are skipped thereafter, and transient failures remain retryable. + */ +export function createBundledExtensionInstaller( + slug: string, + logLabel: string, +): (runtime: Runtime, cwd: string) => Promise { + const installed = new Set() + const pending = new Map>() + + return async (runtime: Runtime, cwd: string): Promise => { + const home = hostCodingDir(runtime.id, cwd) + const key = `${runtime.id}\0${home}` + if (installed.has(key)) return + const existing = pending.get(key) + if (existing) return existing + + const install = (async () => { + const src = findSourceDir([ + path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', slug), + path.join(process.resourcesPath ?? '', 'cate-extensions', slug), + ]) + if (!src) throw new Error(`bundled source directory not found for ${slug}`) + const destDir = hostJoin(runtime.id, home, 'extensions', slug) + await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', logLabel) + await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', logLabel) + installed.add(key) + })() + pending.set(key, install) + try { + await install + } catch (err) { + log.warn('%s install failed: %O', logLabel, err) + } finally { + pending.delete(key) + } + } +} + /** True when the host already has a file/dir at `hostPath`. */ export async function hostFileExists(runtime: Runtime, hostPath: string): Promise { try { diff --git a/src/cateAgent/main/installAskUser.ts b/src/cateAgent/main/installAskUser.ts index 98265451..298f7020 100644 --- a/src/cateAgent/main/installAskUser.ts +++ b/src/cateAgent/main/installAskUser.ts @@ -5,43 +5,7 @@ // copy-if-changed semantics so a shipped update reaches hosts with an older copy). // ============================================================================= -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -/** Source dir of the bundled extension. Dev path first (src/ on disk), then the - * production extraResources copy. */ -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-ask-user'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-ask-user'), - ]) -} - -// Keyed on runtimeId + host path so the same host path on different runtimes -// doesn't collide. -const installed = createIdempotencyTracker() - -/** Idempotent — safe to call from CodingManager.create() on every session. - * `cwd` is the HOST path on whichever machine pi runs. */ -export async function installAskUserExtension(runtime: Runtime, cwd: string): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installAskUser] source dir not found — ask_user extension not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-ask-user') - await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', '[installAskUser]') - await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', '[installAskUser]') - } catch (err) { - log.warn('[installAskUser] install failed: %O', err) - } -} +export const installAskUserExtension = + createBundledExtensionInstaller('cate-ask-user', '[installAskUser]') diff --git a/src/cateAgent/main/installCanvasMode.ts b/src/cateAgent/main/installCanvasMode.ts index 2090219c..99c79870 100644 --- a/src/cateAgent/main/installCanvasMode.ts +++ b/src/cateAgent/main/installCanvasMode.ts @@ -3,37 +3,7 @@ // workspace's Cate agent directory. Mirrors the plan/ask-user installers. // ============================================================================= -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-canvas-mode'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-canvas-mode'), - ]) -} - -const installed = createIdempotencyTracker() - -export async function installCanvasModeExtension(runtime: Runtime, cwd: string): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installCanvasMode] source dir not found — canvas mode extension not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-canvas-mode') - await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', '[installCanvasMode]') - await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', '[installCanvasMode]') - } catch (err) { - log.warn('[installCanvasMode] install failed: %O', err) - } -} +export const installCanvasModeExtension = + createBundledExtensionInstaller('cate-canvas-mode', '[installCanvasMode]') diff --git a/src/cateAgent/main/installOrchestrator.ts b/src/cateAgent/main/installOrchestrator.ts new file mode 100644 index 00000000..85c0499b --- /dev/null +++ b/src/cateAgent/main/installOrchestrator.ts @@ -0,0 +1,4 @@ +import { createBundledExtensionInstaller } from './extensionInstall' + +export const installOrchestratorExtension = + createBundledExtensionInstaller('cate-orchestrator', '[installOrchestrator]') diff --git a/src/cateAgent/main/installPlanMode.ts b/src/cateAgent/main/installPlanMode.ts index f0eb744d..76b8a66a 100644 --- a/src/cateAgent/main/installPlanMode.ts +++ b/src/cateAgent/main/installPlanMode.ts @@ -18,61 +18,7 @@ // copy without rewriting on every launch. // ============================================================================= -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -/** Source dir of the bundled extension. Tries dev path first (src/ on disk), - * then production extraResources copy. */ -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-plan-mode'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-plan-mode'), - ]) -} - -/** Copy a single source file (read locally) to a host destination, overwriting - * only when the host copy differs from the bundled source. This is a - * Cate-managed extension, so the bundled version is authoritative — comparing - * first means we still skip the write when nothing changed (the common case), - * but a shipped update reliably reaches hosts that already have an older copy. */ -async function copyIfChanged( - runtime: Runtime, - src: string, - destDir: string, - destName: string, -): Promise { - await copyFileToHost(runtime, src, destDir, destName, 'if-changed', '[installPlanMode]') -} - -// Keyed on runtimeId + host path so the same host path on different runtimes -// doesn't collide. -const installed = createIdempotencyTracker() - -/** Idempotent — safe to call from CodingManager.create() on every session. - * `cwd` is the HOST path on whichever machine pi runs (local fs path for the - * local runtime, POSIX path on a remote host). */ -export async function installPlanModeExtension( - runtime: Runtime, - cwd: string, -): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installPlanMode] source dir not found — plan mode extension not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-plan-mode') - await copyIfChanged(runtime, path.join(src, 'index.ts'), destDir, 'index.ts') - await copyIfChanged(runtime, path.join(src, 'package.json'), destDir, 'package.json') - } catch (err) { - log.warn('[installPlanMode] install failed: %O', err) - } -} +export const installPlanModeExtension = + createBundledExtensionInstaller('cate-plan-mode', '[installPlanMode]') diff --git a/src/cateAgent/renderer/CateAgentChatTabs.test.tsx b/src/cateAgent/renderer/CateAgentChatTabs.test.tsx new file mode 100644 index 00000000..3bf58c9b --- /dev/null +++ b/src/cateAgent/renderer/CateAgentChatTabs.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useChatDragState } from '../../renderer/drag/chatDragState' +import { useChatsStore } from '../../renderer/stores/chatsStore' +import { CateAgentChatTabs } from './CateAgentChatTabs' +import { useCateAgentStore } from './cateAgentStore' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(window as unknown as { electronAPI: Record }).electronAPI = { + projectChatsSave: vi.fn(), + } + useCateAgentStore.setState({ + byWs: { 'ws-1': { activeChatId: 'chat-a' } }, + activeChatByPanel: {}, + }) + useChatsStore.setState({ + chatsByRoot: { + '/repo': [ + { id: 'chat-a', title: 'Chat A', createdAt: 1, updatedAt: 1 }, + { id: 'chat-b', title: 'Chat B', createdAt: 2, updatedAt: 2 }, + ], + }, + loadedRoots: { '/repo': true }, + }) + useChatDragState.setState({ active: null, destinationHostPanelId: undefined }) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() +}) + +describe('CateAgentChatTabs', () => { + it('reveals a horizontal scrollbar on hover while keeping chats draggable', () => { + act(() => root.render()) + + const strip = host.querySelector('.overflow-x-auto') as HTMLDivElement + const chatB = [...host.querySelectorAll('[role="tab"]')] + .find((tab) => tab.textContent?.includes('Chat B'))! + + expect(strip.classList).not.toContain('no-scrollbar') + expect(strip.classList).toContain('cate-agent-chat-tabs-scroll') + expect(strip.parentElement?.classList).toContain('cate-agent-chat-tabs') + expect(chatB.draggable).toBe(true) + }) + + it('shows and creates only chats owned by a panel', () => { + useChatsStore.setState({ + chatsByRoot: { + '/repo': [ + { id: 'sidebar-chat', title: 'Sidebar chat', createdAt: 1, updatedAt: 1 }, + { + id: 'panel-chat', + title: 'Panel chat', + createdAt: 2, + updatedAt: 2, + hostPanelId: 'panel-1', + }, + { + id: 'other-panel-chat', + title: 'Other panel chat', + createdAt: 3, + updatedAt: 3, + hostPanelId: 'panel-2', + }, + ], + }, + loadedRoots: { '/repo': true }, + }) + const onActiveChatChange = vi.fn() + + act(() => root.render( + , + )) + + expect(host.textContent).toContain('Panel chat') + expect(host.textContent).not.toContain('Sidebar chat') + expect(host.textContent).not.toContain('Other panel chat') + + act(() => { + host.querySelector('button[title="New chat"]')!.click() + }) + + const created = useChatsStore.getState().getChats('/repo').at(-1)! + expect(created.hostPanelId).toBe('panel-1') + expect(onActiveChatChange).toHaveBeenCalledWith(created.id) + }) +}) diff --git a/src/cateAgent/renderer/CateAgentChatTabs.tsx b/src/cateAgent/renderer/CateAgentChatTabs.tsx index 1658c662..2eed4e7a 100644 --- a/src/cateAgent/renderer/CateAgentChatTabs.tsx +++ b/src/cateAgent/renderer/CateAgentChatTabs.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Plus, X } from '@phosphor-icons/react' -import { isSidebarChat, useChatsStore } from '../../renderer/stores/chatsStore' +import { isPanelChat, isSidebarChat, useChatsStore } from '../../renderer/stores/chatsStore' import { useCateAgentStore, useCateAgentWs } from './cateAgentStore' import { disposeDirectChatSession } from './directChatSession' import { @@ -50,58 +50,83 @@ const Tab: React.FC<{ ) -export const CateAgentChatTabs: React.FC<{ wsId: string; rootPath: string }> = ({ wsId, rootPath }) => { +type CateAgentChatTabsProps = { + wsId: string + rootPath: string +} & ({ + panelId: string + activeChatId: string | null + onActiveChatChange: (chatId: string | null) => void +} | { + panelId?: undefined + activeChatId?: never + onActiveChatChange?: never +}) + +export const CateAgentChatTabs: React.FC = (props) => { + const { wsId, rootPath, panelId } = props const cateAgent = useCateAgentWs(wsId) const chats = (useChatsStore((s) => s.chatsByRoot[rootPath]) ?? []) - .filter(isSidebarChat) - const setActiveChat = useCateAgentStore((s) => s.setActiveChat) + .filter((chat) => panelId ? isPanelChat(chat, panelId) : isSidebarChat(chat)) + const setSidebarActiveChat = useCateAgentStore((s) => s.setActiveChat) const drag = useChatDragState((state) => state.active) const dragDestination = useChatDragState((state) => state.destinationHostPanelId) - const showGhost = showChatDropGhost(drag, dragDestination, rootPath, null) + const activeChatId = panelId ? props.activeChatId : cateAgent.activeChatId + const showGhost = showChatDropGhost(drag, dragDestination, rootPath, panelId ?? null) const ordered = [...chats].reverse() const previewItems = showGhost ? [...ordered, drag.chat].sort((a, b) => b.createdAt - a.createdAt) : ordered + const setActiveChat = (chatId: string | null): void => { + if (panelId) props.onActiveChatChange(chatId) + else setSidebarActiveChat(wsId, chatId ?? '') + } + const newChat = (): void => { - const chat = useChatsStore.getState().createChat(rootPath, 'New chat') - setActiveChat(wsId, chat.id) + const chat = useChatsStore.getState().createChat(rootPath, 'New chat', panelId) + setActiveChat(chat.id) } return ( -
- {previewItems.map((chat) => chat.id === drag?.chat.id && showGhost ? ( - - ) : ( - setActiveChat(wsId, chat.id)} - onClose={() => { - disposeDirectChatSession(chat.id) - useChatsStore.getState().removeChat(rootPath, chat.id) - if (chat.id !== cateAgent.activeChatId) return - const remaining = useChatsStore.getState().getChats(rootPath).filter(isSidebarChat) - setActiveChat(wsId, remaining[remaining.length - 1]?.id ?? '') - }} - onDragStart={(e) => { - e.dataTransfer.effectAllowed = 'move' - beginChatDrag(e.dataTransfer, { chat, rootPath, sourceHostPanelId: null }) - }} - onDragEnd={endChatDrag} +
+
+ {previewItems.map((chat) => chat.id === drag?.chat.id && showGhost ? ( + + ) : ( + setActiveChat(chat.id)} + onClose={() => { + disposeDirectChatSession(chat.id) + useChatsStore.getState().removeChat(rootPath, chat.id) + if (chat.id !== activeChatId) return + const remaining = useChatsStore.getState().getChats(rootPath) + .filter((candidate) => panelId + ? isPanelChat(candidate, panelId) + : isSidebarChat(candidate)) + setActiveChat(remaining[remaining.length - 1]?.id ?? null) + }} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = 'move' + beginChatDrag(e.dataTransfer, { chat, rootPath, sourceHostPanelId: panelId ?? null }) + }} + onDragEnd={endChatDrag} + > + + {chat.title} + + ))} + + + +
) } diff --git a/src/cateAgent/renderer/CateAgentChatView.tsx b/src/cateAgent/renderer/CateAgentChatView.tsx index 9cc20e8a..ab62d20b 100644 --- a/src/cateAgent/renderer/CateAgentChatView.tsx +++ b/src/cateAgent/renderer/CateAgentChatView.tsx @@ -16,8 +16,8 @@ import { ensureDirectChatSession, persistDirectSessionFile, } from './directChatSession' -import { resolveTargetWorktree, setTargetWorktree } from './cateAgentWorktreeTarget' import { codingClient } from './codingClient' +import { CateAgentEmptyState } from './CateAgentEmptyState' export const CateAgentChatView: React.FC<{ wsId: string @@ -26,9 +26,7 @@ export const CateAgentChatView: React.FC<{ onChatCreated?: (chatId: string) => void /** Set only when rendered inside an Agent panel; absent means sidebar. */ hostPanelId?: string - /** Worktree assigned when this Agent panel was launched from Parallel Work. */ - defaultWorktreeId?: string -}> = ({ wsId, rootPath, chatId, onChatCreated, hostPanelId, defaultWorktreeId }) => { +}> = ({ wsId, rootPath, chatId, onChatCreated, hostPanelId }) => { const chat = useChatsStore((state) => chatId ? (state.chatsByRoot[rootPath] ?? []).find((candidate) => candidate.id === chatId) : undefined) @@ -36,16 +34,15 @@ export const CateAgentChatView: React.FC<{ // The first send mints a durable record; every later turn uses that session. if (!chat) { return ( -
+ -
+ ) } @@ -54,8 +51,6 @@ export const CateAgentChatView: React.FC<{ wsId={wsId} rootPath={rootPath} chatId={chat.id} - hostPanelId={hostPanelId} - defaultWorktreeId={defaultWorktreeId} /> ) } @@ -64,14 +59,10 @@ const DirectCateChatView: React.FC<{ wsId: string rootPath: string chatId: string - hostPanelId?: string - defaultWorktreeId?: string }> = ({ wsId, rootPath, chatId, - hostPanelId, - defaultWorktreeId, }) => { const chat = useChatsStore((state) => (state.chatsByRoot[rootPath] ?? []).find((candidate) => candidate.id === chatId)) const agentKey = directAgentKey(chatId) @@ -81,7 +72,7 @@ const DirectCateChatView: React.FC<{ const [commands, setCommands] = React.useState([]) const [modelPickerOpen, setModelPickerOpen] = React.useState(false) const [targetId, setTargetId] = React.useState( - () => resolveTargetWorktree(chatId, defaultWorktreeId), + () => chat?.worktreeId ?? null, ) const { models, refreshModels } = useComposerModels() const { worktrees, onCreateWorktree, onCheckoutPr } = useComposerWorktrees({ rootPath, workspaceId: wsId }) @@ -89,13 +80,8 @@ const DirectCateChatView: React.FC<{ const directCwd = resolveWorktree(targetId ?? undefined, worktreeMetas)?.path ?? rootPath React.useEffect(() => { - const next = resolveTargetWorktree(chatId, defaultWorktreeId) - setTargetId(next) - if (next && defaultWorktreeId) setTargetWorktree(chatId, next) - if (hostPanelId && next) { - useAppStore.getState().setPanelWorktreeId(wsId, hostPanelId, next) - } - }, [chatId, defaultWorktreeId, hostPanelId, wsId]) + setTargetId(chat?.worktreeId ?? null) + }, [chat?.worktreeId, chatId]) React.useEffect(() => { if (!chat) return @@ -112,8 +98,7 @@ const DirectCateChatView: React.FC<{ if (!chat) return const cwd = resolveWorktree(id, worktreeMetas)?.path ?? worktrees.find((worktree) => worktree.id === id)?.path ?? rootPath setTargetId(id) - setTargetWorktree(chatId, id) - if (hostPanelId) useAppStore.getState().setPanelWorktreeId(wsId, hostPanelId, id) + useChatsStore.getState().patchChat(rootPath, chatId, { worktreeId: id }) setReady(false) try { // Pi may have learned its session path since the last stats refresh. Save @@ -135,7 +120,7 @@ const DirectCateChatView: React.FC<{ } catch { setReady(false) } - }, [agentKey, chat, chatId, hostPanelId, rootPath, worktreeMetas, worktrees, wsId]) + }, [agentKey, chat, chatId, rootPath, worktreeMetas, worktrees, wsId]) const refreshCommands = React.useCallback(async () => { if (!ready && !sliceExists) return diff --git a/src/cateAgent/renderer/CateAgentComposer.tsx b/src/cateAgent/renderer/CateAgentComposer.tsx index 11bd680d..b5832d28 100644 --- a/src/cateAgent/renderer/CateAgentComposer.tsx +++ b/src/cateAgent/renderer/CateAgentComposer.tsx @@ -4,7 +4,6 @@ import { useComposerModels } from '../../renderer/chat/useComposerModels' import { useComposerWorktrees } from '../../renderer/chat/useComposerWorktrees' import { useSettingsStore } from '../../renderer/stores/settingsStore' import { useUIStore } from '../../renderer/stores/uiStore' -import { useAppStore } from '../../renderer/stores/appStore' import { sendDirectAgentMessage } from './cateAgentSend' import { saveDefaultModel } from './codingModelPrefs' import { buildFileMentions } from './codingDrop' @@ -50,20 +49,18 @@ export const CateAgentComposer: React.FC<{ chatId?: string | null onChatCreated?: (chatId: string) => void hostPanelId?: string - defaultWorktreeId?: string }> = ({ wsId, rootPath, onChatCreated, hostPanelId, - defaultWorktreeId, }) => { const [draft, setDraft] = React.useState(() => loadDraft(wsId, hostPanelId)) const [images, setImages] = React.useState([]) const [thinkingLevel, setThinkingLevel] = React.useState(null) const [promptMode, setPromptMode] = React.useState(null) const [autoCompactionEnabled, setAutoCompactionEnabled] = React.useState(true) - const [targetId, setTargetId] = React.useState(defaultWorktreeId ?? null) + const [targetId, setTargetId] = React.useState(null) const { models, refreshModels } = useComposerModels() const defaultModel = useSettingsStore((state) => state.agentDefaultModel) const { worktrees, onCreateWorktree, onCheckoutPr } = useComposerWorktrees({ @@ -151,7 +148,7 @@ export const CateAgentComposer: React.FC<{ onStop={() => {}} disabled={false} running={false} - placeholder="Message Cate…" + placeholder="Ask the agent anything about this workspace…" images={images} onAddImage={addImage} onRemoveImage={(index) => setImages((current) => current.filter((_, i) => i !== index))} @@ -170,10 +167,7 @@ export const CateAgentComposer: React.FC<{ onManageModels={() => useUIStore.getState().openSettings('cate agent')} worktrees={worktrees} selectedWorktreeId={targetId} - onPickWorktree={(id) => { - setTargetId(id) - if (hostPanelId) useAppStore.getState().setPanelWorktreeId(wsId, hostPanelId, id) - }} + onPickWorktree={setTargetId} worktreeMenuHeading="Work in…" rootPath={rootPath} onCreateWorktree={onCreateWorktree} diff --git a/src/cateAgent/renderer/CateAgentEmptyState.tsx b/src/cateAgent/renderer/CateAgentEmptyState.tsx new file mode 100644 index 00000000..a58e64e6 --- /dev/null +++ b/src/cateAgent/renderer/CateAgentEmptyState.tsx @@ -0,0 +1,18 @@ +import { ChatCircle } from '@phosphor-icons/react' +import type { ReactNode } from 'react' + +export function CateAgentEmptyState({ children }: { children: ReactNode }) { + return ( +
+
+
+ +
+
+ What should we work on? +
+
{children}
+
+
+ ) +} diff --git a/src/cateAgent/renderer/CateAgentPanel.tsx b/src/cateAgent/renderer/CateAgentPanel.tsx index 10d2d40c..daa2cf9e 100644 --- a/src/cateAgent/renderer/CateAgentPanel.tsx +++ b/src/cateAgent/renderer/CateAgentPanel.tsx @@ -2,17 +2,17 @@ // panel, using the same chat view/capabilities as the workspace sidebar. import { useCallback, useEffect, useState } from 'react' -import { Sidebar as SidebarIcon } from '@phosphor-icons/react' import type { PanelProps } from '../../renderer/panels/types' import { useAppStore } from '../../renderer/stores/appStore' import { isPanelChat, useChatsStore } from '../../renderer/stores/chatsStore' import { useCateAgentReady } from '../../renderer/stores/providerReadinessStore' import { useStatusStore } from '../../renderer/stores/statusStore' import { useUIStore } from '../../renderer/stores/uiStore' -import { CateAgentPanelSidebar } from './CateAgentPanelSidebar' +import { CateAgentChatTabs } from './CateAgentChatTabs' import { CateAgentChatView } from './CateAgentChatView' import { useCodingStore } from './codingStore' -import { directAgentKey, disposeDirectChatSession } from './directChatSession' +import { useCateAgentStore } from './cateAgentStore' +import { directAgentKey } from './directChatSession' import { CHAT_DRAG_MIME, readChatDrag } from '../../renderer/drag/fileDragPayload' import { endChatDrag, useChatDragState } from '../../renderer/drag/chatDragState' @@ -27,9 +27,16 @@ export default function CateAgentPanel({ panelId, workspaceId }: PanelProps) { const ready = useCateAgentReady() === 'ok' const [activeChatId, setActiveChatId] = useState(null) - const [sidebarOpen, setSidebarOpen] = useState(true) - const activeChat = activeChatId ? chats.find((chat) => chat.id === activeChatId) : undefined + useEffect(() => { + if (panel?.worktreeId) { + useAppStore.getState().setPanelWorktreeId(workspaceId, panelId, undefined) + } + }, [panel?.worktreeId, panelId, workspaceId]) + useEffect(() => { + useCateAgentStore.getState().setPanelActiveChat(panelId, activeChatId) + return () => useCateAgentStore.getState().setPanelActiveChat(panelId, null) + }, [activeChatId, panelId]) const directRunning = useCodingStore((state) => activeChatId ? state.panels[directAgentKey(activeChatId)]?.running ?? false : false) @@ -74,28 +81,12 @@ export default function CateAgentPanel({ panelId, workspaceId }: PanelProps) { setActiveChatId(chatId) }, []) - const newChat = useCallback(async () => { - await loadChats(rootPath) - const chat = useChatsStore.getState().createChat(rootPath, 'New chat', panelId) - selectChat(chat.id) - }, [loadChats, panelId, rootPath, selectChat]) - - const deleteChat = useCallback((chatId: string) => { - disposeDirectChatSession(chatId) - useChatsStore.getState().removeChat(rootPath, chatId) - if (activeChatId !== chatId) return - const remaining = useChatsStore.getState().getChats(rootPath) - .filter((chat) => isPanelChat(chat, panelId)) - setActiveChatId(remaining[remaining.length - 1]?.id ?? null) - }, [activeChatId, panelId, rootPath]) - const handleChatDragOver = useCallback((event: React.DragEvent) => { if (!event.dataTransfer.types.includes(CHAT_DRAG_MIME)) return event.preventDefault() event.dataTransfer.dropEffect = 'move' useChatDragState.getState().setDestination(panelId) - if (!sidebarOpen) setSidebarOpen(true) - }, [panelId, sidebarOpen]) + }, [panelId]) const handleChatDrop = useCallback((event: React.DragEvent) => { const payload = readChatDrag(event.dataTransfer) @@ -120,59 +111,42 @@ export default function CateAgentPanel({ panelId, workspaceId }: PanelProps) { return (
- {sidebarOpen && ( - { void newChat() }} - onOpenChat={selectChat} - onDeleteChat={deleteChat} - onOpenSettings={() => useUIStore.getState().openSettings('cate agent')} - onCollapse={() => setSidebarOpen(false)} - /> - )} - -
- {!sidebarOpen && ( -
- -
- )} - - {!ready ? ( -
- Connect a provider to use the Cate Agent. - -
- ) : !chatsLoaded ? null : ( - + - )} -
+
+ )} + + {!ready ? ( +
+ Connect a provider to use the Cate Agent. + +
+ ) : !chatsLoaded ? null : ( + + )}
) } diff --git a/src/cateAgent/renderer/CateAgentPanelChrome.tsx b/src/cateAgent/renderer/CateAgentPanelChrome.tsx index 5bc61fda..ec216a68 100644 --- a/src/cateAgent/renderer/CateAgentPanelChrome.tsx +++ b/src/cateAgent/renderer/CateAgentPanelChrome.tsx @@ -623,26 +623,70 @@ function ThinkingBars({ count, size = 10 }: { count: number; size?: number }) { export const COMPOSER_POPOVER_SURFACE = 'rounded-lg border border-strong bg-surface-4/98 backdrop-blur-xl shadow-[0_12px_32px_var(--shadow-node)]' +export type ViewportPopoverPosition = { + top: number + left: number + placement: 'above' | 'below' +} + +export function verticalPopoverPosition( + rect: DOMRect, + gap: number, + popoverHeight?: number, +): Pick { + const viewportMargin = 8 + const above = rect.top - gap - viewportMargin + const below = window.innerHeight - rect.bottom - gap - viewportMargin + const placement = + popoverHeight != null && below >= popoverHeight + ? 'below' + : popoverHeight != null && above >= popoverHeight + ? 'above' + : below >= above + ? 'below' + : 'above' + + return { + placement, + top: placement === 'below' ? rect.bottom + gap : rect.top - gap, + } +} + // Keep anchored popovers in viewport space. Portalling into a transformed // canvas node makes CSS widths and offsets inherit the canvas zoom; it also // leaves sidebar/dock composers without a portal target at all. export function useViewportPopoverPosition( btnRef: React.RefObject, open: boolean, - layout: (rect: DOMRect) => { top: number; left: number }, + layout: (rect: DOMRect) => { left: number; gap: number; height?: number }, + popoverRef?: React.RefObject, ) { - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + const [pos, setPos] = useState(null) const layoutRef = useRef(layout) useLayoutEffect(() => { layoutRef.current = layout }, [layout]) const updatePosition = useCallback(() => { if (!open || !btnRef.current) return - const next = layoutRef.current(btnRef.current.getBoundingClientRect()) + const rect = btnRef.current.getBoundingClientRect() + const layoutResult = layoutRef.current(rect) + const measuredHeight = popoverRef?.current?.getBoundingClientRect().height + const next = { + left: layoutResult.left, + ...verticalPopoverPosition( + rect, + layoutResult.gap, + measuredHeight && measuredHeight > 0 ? measuredHeight : layoutResult.height, + ), + } setPos((current) => ( - current?.top === next.top && current.left === next.left ? current : next + current?.top === next.top && + current.left === next.left && + current.placement === next.placement + ? current + : next )) - }, [btnRef, open]) + }, [btnRef, open, popoverRef]) useLayoutEffect(() => { if (!open) { @@ -659,6 +703,12 @@ export function useViewportPopoverPosition( } }, [open, updatePosition]) + // The first pass positions an unmounted popover from the available space. + // Once it has rendered, refine the choice with its real height before paint. + useLayoutEffect(() => { + if (open && pos) updatePosition() + }) + return { pos, portalTarget: typeof document === 'undefined' ? null : document.body, @@ -669,11 +719,11 @@ export function useViewportPopoverPosition( // Owns open state, outside-click dismissal, and viewport-relative positioning. export function useNodePopover( btnRef: React.RefObject, - layout: (rect: DOMRect) => { top: number; left: number }, + layout: (rect: DOMRect) => { left: number; gap: number; height?: number }, ) { const [open, setOpen] = useState(false) const popoverRef = useRef(null) - const { pos, portalTarget } = useViewportPopoverPosition(btnRef, open, layout) + const { pos, portalTarget } = useViewportPopoverPosition(btnRef, open, layout, popoverRef) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { @@ -695,7 +745,7 @@ export function useNodePopover( } // Shared shell for composer popovers: a blurred, bordered card portalled to the -// document body and pinned above its trigger (translateY(-100%)). +// document body and placed on whichever side of its trigger has room. export function NodePopover({ popoverRef, pos, @@ -705,7 +755,7 @@ export function NodePopover({ children, }: { popoverRef: React.RefObject - pos: { top: number; left: number } | null + pos: ViewportPopoverPosition | null portalTarget: HTMLElement | null width: number bodyClassName?: string @@ -716,7 +766,13 @@ export function NodePopover({
{children}
, @@ -740,7 +796,7 @@ export function ThinkingLevelPicker({ const popW = 160 let left = r.right - popW if (left < 4) left = 4 - return { top: r.top - 4, left } + return { left, gap: 4 } }, ) const current = level ?? 'medium' diff --git a/src/cateAgent/renderer/CateAgentPanelSidebar.tsx b/src/cateAgent/renderer/CateAgentPanelSidebar.tsx deleted file mode 100644 index 9aba59bb..00000000 --- a/src/cateAgent/renderer/CateAgentPanelSidebar.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Chat-list rail for the floating Cate Agent panel. - -import { useMemo } from 'react' -import { Plus, Sidebar as SidebarIcon, Gear, Trash } from '@phosphor-icons/react' -import type { Chat } from '../../shared/types' -import { Tooltip } from '../../renderer/ui/Tooltip' -import { - beginChatDrag, - endChatDrag, - showChatDropGhost, - useChatDragState, -} from '../../renderer/drag/chatDragState' -import { ChatDropGhost, ChatStatusGlyph } from './chatListPrimitives' - -export function CateAgentPanelSidebar({ - chats, - activeChatId, - rootPath, - panelId, - onNewChat, - onOpenChat, - onDeleteChat, - onOpenSettings, - onCollapse, -}: { - chats: Chat[] - activeChatId: string | null - rootPath: string - panelId: string - onNewChat: () => void - onOpenChat: (chatId: string) => void - onDeleteChat: (chatId: string) => void - onOpenSettings: () => void - onCollapse: () => void -}) { - const ordered = useMemo(() => [...chats].reverse(), [chats]) - const drag = useChatDragState((state) => state.active) - const dragDestination = useChatDragState((state) => state.destinationHostPanelId) - const showGhost = showChatDropGhost(drag, dragDestination, rootPath, panelId) - const previewItems = useMemo( - () => showGhost && drag - ? [...ordered, drag.chat].sort((a, b) => b.createdAt - a.createdAt) - : ordered, - [drag, ordered, showGhost], - ) - - return ( -
-
- - - -
- - - -
- -
- {previewItems.length === 0 ? ( -
No chats yet.
- ) : previewItems.map((chat) => chat.id === drag?.chat.id && showGhost ? ( - - ) : ( -
{ - e.dataTransfer.effectAllowed = 'move' - beginChatDrag(e.dataTransfer, { chat, rootPath, sourceHostPanelId: panelId }) - }} - onDragEnd={endChatDrag} - className={`group flex items-center gap-1 rounded-md px-1 ${ - chat.id === activeChatId ? 'bg-hover-strong' : 'hover:bg-hover' - }`} - > - - - - -
- ))} -
- -
- -
-
- ) -} diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.test.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.test.tsx new file mode 100644 index 00000000..29e56fa7 --- /dev/null +++ b/src/cateAgent/renderer/ChatCodingAgentCard.test.tsx @@ -0,0 +1,88 @@ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { ToolMessage } from './codingStore' +import { useAppStore } from '../../renderer/stores/appStore' +import { CodingAgentCard } from './ChatCodingAgentCard' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const initialAppState = useAppStore.getState() + +function message(): ToolMessage { + return { + type: 'tool', + id: 'create-message', + toolCallId: 'create-call', + name: 'create_coding_agent', + args: { + agentId: 'codex', + prompt: 'Make the default test command deterministic', + newWorktree: 'dx/deterministic-tests', + }, + status: 'success', + result: JSON.stringify({ + id: 'run-1', + panelId: 'panel-1', + agentId: 'codex', + agentName: 'Codex', + status: 'starting', + cwd: '/repo', + worktreeId: 'worktree-1', + }), + } +} + +describe('coding agent launch presentation', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + useAppStore.setState({ + workspaces: [{ + id: 'workspace-1', + panels: { + 'panel-1': { + id: 'panel-1', + type: 'terminal', + title: 'Codex 3', + }, + }, + }], + selectedWorkspaceId: 'workspace-1', + } as never) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useAppStore.setState(initialAppState, true) + }) + + it('renders a flat Cate row with a tab-style terminal chip', () => { + act(() => root.render()) + + const row = host.querySelector('[data-tool-name="create_coding_agent"]')! + const terminalLink = host.querySelector('[data-coding-agent-terminal-link]')! + + expect(row.className).not.toContain('border') + expect(row.className).not.toContain('rounded') + expect(row.className).not.toContain('bg-surface') + expect(host.querySelector('[aria-label="Cate"]')).not.toBeNull() + expect(terminalLink.textContent).toContain('Codex') + expect(terminalLink.className).toContain('rounded-[10px]') + expect(host.textContent).toContain('Make the default test command deterministic') + + act(() => { + host.querySelector('[aria-label="Show coding agent details"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.textContent).toContain('Input') + expect(host.textContent).toContain('Output') + expect(host.querySelector('.rounded-md')).toBeNull() + }) +}) diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.tsx new file mode 100644 index 00000000..56d8741a --- /dev/null +++ b/src/cateAgent/renderer/ChatCodingAgentCard.tsx @@ -0,0 +1,115 @@ +import { useMemo, useState } from 'react' +import type { ToolMessage } from './codingStore' +import { useAppStore } from '../../renderer/stores/appStore' +import { revealPanel } from '../../renderer/lib/workspace/panelReveal' +import { useAgentTerminalStatus, agentStateLabel } from './useAgentTerminalStatus' +import { codingAgentDisplayName, parseCodingAgentId } from '../../shared/codingAgentRuns' +import { getAgentLogoById } from '../../renderer/lib/agent/agentLogos' +import { CateLogo } from '../../renderer/ui/CateLogo' +import { OrchestrationToolDetails } from './ChatOrchestrationToolCard' + +function resultObject(result: string | undefined): Record { + if (!result) return {} + try { + const parsed = JSON.parse(result) + return parsed && typeof parsed === 'object' ? parsed as Record : {} + } catch { + return {} + } +} + +/** Native card for a worker created by Cate Agent. The terminal remains a real, + * visible canvas panel; this is its live mission-level summary and jump target. */ +export function CodingAgentCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean }) { + const result = useMemo(() => resultObject(msg.result), [msg.result]) + const args = (msg.args ?? {}) as Record + const panelId = typeof result.panelId === 'string' ? result.panelId : '' + const agentId = parseCodingAgentId(result.agentId ?? args.agentId) + const agentLogo = getAgentLogoById(agentId) + const prompt = typeof args.prompt === 'string' ? args.prompt : 'Coding task' + const workspaceId = useAppStore((state) => + state.workspaces.find((ws) => panelId && ws.panels[panelId])?.id ?? '', + ) + const terminalStatus = useAgentTerminalStatus(workspaceId, panelId) + const [expanded, setExpanded] = useState(false) + const running = msg.status === 'running' || msg.status === 'pending' + const label = agentId ? codingAgentDisplayName(agentId) : 'Coding agent' + const status = panelId + ? agentStateLabel(terminalStatus.agentState) + : running ? 'Starting…' : msg.error ? 'Failed' : 'Created' + const canOpenTerminal = Boolean(panelId && workspaceId) + const statusColor = msg.error + ? 'bg-danger' + : terminalStatus.agentState === 'waitingForInput' + ? 'bg-warning' + : terminalStatus.agentState === 'finished' + ? 'bg-[#34C759]' + : terminalStatus.agentState === 'running' + ? 'bg-accent' + : 'bg-muted' + + return ( +
+
+ + + +
+ {expanded && ( +
+ + {terminalStatus.line && ( +
+ {terminalStatus.line} +
+ )} +
+ )} +
+ ) +} diff --git a/src/cateAgent/renderer/ChatMessageRow.test.tsx b/src/cateAgent/renderer/ChatMessageRow.test.tsx new file mode 100644 index 00000000..fc86e20d --- /dev/null +++ b/src/cateAgent/renderer/ChatMessageRow.test.tsx @@ -0,0 +1,45 @@ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { AssistantMessage } from './codingStore' +import { MessageRow } from './ChatMessageRow' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +describe('assistant thinking presentation', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + }) + + it('keeps live thinking text outside the transparent shimmer container', () => { + const msg: AssistantMessage = { + type: 'assistant', + id: 'assistant-thinking', + text: '', + thinking: 'Reasoning remains readable while it streams.', + streaming: true, + } + + act(() => root.render()) + + const row = host.firstElementChild as HTMLElement + expect(row.className).not.toContain('cate-notif-pulse') + expect(host.querySelector('button span')?.className).toContain('cate-notif-pulse') + + act(() => { + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.querySelector('pre')?.textContent).toContain('Reasoning remains readable') + }) +}) diff --git a/src/cateAgent/renderer/ChatMessageRow.tsx b/src/cateAgent/renderer/ChatMessageRow.tsx index eb648ec6..bb5bbd26 100644 --- a/src/cateAgent/renderer/ChatMessageRow.tsx +++ b/src/cateAgent/renderer/ChatMessageRow.tsx @@ -16,6 +16,11 @@ import { Markdown, CursorBlink } from './ChatMarkdown' import { ToolCard, AskUserToolView } from './ChatToolCard' import { SubagentCard } from './ChatSubagentCard' import { PlanReadyCard } from './ChatPlanCard' +import { CodingAgentCard } from './ChatCodingAgentCard' +import { + ORCHESTRATION_TOOL_NAMES, + OrchestrationToolCard, +} from './ChatOrchestrationToolCard' import { formatTime } from './chatShared' interface MessageRowProps { @@ -70,7 +75,7 @@ export const MessageRow = memo(function MessageRow({ } if (msg.type === 'assistant') { return ( -
+
{msg.thinking && }
@@ -125,6 +130,12 @@ export const MessageRow = memo(function MessageRow({ /> ) } + if (msg.type === 'tool' && msg.name === 'create_coding_agent') { + return + } + if (msg.type === 'tool' && ORCHESTRATION_TOOL_NAMES.has(msg.name)) { + return + } return }) diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx new file mode 100644 index 00000000..85e64748 --- /dev/null +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx @@ -0,0 +1,147 @@ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { ToolMessage } from './codingStore' +import { + OrchestrationToolCard, + orchestrationToolSummary, +} from './ChatOrchestrationToolCard' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function message( + name: string, + args: unknown, + result?: unknown, + status: ToolMessage['status'] = 'success', +): ToolMessage { + return { + type: 'tool', + id: `message-${name}`, + toolCallId: `call-${name}`, + name, + args, + status, + ...(result === undefined ? {} : { result: JSON.stringify(result, null, 2) }), + } +} + +describe('orchestration tool presentation', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + }) + + it('uses contextual summaries instead of the generic Used label', () => { + expect(orchestrationToolSummary(message( + 'send_to_coding_agent', + { runId: '12345678-abcd', prompt: 'Run the focused tests' }, + { agentName: 'Codex', status: 'working' }, + ))).toEqual({ + verb: 'Steered', + detail: 'Codex · Run the focused tests', + }) + + expect(orchestrationToolSummary(message( + 'wait_for_coding_agents', + { runIds: ['one'], timeoutSeconds: 60 }, + { timedOut: true, runs: [{ id: 'one', status: 'working' }] }, + ))).toEqual({ + verb: 'Monitored', + detail: '1 coding agent · no change after 60s', + }) + + expect(orchestrationToolSummary(message( + 'wait_for_coding_agents', + { runIds: ['one'] }, + { + timedOut: false, + changedRunIds: ['one'], + runs: [{ id: 'one', agentName: 'Codex', status: 'waiting' }], + }, + ))).toEqual({ + verb: 'Agent update', + detail: 'Codex · waiting', + }) + }) + + it('reveals structured tool input and output from its collapsed summary', () => { + const msg = message( + 'inspect_coding_agent', + { runId: 'run-1' }, + { agentName: 'Codex', status: 'ready', recentOutput: 'All tests passed' }, + ) + act(() => root.render()) + + expect(host.textContent).toContain('Inspected') + expect(host.textContent).not.toContain('recentOutput') + + act(() => { + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.textContent).toContain('Input') + expect(host.textContent).toContain('Run') + expect(host.textContent).toContain('run-1') + expect(host.textContent).toContain('Output') + expect(host.textContent).toContain('Codex') + expect(host.textContent).toContain('ready') + expect(host.textContent).toContain('Terminal output') + expect(host.textContent).toContain('All tests passed') + expect(host.textContent).not.toContain('"runId"') + expect(host.textContent).not.toContain('"recentOutput"') + expect(host.querySelector('.rounded-md')).toBeNull() + }) + + it('renders legacy wait results without exposing their raw prompt payload', () => { + const msg = message( + 'wait_for_coding_agents', + { + runIds: ['fae0053c-7710-43cf-8767-66304ab47e91'], + timeoutSeconds: 8, + }, + { + timedOut: true, + runs: [{ + id: 'fae0053c-7710-43cf-8767-66304ab47e91', + agentId: 'codex', + panelId: '16dbc81e-ad5b-4ae2-b373-7df2861b26ae', + prompt: 'Mission: noisy legacy prompt that should stay hidden', + createdAt: 1_786_000_000_000, + status: 'working', + agentName: 'Codex', + cwd: '/repo', + alive: true, + followUpSupported: true, + statusLine: 'Running tests', + }], + }, + ) + act(() => root.render()) + + act(() => { + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.textContent).toContain('Targets') + expect(host.textContent).toContain('8 seconds') + expect(host.textContent).toContain('No meaningful state change before timeout') + expect(host.textContent).toContain('Codex') + expect(host.textContent).toContain('working') + expect(host.textContent).toContain('/repo') + expect(host.textContent).toContain('Running tests') + expect(host.textContent).not.toContain('Mission: noisy legacy prompt') + expect(host.textContent).not.toContain('"timedOut"') + expect(host.textContent).not.toContain('"prompt"') + expect(host.querySelector('.rounded-md')).toBeNull() + }) +}) diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx new file mode 100644 index 00000000..e55abd19 --- /dev/null +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx @@ -0,0 +1,315 @@ +import { useMemo, useState } from 'react' +import type { ToolMessage } from './codingStore' +import { + codingAgentDisplayName, + parseCodingAgentId, +} from '../../shared/codingAgentRuns' + +export const ORCHESTRATION_TOOL_NAMES: ReadonlySet = new Set([ + 'send_to_coding_agent', + 'wait_for_coding_agents', + 'inspect_coding_agent', + 'stop_coding_agent', +]) + +function resultObject(result: string | undefined): Record { + if (!result) return {} + try { + const parsed = JSON.parse(result) + return parsed && typeof parsed === 'object' ? parsed as Record : {} + } catch { + return {} + } +} + +function compact(text: string, max = 120): string { + const line = text.replace(/\s+/g, ' ').trim() + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} + +function runLabel(args: Record, result: Record): string { + if (typeof result.agentName === 'string') return result.agentName + const runId = typeof args.runId === 'string' ? args.runId : '' + return runId ? `agent ${runId.slice(0, 8)}` : 'coding agent' +} + +function text(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function agentLabel(value: unknown): string { + const agentId = parseCodingAgentId(value) + return agentId ? codingAgentDisplayName(agentId) : text(value) || 'Coding agent' +} + +function DetailField({ + label, + value, + mono = false, +}: { + label: string + value: string + mono?: boolean +}) { + if (!value) return null + return ( +
+ {label} + + {value} + +
+ ) +} + +function RunDetails({ run }: { run: Record }) { + const agent = text(run.agentName) || agentLabel(run.agentId) + return ( +
+ + + + + + +
+ ) +} + +function InputDetails({ + name, + args, +}: { + name: string + args: Record +}) { + if (name === 'create_coding_agent') { + return ( + <> + + + + + + ) + } + if (name === 'send_to_coding_agent') { + return ( + <> + + + + ) + } + if (name === 'wait_for_coding_agents') { + const runIds = Array.isArray(args.runIds) + ? args.runIds.filter((id): id is string => typeof id === 'string') + : [] + return ( + <> + 0 ? runIds.join('\n') : 'All active coding agents'} + mono={runIds.length > 0} + /> + + + ) + } + return +} + +function OutputDetails({ + name, + result, + rawResult, +}: { + name: string + result: Record + rawResult?: string +}) { + if (name === 'wait_for_coding_agents') { + const runs = Array.isArray(result.runs) + ? result.runs.filter((run): run is Record => + !!run && typeof run === 'object') + : [] + const changed = Array.isArray(result.changedRunIds) + ? result.changedRunIds.filter((id) => typeof id === 'string').length + : 0 + return ( + <> + 0 + ? `${changed} coding agent${changed === 1 ? '' : 's'} need attention` + : 'No active coding agents' + } + /> +
+ {runs.map((run, index) => ( +
0 ? 'mt-2 border-t border-strong/40 pt-2' : ''} + > + +
+ ))} +
+ + ) + } + if (Object.keys(result).length > 0) { + return ( + <> + + {typeof result.recentOutput === 'string' && result.recentOutput && ( +
+
Terminal output
+
+              {result.recentOutput}
+            
+
+ )} + + ) + } + return rawResult ? ( +
+ {rawResult} +
+ ) : null +} + +export function OrchestrationToolDetails({ msg }: { msg: ToolMessage }) { + const args = (msg.args ?? {}) as Record + const result = resultObject(msg.result) + const parsed = Object.keys(result).length > 0 + return ( +
+
+
+ Input +
+
+ +
+
+ {(msg.result || msg.error) && ( +
+
+ Output +
+
+ + {msg.error &&
{msg.error}
} +
+
+ )} +
+ ) +} + +export function orchestrationToolSummary(msg: ToolMessage): { verb: string; detail: string } { + const args = (msg.args ?? {}) as Record + const result = resultObject(msg.result) + const running = msg.status === 'running' || msg.status === 'pending' + const failed = msg.status === 'error' || msg.status === 'denied' + + switch (msg.name) { + case 'send_to_coding_agent': + return { + verb: failed ? 'Steering failed' : running ? 'Steering' : 'Steered', + detail: `${runLabel(args, result)} · ${compact(String(args.prompt ?? 'follow-up prompt'))}`, + } + case 'inspect_coding_agent': + return { + verb: failed ? 'Inspection failed' : running ? 'Inspecting' : 'Inspected', + detail: `${runLabel(args, result)}${ + typeof result.status === 'string' ? ` · ${result.status}` : '' + }`, + } + case 'stop_coding_agent': + return { + verb: failed ? 'Stop failed' : running ? 'Stopping' : 'Stopped', + detail: runLabel(args, result), + } + case 'wait_for_coding_agents': { + const runs = Array.isArray(result.runs) ? result.runs as Array> : [] + const requested = Array.isArray(args.runIds) ? args.runIds.length : undefined + const count = runs.length || requested + const subject = count ? `${count} coding agent${count === 1 ? '' : 's'}` : 'coding agents' + if (failed) return { verb: 'Monitoring failed', detail: subject } + if (running) return { verb: 'Monitoring', detail: `${subject} for meaningful changes` } + if (result.timedOut === true) { + return { + verb: 'Monitored', + detail: `${subject} · no change after ${Number(args.timeoutSeconds ?? 60)}s`, + } + } + const changedIds = Array.isArray(result.changedRunIds) + ? result.changedRunIds.filter((id): id is string => typeof id === 'string') + : [] + const changedRuns = runs.filter((run) => changedIds.includes(String(run.id))) + if (changedRuns.length === 1) { + const changedRun = changedRuns[0] + const agent = typeof changedRun.agentName === 'string' ? changedRun.agentName : 'Coding agent' + const status = typeof changedRun.status === 'string' ? changedRun.status : 'updated' + return { verb: 'Agent update', detail: `${agent} · ${status}` } + } + return { + verb: 'Agent update', + detail: changedIds.length ? `${changedIds.length} of ${subject} changed state` : subject, + } + } + default: + return { verb: 'Used', detail: msg.name } + } +} + +export function OrchestrationToolCard({ + msg, + shimmer, +}: { + msg: ToolMessage + shimmer?: boolean +}) { + const [expanded, setExpanded] = useState(false) + const summary = useMemo(() => orchestrationToolSummary(msg), [msg]) + const running = msg.status === 'running' || msg.status === 'pending' + const hasDetails = msg.args != null || !!msg.result || !!msg.error + + return ( +
+ + {expanded && hasDetails && ( +
+ +
+ )} +
+ ) +} diff --git a/src/cateAgent/renderer/CodingChatView.tsx b/src/cateAgent/renderer/CodingChatView.tsx index f855155b..e8668b33 100644 --- a/src/cateAgent/renderer/CodingChatView.tsx +++ b/src/cateAgent/renderer/CodingChatView.tsx @@ -15,12 +15,12 @@ // readyByKey ref, so it too arrives as a prop. // ============================================================================= -import { ChatCircle } from '@phosphor-icons/react' import { ChatThread } from './ChatThread' import { ChatComposer } from '../../renderer/chat/ChatComposer' import { ExtensionDialog, ExtensionWidget, QueueBadges } from './CateAgentPanelChrome' import { useCodingChat, type CodingChatComposerExtras } from './useCodingChat' import type { CodingSlashCommand } from '../../shared/types' +import { CateAgentEmptyState } from './CateAgentEmptyState' export type { CodingChatComposerExtras } @@ -147,22 +147,12 @@ export function CodingChatView({ {messages.length === 0 ? ( -
-
-
- -
-
- What should we work on? -
-
- -
-
-
+ + + ) : ( <> ({ + promptDirectChat: vi.fn(), +})) + +const ROOT = '/repo' +const WS_ID = 'workspace-1' + +beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as unknown as { window: unknown }).window = { + electronAPI: { + projectChatsSave: vi.fn(), + projectChatsLoad: vi.fn(async () => [] as Chat[]), + }, + } + useChatsStore.setState({ + chatsByRoot: { [ROOT]: [] }, + loadedRoots: { [ROOT]: true }, + }) + useCateAgentStore.setState({ byWs: {} }) +}) + +describe('sendDirectAgentMessage', () => { + it('creates and selects a sidebar chat for the first message', () => { + const chatId = sendDirectAgentMessage(WS_ID, ROOT, 'Start here') + const chat = useChatsStore.getState().getChat(ROOT, chatId) + + expect(chat).toMatchObject({ id: chatId, title: 'Start here' }) + expect(useCateAgentStore.getState().byWs[WS_ID]?.activeChatId).toBe(chatId) + expect(promptDirectChat).toHaveBeenCalledWith(chat, WS_ID, ROOT, 'Start here', undefined, undefined) + }) + + it('creates the first message in the requesting agent panel', () => { + const chatId = sendDirectAgentMessage( + WS_ID, + ROOT, + 'Panel task', + undefined, + undefined, + undefined, + 'agent-panel-1', + ) + const chat = useChatsStore.getState().getChat(ROOT, chatId) + + expect(chat?.hostPanelId).toBe('agent-panel-1') + expect(promptDirectChat).toHaveBeenCalledWith(chat, WS_ID, ROOT, 'Panel task', undefined, undefined) + }) +}) diff --git a/src/cateAgent/renderer/cateAgentSend.ts b/src/cateAgent/renderer/cateAgentSend.ts index 1f4b633c..8f622be0 100644 --- a/src/cateAgent/renderer/cateAgentSend.ts +++ b/src/cateAgent/renderer/cateAgentSend.ts @@ -1,6 +1,5 @@ import { useChatsStore } from '../../renderer/stores/chatsStore' import { useCateAgentStore } from './cateAgentStore' -import { setTargetWorktree } from './cateAgentWorktreeTarget' import { promptDirectChat, type DirectChatTurnOptions, @@ -23,8 +22,7 @@ export function sendDirectAgentMessage( hostPanelId?: string, ): string { const chats = useChatsStore.getState() - const chat = chats.createChat(rootPath, deriveChatTitle(text), hostPanelId) - if (worktreeId) setTargetWorktree(chat.id, worktreeId) + const chat = chats.createChat(rootPath, deriveChatTitle(text), hostPanelId, worktreeId) if (!hostPanelId) useCateAgentStore.getState().setActiveChat(wsId, chat.id) void promptDirectChat(chat, wsId, rootPath, text, options, cwd) return chat.id diff --git a/src/cateAgent/renderer/cateAgentStore.test.tsx b/src/cateAgent/renderer/cateAgentStore.test.tsx new file mode 100644 index 00000000..6c5dfcbf --- /dev/null +++ b/src/cateAgent/renderer/cateAgentStore.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useChatsStore } from '../../renderer/stores/chatsStore' +import { + useActiveChatWorktreeByPanel, + useCateAgentStore, +} from './cateAgentStore' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +beforeEach(() => { + useCateAgentStore.setState({ byWs: {}, activeChatByPanel: {} }) + useChatsStore.setState({ + chatsByRoot: { + '/repo': [ + { id: 'chat-a', title: 'A', createdAt: 1, updatedAt: 1, worktreeId: 'wt-a' }, + { id: 'chat-b', title: 'B', createdAt: 2, updatedAt: 2, worktreeId: 'wt-b' }, + ], + }, + loadedRoots: { '/repo': true }, + }) +}) + +let root: Root | null = null +afterEach(() => { + act(() => root?.unmount()) + root = null + document.body.innerHTML = '' +}) + +describe('active Cate Agent chat worktree projection', () => { + it('reactively follows chat switches for canvas terrace membership', () => { + let current: Record = {} + const Probe = () => { + current = useActiveChatWorktreeByPanel() + return null + } + const host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + act(() => root?.render()) + + act(() => useCateAgentStore.getState().setPanelActiveChat('agent-panel', 'chat-a')) + expect(current['agent-panel']).toBe('wt-a') + + act(() => useCateAgentStore.getState().setPanelActiveChat('agent-panel', 'chat-b')) + expect(current['agent-panel']).toBe('wt-b') + + act(() => useCateAgentStore.getState().setPanelActiveChat('agent-panel', null)) + expect(current['agent-panel']).toBeUndefined() + }) +}) diff --git a/src/cateAgent/renderer/cateAgentStore.ts b/src/cateAgent/renderer/cateAgentStore.ts index e1b7c94b..1568c0b1 100644 --- a/src/cateAgent/renderer/cateAgentStore.ts +++ b/src/cateAgent/renderer/cateAgentStore.ts @@ -1,4 +1,6 @@ import { create } from 'zustand' +import { useMemo } from 'react' +import { useChatsStore } from '../../renderer/stores/chatsStore' export interface CateAgentWsState { /** Empty means the sidebar is showing the new-chat surface. */ @@ -11,12 +13,15 @@ export const DEFAULT_CATE_AGENT_WS: CateAgentWsState = { interface CateAgentStore { byWs: Record + activeChatByPanel: Record setActiveChat: (wsId: string, chatId: string) => void + setPanelActiveChat: (panelId: string, chatId: string | null) => void reset: (wsId: string) => void } export const useCateAgentStore = create((set) => ({ byWs: {}, + activeChatByPanel: {}, setActiveChat(wsId, chatId) { set((state) => ({ @@ -27,6 +32,16 @@ export const useCateAgentStore = create((set) => ({ })) }, + setPanelActiveChat(panelId, chatId) { + set((state) => { + if ((state.activeChatByPanel[panelId] ?? '') === (chatId ?? '')) return state + const activeChatByPanel = { ...state.activeChatByPanel } + if (chatId) activeChatByPanel[panelId] = chatId + else delete activeChatByPanel[panelId] + return { activeChatByPanel } + }) + }, + reset(wsId) { set((state) => ({ byWs: { @@ -42,3 +57,37 @@ export function useCateAgentWs(wsId: string | null | undefined): CateAgentWsStat wsId ? state.byWs[wsId] ?? DEFAULT_CATE_AGENT_WS : DEFAULT_CATE_AGENT_WS )) } + +/** Resolve the active chat target for non-React creation paths that inherit the + * selected canvas node's worktree. Chat ids are globally generated, so scanning + * the already-loaded workspace lists is unambiguous. */ +export function activeChatWorktreeIdForPanel(panelId: string): string | undefined { + const chatId = useCateAgentStore.getState().activeChatByPanel[panelId] + if (!chatId) return undefined + for (const chats of Object.values(useChatsStore.getState().chatsByRoot)) { + const chat = chats.find((candidate) => candidate.id === chatId) + if (chat) return chat.worktreeId + } + return undefined +} + +/** Reactive panel projection used only by canvas/tab chrome. The worktree value + * remains owned by Chat; this map is derived UI state, never persisted. */ +export function useActiveChatWorktreeByPanel(): Record { + const activeChatByPanel = useCateAgentStore((state) => state.activeChatByPanel) + const chatsByRoot = useChatsStore((state) => state.chatsByRoot) + return useMemo(() => { + const worktreeByChat = new Map() + for (const chats of Object.values(chatsByRoot)) { + for (const chat of chats) { + if (chat.worktreeId) worktreeByChat.set(chat.id, chat.worktreeId) + } + } + const out: Record = {} + for (const [panelId, chatId] of Object.entries(activeChatByPanel)) { + const worktreeId = worktreeByChat.get(chatId) + if (worktreeId) out[panelId] = worktreeId + } + return out + }, [activeChatByPanel, chatsByRoot]) +} diff --git a/src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts b/src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts deleted file mode 100644 index fc4a1bc4..00000000 --- a/src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @vitest-environment jsdom - -import { beforeEach, describe, expect, it } from 'vitest' -import { - resolveTargetWorktree, - setTargetWorktree, -} from './cateAgentWorktreeTarget' - -beforeEach(() => { - localStorage.clear() -}) - -describe('resolveTargetWorktree', () => { - it('defaults a new panel chat to the worktree the panel was launched from', () => { - expect(resolveTargetWorktree('', 'worktree-from-popup')).toBe('worktree-from-popup') - }) - - it('keeps a chat-specific composer choice ahead of the panel default', () => { - setTargetWorktree('chat-1', 'worktree-picked-below-input') - - expect(resolveTargetWorktree('chat-1', 'worktree-from-popup')) - .toBe('worktree-picked-below-input') - }) -}) diff --git a/src/cateAgent/renderer/cateAgentWorktreeTarget.ts b/src/cateAgent/renderer/cateAgentWorktreeTarget.ts deleted file mode 100644 index 243cb01a..00000000 --- a/src/cateAgent/renderer/cateAgentWorktreeTarget.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ============================================================================= -// cateAgentWorktreeTarget — the WORKTREE a chat works against, picked in the -// composer's worktree pill. The main agent uses it as its cwd. Stored as the -// worktree's stable id (never a branch name or path). -// -// Kept per-chat in localStorage, like the composer draft — ephemeral across -// restarts. Resolve an id to its live branch with `worktreeBranchFor`. -// ============================================================================= - -const key = (chatId: string): string => `cate.targetWorktree.${chatId}` - -export const getTargetWorktree = (chatId: string): string | null => { - try { - return chatId ? localStorage.getItem(key(chatId)) : null - } catch { - return null - } -} - -/** A chat's explicit target wins; a newly-created Agent panel falls back to - * the worktree it was launched from until the chat records its own choice. */ -export const resolveTargetWorktree = ( - chatId: string, - defaultWorktreeId?: string, -): string | null => getTargetWorktree(chatId) ?? defaultWorktreeId ?? null - -export const setTargetWorktree = (chatId: string, worktreeId: string | null): void => { - try { - if (!chatId) return - if (worktreeId) localStorage.setItem(key(chatId), worktreeId) - else localStorage.removeItem(key(chatId)) - } catch { - /* best-effort */ - } -} diff --git a/src/cateAgent/renderer/codingStore.ts b/src/cateAgent/renderer/codingStore.ts index c5f779a3..066f3ba3 100644 --- a/src/cateAgent/renderer/codingStore.ts +++ b/src/cateAgent/renderer/codingStore.ts @@ -15,6 +15,7 @@ import { create } from 'zustand' import log from '../../renderer/lib/logger' +import { agentErrorMessage } from '../../shared/agentErrorMessage' import type { CodingExtensionUIRequest, CodingImageAttachment, @@ -897,7 +898,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: case 'compaction_end': { const reason = asString(event.reason) as CompactionState['reason'] const result = event.result as Record | null - const errorMessage = asString(event.errorMessage) + const rawError = asString(event.errorMessage) useCodingStore.getState().setCompaction(panelId, { active: false, reason, @@ -907,7 +908,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: tokensBefore: asNumber(result.tokensBefore), } : undefined, - lastErrorMessage: errorMessage, + lastErrorMessage: rawError ? agentErrorMessage(rawError) : undefined, }) return } @@ -917,7 +918,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: attempt: asNumber(event.attempt), maxAttempts: asNumber(event.maxAttempts), delayMs: asNumber(event.delayMs), - errorMessage: asString(event.errorMessage), + errorMessage: event.errorMessage ? agentErrorMessage(event.errorMessage) : undefined, finalError: undefined, succeededOnAttempt: undefined, }) @@ -928,7 +929,9 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: useCodingStore.getState().setRetry(panelId, { active: false, succeededOnAttempt: success ? asNumber(event.attempt) : undefined, - finalError: success ? undefined : asString(event.finalError), + finalError: success || !event.finalError + ? undefined + : agentErrorMessage(event.finalError), }) return } @@ -936,9 +939,10 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: const extensionPath = asString(event.extensionPath) ?? '(unknown extension)' const ev = asString(event.event) ?? 'event' const errMsg = asString(event.error) ?? 'unknown error' + log.warn('[agentStore] extension error (%s during %s): %s', extensionPath, ev, errMsg) useCodingStore .getState() - .appendSystem(panelId, `Extension error (${extensionPath} during ${ev}): ${errMsg}`, 'error') + .appendSystem(panelId, agentErrorMessage(`Extension error: ${errMsg}`), 'error') return } case 'extension_ui_request': { @@ -982,7 +986,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: return } case 'error': { - const message = asString(event.message) ?? 'Agent error' + const message = agentErrorMessage(event.message) useCodingStore.getState().appendSystem(panelId, message, 'error') useCodingStore.getState().setRunning(panelId, false) return diff --git a/src/cateAgent/renderer/directChatSession.test.ts b/src/cateAgent/renderer/directChatSession.test.ts new file mode 100644 index 00000000..693ad9d5 --- /dev/null +++ b/src/cateAgent/renderer/directChatSession.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Chat } from '../../shared/types' +import { useCodingStore } from './codingStore' +import { + directAgentKey, + promptDirectChat, +} from './directChatSession' + +const chat: Chat = { + id: 'chat-1', + title: 'New chat', + createdAt: 1, + updatedAt: 1, + model: { provider: 'test-provider', model: 'test-model' }, +} + +beforeEach(() => { + vi.clearAllMocks() + useCodingStore.setState({ panels: {} }) + ;(globalThis as unknown as { window: unknown }).window = { + electronAPI: { + agentCreate: vi.fn(async () => ({ ok: true })), + agentPrompt: vi.fn(async () => {}), + }, + } +}) + +describe('promptDirectChat', () => { + it('appends a new chat’s first message before asynchronous session startup', async () => { + const sending = promptDirectChat(chat, 'workspace-1', '/repo', 'First message') + const agentKey = directAgentKey(chat.id) + + expect(useCodingStore.getState().panels[agentKey]?.messages).toMatchObject([ + { type: 'user', text: 'First message' }, + ]) + await expect(sending).resolves.toBe(true) + }) +}) diff --git a/src/cateAgent/renderer/directChatSession.ts b/src/cateAgent/renderer/directChatSession.ts index 6be0b997..93e21b3f 100644 --- a/src/cateAgent/renderer/directChatSession.ts +++ b/src/cateAgent/renderer/directChatSession.ts @@ -9,6 +9,7 @@ import { codingClient } from './codingClient' import { resolveSessionModel } from './codingModelPrefs' import log from '../../renderer/lib/logger' import type { ComposerPromptMode } from '../../renderer/chat/ChatComposer' +import { agentErrorMessage } from '../../shared/agentErrorMessage' export interface DirectChatTurnOptions { images?: CodingImageAttachment[] @@ -57,7 +58,14 @@ export async function ensureDirectChatSession( sessionFile: chat.sessionFile ?? undefined, }) if (!result.ok) { - store.appendSystem(panelId, `Failed to start agent: ${result.error}`, 'error') + store.appendSystem( + panelId, + agentErrorMessage( + result.error, + 'Cate couldn’t start the agent. Start a new chat and try again.', + ), + 'error', + ) return false } return true @@ -81,9 +89,16 @@ export async function promptDirectChat( cwd = rootPath, ): Promise { const panelId = directAgentKey(chat.id) - if (!(await ensureDirectChatSession(chat, workspaceId, rootPath, cwd))) return false - + const session = ensureDirectChatSession(chat, workspaceId, rootPath, cwd) const store = useCodingStore.getState() + // A brand-new chat has no transcript to hydrate, so show its first message + // immediately while the agent session starts. Otherwise selecting the newly + // created chat briefly renders the empty-chat composer before this message + // arrives and makes the composer jump between layouts. + const appendedOptimistically = !chat.sessionFile + if (appendedOptimistically) store.appendUser(panelId, text) + if (!(await session)) return false + const controlUpdates: Promise[] = [] if (options.thinkingLevel) { store.setThinkingLevel(panelId, options.thinkingLevel) @@ -97,7 +112,7 @@ export async function promptDirectChat( try { await Promise.all(controlUpdates) if (options.promptMode) await codingClient.prompt(panelId, `/${options.promptMode}`) - store.appendUser(panelId, text) + if (!appendedOptimistically) store.appendUser(panelId, text) await codingClient.prompt(panelId, text, options.images) return true } catch (error) { diff --git a/src/cateAgent/renderer/seedWorktreeChat.ts b/src/cateAgent/renderer/seedWorktreeChat.ts new file mode 100644 index 00000000..88fb4ad5 --- /dev/null +++ b/src/cateAgent/renderer/seedWorktreeChat.ts @@ -0,0 +1,22 @@ +import { useAppStore } from '../../renderer/stores/appStore' +import { useChatsStore } from '../../renderer/stores/chatsStore' + +/** Give a newly-created Agent panel its initial worktree by creating a tagged + * chat. The panel itself stays worktree-agnostic. */ +export async function seedAgentPanelWithWorktreeChat( + workspaceId: string, + rootPath: string, + panelId: string, + worktreeId: string, +): Promise { + const chats = useChatsStore.getState() + try { + await chats.loadChats(rootPath) + } catch { + return + } + const panel = useAppStore.getState().getWorkspace(workspaceId)?.panels[panelId] + if (panel?.type !== 'cateAgent') return + const chat = useChatsStore.getState().createChat(rootPath, 'New chat', panelId, worktreeId) + useAppStore.getState().setPanelInitialChat(workspaceId, panelId, chat.id) +} diff --git a/src/cateAgent/renderer/useCodingChat.ts b/src/cateAgent/renderer/useCodingChat.ts index 72cdd935..6673b6e0 100644 --- a/src/cateAgent/renderer/useCodingChat.ts +++ b/src/cateAgent/renderer/useCodingChat.ts @@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import log from '../../renderer/lib/logger' -import { errorMessage as toErrorMessage } from '../../renderer/lib/errorMessage' +import { agentErrorMessage as toErrorMessage } from '../../shared/agentErrorMessage' import { useCodingStore } from './codingStore' import { codingClient } from './codingClient' import { useChatsStore } from '../../renderer/stores/chatsStore' @@ -143,8 +143,17 @@ export function useCodingChat({ const extensionWidgets = slice?.extensionWidgets ?? [] const planModeActive = extensionStatuses.some((status) => status.key === 'plan-mode') const canvasModeActive = extensionStatuses.some((status) => status.key === 'canvas-mode') + const orchestratorModeActive = extensionStatuses.some( + (status) => status.key === 'orchestrator-mode', + ) const activePromptMode: ComposerPromptMode | null = - planModeActive ? 'plan' : canvasModeActive ? 'canvas' : null + planModeActive + ? 'plan' + : canvasModeActive + ? 'canvas' + : orchestratorModeActive + ? 'orchestrate' + : null // Composer draft lives in the active chat's slice so switching chats keeps // each chat's own in-progress message + image attachments. const draft = slice?.draft ?? '' @@ -448,7 +457,7 @@ export function useCodingChat({ }, [agentKey, forkMap, refreshStatsAndState, setDraft]) // --------------------------------------------------------------------------- - // Prompt modes (cate-plan-mode / cate-canvas-mode extensions) + // Prompt modes (plan, canvas, and coding-agent orchestration extensions) // --------------------------------------------------------------------------- const handlePromptModeChange = useCallback(async (mode: ComposerPromptMode | null) => { diff --git a/src/main/extensions/cateApiEndpointManager.ts b/src/main/extensions/cateApiEndpointManager.ts index afdf46bb..2c915db2 100644 --- a/src/main/extensions/cateApiEndpointManager.ts +++ b/src/main/extensions/cateApiEndpointManager.ts @@ -6,8 +6,9 @@ import { runtimes } from '../runtime/runtimeManager' import type { Runtime } from '../runtime/types' import { getWorkspaceInfo } from '../workspaceManager' import type { ReverseTunnelBinding } from './cateApiReverse' +import type { WebContents } from 'electron' -type CateApiEndpointOwner = 'extension' | 'first-party' +type CateApiEndpointOwner = 'extension' | 'first-party' | 'cate-agent' interface CateApiEndpoint { runtime: Runtime @@ -28,8 +29,12 @@ interface CateApiEndpointOptions { extensionId: string workspaceId: string listenerId: string - caller?: 'first-party' + caller?: 'first-party' | 'cate-agent' + panelId?: string + originCwd?: string grantedScopes?: string[] + /** Renderer that owns the embedded supervisor's panel/session. */ + ownerWebContents?: WebContents } export function resolveWorkspaceRuntime(workspaceId: string): { runtime: Runtime; cwd: string } { @@ -73,7 +78,10 @@ export class CateApiEndpointManager { token, runtime, caller: options.caller, + panelId: options.panelId, + originCwd: options.originCwd, grantedScopes: options.grantedScopes, + ownerWebContents: options.ownerWebContents, }) let binding: ReverseTunnelBinding try { diff --git a/src/main/extensions/cateApiHandlers.test.ts b/src/main/extensions/cateApiHandlers.test.ts index 25a976d1..a5e330d0 100644 --- a/src/main/extensions/cateApiHandlers.test.ts +++ b/src/main/extensions/cateApiHandlers.test.ts @@ -72,7 +72,7 @@ vi.mock('./ExtensionManager', () => ({ // importing cateApiHandlers doesn't drag in the proxy/server/IPC machinery. vi.mock('./proxyServer', () => ({ getProxyUrlFor: vi.fn() })) vi.mock('./ExtensionServerManager', () => ({ extensionServerManager: {} })) -const { activeWindow, windowsById, windowPanelList, revealWindowPanel, upsertWindowPanel, removeWindowPanel } = vi.hoisted(() => ({ +const { activeWindow, windowsById, windowPanelList, windowPanelListener, revealWindowPanel, upsertWindowPanel, removeWindowPanel } = vi.hoisted(() => ({ activeWindow: { value: undefined as unknown }, windowsById: new Map(), windowPanelList: { value: [] as Array<{ @@ -84,7 +84,11 @@ const { activeWindow, windowsById, windowPanelList, revealWindowPanel, upsertWin filePath?: string url?: string focused?: boolean + codingAgentRunId?: string + codingAgentOwnerPanelId?: string + codingAgentStatus?: 'starting' | 'working' | 'waiting' | 'ready' | 'stopped' | 'failed' }> }, + windowPanelListener: { value: null as (() => void) | null }, revealWindowPanel: vi.fn(() => true), upsertWindowPanel: vi.fn(), removeWindowPanel: vi.fn(), @@ -95,6 +99,10 @@ vi.mock('../windowRegistry', () => ({ })) vi.mock('../windowPanels', () => ({ getWindowPanels: () => windowPanelList.value, + subscribeWindowPanels: vi.fn((listener: () => void) => { + windowPanelListener.value = listener + return () => { windowPanelListener.value = null } + }), revealWindowPanel, upsertWindowPanel, removeWindowPanel, @@ -135,6 +143,7 @@ vi.mock('./storage', () => ({ import { dispatchCateInvoke, + forwardTimeoutMs, requiredScopeFor, TERMINAL_INPUT_DISABLED, TERMINAL_READ_DISABLED, @@ -147,7 +156,7 @@ import { cliPermissionDenied, cliPermissionForMethod, } from '../../shared/cliPermissions' -import { GRANTED_SCOPES } from './workspaceCateApi' +import { CATE_AGENT_GRANTED_SCOPES, GRANTED_SCOPES } from './workspaceCateApi' const EXT = 'cate.kitchensink' const WS = 'ws-1' @@ -173,6 +182,7 @@ beforeEach(() => { activeWindow.value = undefined windowsById.clear() windowPanelList.value = [] + windowPanelListener.value = null revealWindowPanel.mockClear() revealWindowPanel.mockReturnValue(true) removeWindowPanel.mockClear() @@ -352,6 +362,174 @@ describe('dispatchCateInvoke — Kitchen Sink reverse API', () => { }) }) +describe('dispatchCateInvoke — Cate Agent orchestration boundary', () => { + it('allows a long monitor call without extending unrelated host actions', () => { + expect(forwardTimeoutMs('cate.codingAgent.wait')).toBe(125_000) + expect(forwardTimeoutMs('cate.editor.openFile')).toBe(10_000) + }) + + it('maps every native coding-agent method to its dedicated scope', () => { + for (const verb of ['create', 'send', 'wait', 'inspect', 'stop']) { + expect(requiredScopeFor(`cate.codingAgent.${verb}`)).toBe('coding-agent') + } + }) + + it('forwards orchestration only for the embedded Cate Agent and carries its cwd', async () => { + const forward = vi.fn(async () => ({ id: 'run-1' })) + const result = await dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'cate-direct:chat-1', + caller: 'cate-agent', + originCwd: '/ws/worktrees/feature', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward, + }, 'cate.codingAgent.create', { agentId: 'codex', prompt: 'Implement it' }) + + expect(result).toEqual({ id: 'run-1' }) + expect(forward).toHaveBeenCalledWith(expect.objectContaining({ + method: 'cate.codingAgent.create', + panelId: 'cate-direct:chat-1', + args: { + agentId: 'codex', + prompt: 'Implement it', + _cateOriginCwd: '/ws/worktrees/feature', + }, + })) + }) + + it('denies ordinary first-party terminals and extensions even with the scope', async () => { + const forward = vi.fn() + for (const caller of ['first-party', 'extension'] as const) { + expect(await dispatchCateInvoke({ + extensionId: caller === 'extension' ? EXT : 'terminal', + workspaceId: WS, + panelId: undefined, + caller, + grantedScopes: ['coding-agent'], + forward, + }, 'cate.codingAgent.create', { agentId: 'codex', prompt: 'No' })).toEqual({ + error: 'cate-agent-only', + method: 'cate.codingAgent.create', + }) + } + expect(forward).not.toHaveBeenCalled() + }) + + it('routes a transferred worker to its exact owner window', async () => { + const send = vi.fn(() => { throw new Error('closed for test') }) + windowsById.set(7, { isDestroyed: () => false, webContents: { send } }) + windowPanelList.value = [{ + panelId: 'worker-panel', + type: 'terminal', + ownerWindowId: 7, + codingAgentRunId: 'run-1', + codingAgentOwnerPanelId: 'supervisor-1', + }] + const fallback = vi.fn() + + const result = await dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'supervisor-1', + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward: fallback, + }, 'cate.codingAgent.inspect', { runId: 'run-1' }) + + expect(result).toEqual({ error: 'no-owner', method: 'cate.codingAgent.inspect' }) + expect(send).toHaveBeenCalledOnce() + expect(fallback).not.toHaveBeenCalled() + }) + + it('coordinates one event-driven wait across workers in different windows', async () => { + const sendA = vi.fn(() => { throw new Error('closed for test') }) + const sendB = vi.fn(() => { throw new Error('closed for test') }) + windowsById.set(7, { isDestroyed: () => false, webContents: { send: sendA } }) + windowsById.set(8, { isDestroyed: () => false, webContents: { send: sendB } }) + windowPanelList.value = [ + { + panelId: 'worker-a', + type: 'terminal', + ownerWindowId: 7, + codingAgentRunId: 'run-a', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'ready', + }, + { + panelId: 'worker-b', + type: 'terminal', + ownerWindowId: 8, + codingAgentRunId: 'run-b', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'waiting', + }, + ] + + const result = await dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'supervisor-1', + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward: vi.fn(), + }, 'cate.codingAgent.wait', { runIds: ['run-a', 'run-b'] }) + + expect(result).toEqual({ + timedOut: false, + changedRunIds: ['run-a', 'run-b'], + runs: [ + { id: 'run-a', panelId: 'worker-a', status: 'ready' }, + { id: 'run-b', panelId: 'worker-b', status: 'waiting' }, + ], + }) + expect(sendA).toHaveBeenCalledOnce() + expect(sendB).toHaveBeenCalledOnce() + }) + + it('wakes a cross-window wait from the shared window-panel event stream', async () => { + const send = vi.fn(() => { throw new Error('closed for test') }) + windowsById.set(7, { isDestroyed: () => false, webContents: { send } }) + windowsById.set(8, { isDestroyed: () => false, webContents: { send } }) + windowPanelList.value = [ + { + panelId: 'worker-a', + type: 'terminal', + ownerWindowId: 7, + codingAgentRunId: 'run-a', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'working', + }, + { + panelId: 'worker-b', + type: 'terminal', + ownerWindowId: 8, + codingAgentRunId: 'run-b', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'working', + }, + ] + const waiting = dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'supervisor-1', + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward: vi.fn(), + }, 'cate.codingAgent.wait', { runIds: ['run-a', 'run-b'] }) + + expect(windowPanelListener.value).toBeTypeOf('function') + windowPanelList.value[1].codingAgentStatus = 'ready' + windowPanelListener.value!() + + await expect(waiting).resolves.toMatchObject({ + timedOut: false, + changedRunIds: ['run-b'], + }) + expect(windowPanelListener.value).toBeNull() + }) +}) + describe('dispatchCateInvoke — cate.agent.* (open/send/dispose; run is gone)', () => { const fakeWin = { isDestroyed: () => false, webContents: {} } // Consent is granted once per extension for the app session, so each test diff --git a/src/main/extensions/cateApiHandlers.ts b/src/main/extensions/cateApiHandlers.ts index 19d13532..32fc6106 100644 --- a/src/main/extensions/cateApiHandlers.ts +++ b/src/main/extensions/cateApiHandlers.ts @@ -65,12 +65,19 @@ import { codingManager } from '../../cateAgent/main/codingManager' import { getExtensionStorage } from './storage' import { getWorkspaceInfo } from '../workspaceManager' import { getActiveMainWindow, getWindow } from '../windowRegistry' -import { getWindowPanels, removeWindowPanel, revealWindowPanel, upsertWindowPanel } from '../windowPanels' +import { + getWindowPanels, + removeWindowPanel, + revealWindowPanel, + subscribeWindowPanels, + upsertWindowPanel, +} from '../windowPanels' import { parseLocator, LOCAL_RUNTIME_ID } from '../../shared/runtimeLocator' import { getAllSettings, getSetting } from '../settingsFile' import { resolveActiveTheme } from '../themeBootCache' import { showOsNotification } from '../ipc/notifications' -import type { PanelType } from '../../shared/types' +import type { PanelType, WindowPanelInfo } from '../../shared/types' +import type { CodingAgentRunStatus } from '../../shared/codingAgentRuns' /** Bumped when the cateHost API surface changes incompatibly. Guests use * `cate.version` for feature detection. @@ -94,6 +101,13 @@ import type { PanelType } from '../../shared/types' const CATE_API_VERSION = 6 const FORWARD_TIMEOUT_MS = 10_000 +const CODING_AGENT_WAIT_FORWARD_TIMEOUT_MS = 125_000 + +export function forwardTimeoutMs(method: string): number { + return method === 'cate.codingAgent.wait' + ? CODING_AGENT_WAIT_FORWARD_TIMEOUT_MS + : FORWARD_TIMEOUT_MS +} /** Stable errors for CLI permission cells (Settings → CLI) that are off. Each * tells the caller how to get the feature enabled, not just that it is denied. @@ -125,11 +139,13 @@ export interface InvokeScope { /** Who is calling. First-party (terminal/agent via the CLI/reverse endpoint) * callers are trusted: they skip the extension-enabled gate and the browser * consent prompt. Undefined is treated as 'extension'. */ - caller?: 'extension' | 'first-party' + caller?: 'extension' | 'first-party' | 'cate-agent' /** Scopes the caller was granted. For first-party callers this is supplied by * the env-manager instead of a manifest; when absent the extension manifest's * `cateApi` is used. */ grantedScopes?: string[] + /** Runtime-absolute cwd of an embedded supervisor session. */ + originCwd?: string } // --------------------------------------------------------------------------- @@ -175,7 +191,7 @@ export function forwardToOwner( const timer = setTimeout(() => { pendingForwards.delete(requestId) resolve({ error: 'timeout', method: payload.method }) - }, FORWARD_TIMEOUT_MS) + }, forwardTimeoutMs(payload.method)) pendingForwards.set(requestId, { resolve, timer }) try { owner.send(CATE_HOST_FORWARD, { @@ -234,6 +250,167 @@ function resolvePanelTargetWindow( return { wc: win.webContents, ownerWindowId: win.id } } +/** Resolve a mission worker to the renderer currently hosting its terminal. + * Window reports carry both the run and supervisor identities, preventing one + * Cate Agent session from using a guessed run id to reach another mission. */ +function resolveCodingAgentTargetWindow( + runIds: string[], + ownerPanelId: string, +): { wc: WebContents } | { error: string } | null { + if (runIds.length === 0) return null + const reports = runIds.map((runId) => + getWindowPanels().find((panel) => + panel.type === 'terminal' && + panel.codingAgentRunId === runId && + panel.codingAgentOwnerPanelId === ownerPanelId, + ), + ) + // A just-created panel may not have reached the debounced discovery report + // yet. The supervisor-bound forward remains exact in that common case. + if (reports.some((report) => !report)) return null + const ownerIds = new Set(reports.map((report) => report!.ownerWindowId)) + if (ownerIds.size !== 1) return { error: 'coding-agent-runs-span-windows' } + const win = getWindow([...ownerIds][0]) + if (!win || win.isDestroyed()) return { error: 'coding-agent-window-not-found' } + return { wc: win.webContents } +} + +const ACTIONABLE_CODING_AGENT_STATUSES = new Set([ + 'waiting', + 'ready', + 'stopped', + 'failed', +]) + +function codingAgentReports( + runIds: string[], + ownerPanelId: string, +): WindowPanelInfo[] | null { + const reports = runIds.map((runId) => + getWindowPanels().find((panel) => + panel.type === 'terminal' && + panel.codingAgentRunId === runId && + panel.codingAgentOwnerPanelId === ownerPanelId, + ), + ) + return reports.some((report) => !report) ? null : reports as WindowPanelInfo[] +} + +async function inspectCodingAgentReports(args: { + reports: WindowPanelInfo[] + extensionId: string + workspaceId: string + ownerPanelId: string + originCwd?: string +}): Promise { + return Promise.all(args.reports.map(async (report) => { + const win = getWindow(report.ownerWindowId) + if (!win || win.isDestroyed()) { + return { + id: report.codingAgentRunId, + panelId: report.panelId, + status: report.codingAgentStatus, + } + } + const result = await forwardToOwner(win.webContents, { + extensionId: args.extensionId, + workspaceId: args.workspaceId, + panelId: args.ownerPanelId, + method: 'cate.codingAgent.inspect', + args: { + runId: report.codingAgentRunId, + _cateOriginCwd: args.originCwd, + }, + }) + if (result && typeof result === 'object' && !('error' in result)) { + const { recentOutput: _recentOutput, ...compact } = + result as Record + return compact + } + return { + id: report.codingAgentRunId, + panelId: report.panelId, + status: report.codingAgentStatus, + } + })) +} + +/** Wait across workers hosted by different renderers using the existing + * cross-window discovery events. No renderer polling and no orphaned parallel + * wait requests: one main-process subscription observes all worker statuses. */ +function waitForCrossWindowCodingAgents(args: { + runIds: string[] + extensionId: string + workspaceId: string + ownerPanelId: string + originCwd?: string + timeoutSeconds: unknown +}): Promise { + const initial = codingAgentReports(args.runIds, args.ownerPanelId) + if (!initial || initial.some((report) => !report.codingAgentStatus)) { + return Promise.resolve({ + error: 'coding-agent-status-unavailable', + method: 'cate.codingAgent.wait', + }) + } + const baseline = new Map(initial.map((report) => [ + report.codingAgentRunId!, + report.codingAgentStatus!, + ])) + const requestedSeconds = Number(args.timeoutSeconds ?? 60) + const timeoutMs = (Number.isFinite(requestedSeconds) + ? Math.max(15, Math.min(120, requestedSeconds)) + : 60) * 1_000 + + return new Promise((resolve) => { + let settled = false + let unsubscribe = () => {} + const finish = async ( + timedOut: boolean, + changedRunIds: string[], + reports: WindowPanelInfo[], + ): Promise => { + if (settled) return + settled = true + clearTimeout(timer) + unsubscribe() + resolve({ + timedOut, + changedRunIds, + runs: await inspectCodingAgentReports({ reports, ...args }), + }) + } + const check = (): void => { + const reports = codingAgentReports(args.runIds, args.ownerPanelId) + if (!reports) { + void finish(false, args.runIds, initial) + return + } + const changedRunIds = reports + .filter((report) => + report.codingAgentStatus !== baseline.get(report.codingAgentRunId!) && + report.codingAgentStatus !== undefined && + ACTIONABLE_CODING_AGENT_STATUSES.has(report.codingAgentStatus), + ) + .map((report) => report.codingAgentRunId!) + if (changedRunIds.length > 0) void finish(false, changedRunIds, reports) + } + const initialActionable = initial + .filter((report) => ACTIONABLE_CODING_AGENT_STATUSES.has(report.codingAgentStatus!)) + .map((report) => report.codingAgentRunId!) + const timer = setTimeout(() => { + const reports = codingAgentReports(args.runIds, args.ownerPanelId) ?? initial + void finish(true, [], reports) + }, timeoutMs) + unsubscribe = subscribeWindowPanels(check) + if (initialActionable.length > 0) { + void finish(false, initialActionable, initial) + return + } + check() + }) +} + // --------------------------------------------------------------------------- // Method dispatch // --------------------------------------------------------------------------- @@ -266,6 +443,12 @@ export function requiredScopeFor(method: string): string | null | undefined { return 'theme' case 'cate.ui.notify': return 'ui' + case 'cate.codingAgent.create': + case 'cate.codingAgent.send': + case 'cate.codingAgent.wait': + case 'cate.codingAgent.inspect': + case 'cate.codingAgent.stop': + return 'coding-agent' case 'cate.editor.openFile': return 'editor.write' // Unlike the self-identity panel.* methods below, these read or steer OTHER @@ -416,16 +599,24 @@ export async function dispatchCateInvoke( args: unknown, ): Promise { const { extensionId, workspaceId, panelId } = scope + const trustedCaller = scope.caller === 'first-party' || scope.caller === 'cate-agent' // Security: only enabled, known extensions may call the host. First-party - // (terminal/agent) callers are trusted and skip this gate. - if (scope.caller !== 'first-party' && (!extensionManager.isKnown(extensionId) || !extensionManager.isEnabled(extensionId))) { + // terminals and the embedded Cate Agent are trusted and skip this gate. + if (!trustedCaller && (!extensionManager.isKnown(extensionId) || !extensionManager.isEnabled(extensionId))) { return { error: 'not-enabled', method } } + // Coding-agent orchestration is the embedded supervisor's privileged surface. + // Ordinary terminals, extension webviews, and spawned workers never receive + // its token, even if they self-declare a matching scope. + if (method.startsWith('cate.codingAgent.') && scope.caller !== 'cate-agent') { + return { error: 'cate-agent-only', method } + } + // The terminal/agent endpoint must never move the user's window, panel focus, // or canvas camera. Extensions retain panel.focus behind their panel scope. - if (scope.caller === 'first-party' && method === 'cate.panel.focus') { + if (trustedCaller && method === 'cate.panel.focus') { return unsupported(method) } @@ -460,6 +651,43 @@ export async function dispatchCateInvoke( } } + if (method.startsWith('cate.codingAgent.')) { + const routedArgs: Record = { + ...((args ?? {}) as Record), + _cateOriginCwd: scope.originCwd, + } + const name = method.slice('cate.codingAgent.'.length) + const requestedRunIds = name === 'wait' + ? (Array.isArray(routedArgs.runIds) + ? routedArgs.runIds.filter((id: unknown): id is string => typeof id === 'string') + : []) + : typeof routedArgs.runId === 'string' ? [routedArgs.runId] : [] + const target = name === 'create' + ? null + : resolveCodingAgentTargetWindow(requestedRunIds, panelId ?? '') + if (target && 'error' in target) { + if (name === 'wait' && target.error === 'coding-agent-runs-span-windows') { + return waitForCrossWindowCodingAgents({ + runIds: requestedRunIds, + extensionId, + workspaceId, + ownerPanelId: panelId ?? '', + originCwd: scope.originCwd, + timeoutSeconds: routedArgs.timeoutSeconds, + }) + } + return { error: target.error, method } + } + const payload = { + extensionId, + workspaceId, + panelId: panelId ?? '', + method, + args: routedArgs, + } + return target ? forwardToOwner(target.wc, payload) : scope.forward(payload) + } + // Storage (handled in main, backed by storage.ts). Routed by prefix — mirrors // requiredScopeFor's storage.* branch — so dispatchStorage's switch is the sole // enumeration of the six storage methods. @@ -477,7 +705,7 @@ export async function dispatchCateInvoke( // Two flavors of the same gate: extensions get a one-time-per-session // consent prompt (mirroring agent); the first-party CLI was already checked // against its Browser read/control cells above, so it needs no prompt. - if (scope.caller !== 'first-party' && !(await ensureConsent(extensionId, 'browser'))) { + if (!trustedCaller && !(await ensureConsent(extensionId, 'browser'))) { return { error: 'consent-denied', method } } const result = await forwardToOwner(target.wc, { extensionId, workspaceId, panelId: panelId ?? '', method, args }) @@ -507,7 +735,7 @@ export async function dispatchCateInvoke( // self-declared, and the terminal consent story (prompt vs toggle) is // deferred until a real extension consumer exists. Revisit alongside // ConsentCapability if one appears. - if (scope.caller !== 'first-party') { + if (!trustedCaller) { return { error: 'terminal-first-party-only', method } } const a = (args ?? {}) as { panelId?: string } diff --git a/src/main/extensions/cateApiReverse.test.ts b/src/main/extensions/cateApiReverse.test.ts index 37388d93..bb336bf0 100644 --- a/src/main/extensions/cateApiReverse.test.ts +++ b/src/main/extensions/cateApiReverse.test.ts @@ -13,9 +13,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // Dispatch core: an in-memory storage impl so the set->get round-trip is real. const store = vi.hoisted(() => new Map()) const dispatchCateInvoke = vi.hoisted(() => vi.fn()) +const forwardToOwner = vi.hoisted(() => vi.fn(async () => ({ ok: true }))) vi.mock('./cateApiHandlers', () => ({ dispatchCateInvoke, forwardToActiveWindow: vi.fn(async () => ({ error: 'no-host-window' })), + forwardToOwner, })) vi.mock('../logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) @@ -98,6 +100,34 @@ beforeEach(() => { }) describe('createCateApiReverse — server-side CATE_API endpoint', () => { + it('binds Cate Agent forwarding to its owning renderer', async () => { + const host = makeRuntime() + const owner = { isDestroyed: () => false } as never + const endpoint = createCateApiReverse({ + extensionId: 'cate-agent', + workspaceId: 'ws-1', + token: TOKEN, + runtime: host.runtime, + caller: 'cate-agent', + ownerWebContents: owner, + }) + await request(endpoint, host.output, { + json: { method: 'cate.codingAgent.create', args: { prompt: 'Implement it' } }, + }) + + const scope = dispatchCateInvoke.mock.calls[0][0] + const payload = { + extensionId: 'cate-agent', + workspaceId: 'ws-1', + panelId: '', + method: 'cate.codingAgent.create', + args: {}, + } + await scope.forward(payload) + expect(forwardToOwner).toHaveBeenCalledWith(owner, payload) + endpoint.dispose() + }) + it('round-trips storage.set then storage.get (the Kitchen Sink roundtrip)', async () => { // Two requests = two connections (HTTP/1.1 Connection: close). Each endpoint // gets its own runtime/output, but both dispatch into the shared store, so diff --git a/src/main/extensions/cateApiReverse.ts b/src/main/extensions/cateApiReverse.ts index c888fe44..8edf11f6 100644 --- a/src/main/extensions/cateApiReverse.ts +++ b/src/main/extensions/cateApiReverse.ts @@ -18,7 +18,8 @@ import http from 'http' import { Duplex } from 'stream' import log from '../logger' import type { Runtime } from '../runtime/types' -import { dispatchCateInvoke, forwardToActiveWindow } from './cateApiHandlers' +import type { WebContents } from 'electron' +import { dispatchCateInvoke, forwardToActiveWindow, forwardToOwner } from './cateApiHandlers' import { reverseDuplex } from './serverTunnel' const MAX_BODY_BYTES = 1 * 1024 * 1024 @@ -31,10 +32,17 @@ export interface ReverseSession { /** First-party (terminal/agent) callers skip the extension-enabled gate and * browser consent prompt. Absent for extension-server sessions (the default). * `extensionId` may be a sentinel string for first-party sessions. */ - caller?: 'first-party' + caller?: 'first-party' | 'cate-agent' + /** Owning Cate Agent session/panel for native worktree affinity. */ + panelId?: string + /** Runtime-absolute cwd of the embedded supervisor session. */ + originCwd?: string /** Scopes granted to a first-party caller (used instead of a manifest's * `cateApi`). Absent for extension-server sessions. */ grantedScopes?: string[] + /** Exact renderer hosting the embedded Cate Agent. Server extensions do not + * have one and retain the active-window fallback. */ + ownerWebContents?: WebContents } export interface CateApiReverseEndpoint { @@ -100,16 +108,19 @@ export function createCateApiReverse(session: ReverseSession): CateApiReverseEnd workspaceId: session.workspaceId, // No owning panel/sender on the server side: panel-scoped storage and // forwarded methods target the workspace best-effort. - panelId: undefined, + panelId: session.panelId, // State-mutating methods (editor.openFile / canvas.createPanel / // panel.setTitle) need a renderer. The server has no sender, so we // forward to the active main window (best-effort — there's no // authoritative workspace→window map for main windows). - forward: forwardToActiveWindow, + forward: session.ownerWebContents && !session.ownerWebContents.isDestroyed() + ? (payload) => forwardToOwner(session.ownerWebContents!, payload) + : forwardToActiveWindow, // Absent for extension-server sessions (undefined => 'extension' // gate + manifest scopes); set for first-party terminal/agent callers. caller: session.caller, grantedScopes: session.grantedScopes, + originCwd: session.originCwd, }, method, parsed.args, diff --git a/src/main/extensions/workspaceCateApi.test.ts b/src/main/extensions/workspaceCateApi.test.ts index c4b498d3..5a3c1e8e 100644 --- a/src/main/extensions/workspaceCateApi.test.ts +++ b/src/main/extensions/workspaceCateApi.test.ts @@ -42,7 +42,11 @@ vi.mock('./cateApiReverse', async (importActual) => ({ })) vi.mock('../logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) -import { WorkspaceCateApiManager, GRANTED_SCOPES } from './workspaceCateApi' +import { + WorkspaceCateApiManager, + CATE_AGENT_GRANTED_SCOPES, + GRANTED_SCOPES, +} from './workspaceCateApi' beforeEach(() => { settingsState.cliEnabled = true @@ -65,6 +69,11 @@ describe('GRANTED_SCOPES contract', () => { // workspace root, so the CLI has no verbs (and thus no grants) for them. expect([...GRANTED_SCOPES]).toEqual(['browser', 'ui', 'editor', 'canvas', 'panel', 'terminal']) }) + + it('reserves coding-agent orchestration for the embedded supervisor', () => { + expect(GRANTED_SCOPES).not.toContain('coding-agent') + expect(CATE_AGENT_GRANTED_SCOPES).toContain('coding-agent') + }) }) describe('WorkspaceCateApiManager.ensureEndpoint', () => { @@ -178,6 +187,25 @@ describe('WorkspaceCateApiManager.ensureEndpoint', () => { }) }) +describe('WorkspaceCateApiManager.ensureCateAgentEndpoint', () => { + it('mints a panel-bound endpoint even when the terminal CLI is disabled', async () => { + settingsState.cliEnabled = false + const mgr = new WorkspaceCateApiManager() + const owner = { id: 42 } as never + const endpoint = await mgr.ensureCateAgentEndpoint('ws1', 'chat-1', '/ws/worktree', owner) + + expect(endpoint).toEqual({ port: 54321, token: expect.any(String) }) + expect(reverseCalls).toHaveLength(1) + expect(reverseCalls[0]).toMatchObject({ + caller: 'cate-agent', + panelId: 'chat-1', + originCwd: '/ws/worktree', + ownerWebContents: owner, + grantedScopes: expect.arrayContaining(['coding-agent']), + }) + }) +}) + describe('WorkspaceCateApiManager tunnel connection callbacks', () => { // Pull the (onConnection, onData, onClose) trio the manager wired into // runtime.tunnel.listen for the freshly-minted endpoint. diff --git a/src/main/extensions/workspaceCateApi.ts b/src/main/extensions/workspaceCateApi.ts index 778106f7..c06811eb 100644 --- a/src/main/extensions/workspaceCateApi.ts +++ b/src/main/extensions/workspaceCateApi.ts @@ -5,9 +5,12 @@ import log from '../logger' import { getSetting } from '../settingsFile' import { CateApiEndpointManager, cateApiEndpointManager } from './cateApiEndpointManager' +import type { WebContents } from 'electron' const FIRST_PARTY_ID = 'terminal' const endpointKey = (workspaceId: string): string => `first-party:${workspaceId}` +const cateAgentEndpointKey = (workspaceId: string, panelId: string): string => + `cate-agent:${workspaceId}:${panelId}` // Only scopes the CLI has verbs for. workspace.read/theme exist for extensions // (webviews with no filesystem) — a terminal's cwd IS the workspace root, so @@ -21,12 +24,19 @@ export const GRANTED_SCOPES: readonly string[] = [ 'terminal', ] +export const CATE_AGENT_GRANTED_SCOPES: readonly string[] = [ + ...GRANTED_SCOPES, + 'coding-agent', +] + export interface WorkspaceCateApiEndpoint { port: number token: string } export class WorkspaceCateApiManager { + private readonly cateAgentKeys = new Map>() + constructor(private readonly endpoints = new CateApiEndpointManager()) {} async ensureEndpoint(workspaceId: string): Promise { @@ -49,19 +59,68 @@ export class WorkspaceCateApiManager { } } + /** A token minted only for the embedded Cate Agent. Unlike the token injected + * into ordinary terminals, it may create and supervise coding agents. The + * endpoint is bound to the originating agent panel/session for worktree + * affinity and cannot be obtained by a spawned worker. */ + async ensureCateAgentEndpoint( + workspaceId: string, + panelId: string, + originCwd: string, + ownerWebContents?: WebContents, + ): Promise { + try { + const endpoint = await this.endpoints.ensure({ + key: cateAgentEndpointKey(workspaceId, panelId), + owner: 'cate-agent', + extensionId: 'cate-agent', + workspaceId, + panelId, + originCwd, + listenerId: `cateapi-agent-${workspaceId}-${panelId}`, + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + ownerWebContents, + }) + const keys = this.cateAgentKeys.get(workspaceId) ?? new Set() + keys.add(cateAgentEndpointKey(workspaceId, panelId)) + this.cateAgentKeys.set(workspaceId, keys) + log.info('[workspace-cateapi] Cate Agent endpoint up ws=%s panel=%s port=%d', workspaceId, panelId, endpoint.port) + return { port: endpoint.port, token: endpoint.token } + } catch (err) { + log.warn('[workspace-cateapi] failed to open Cate Agent listener for %s: %O', workspaceId, err) + return null + } + } + + disposeCateAgentEndpoint(workspaceId: string, panelId: string): void { + const key = cateAgentEndpointKey(workspaceId, panelId) + this.endpoints.dispose(key) + const keys = this.cateAgentKeys.get(workspaceId) + keys?.delete(key) + if (keys?.size === 0) this.cateAgentKeys.delete(workspaceId) + } + /** Tear down a single workspace's first-party endpoint. The local runtime never * disconnects during app life, so without this every opened-then-closed * workspace would leak its loopback listener + http.Server for the session. */ disposeForWorkspace(workspaceId: string): void { this.endpoints.dispose(endpointKey(workspaceId)) + for (const key of this.cateAgentKeys.get(workspaceId) ?? []) { + this.endpoints.dispose(key) + } + this.cateAgentKeys.delete(workspaceId) } disposeForRuntime(runtimeId: string): void { this.endpoints.disposeForRuntime('first-party', runtimeId) + this.endpoints.disposeForRuntime('cate-agent', runtimeId) } disposeAll(): void { this.endpoints.disposeAll('first-party') + this.endpoints.disposeAll('cate-agent') + this.cateAgentKeys.clear() } } diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts index 63aa1cfb..9d82c7e9 100644 --- a/src/main/ipc/terminal.ts +++ b/src/main/ipc/terminal.ts @@ -48,6 +48,7 @@ import { workspaceCateApi } from '../extensions/workspaceCateApi' import { getWorkspaceInfo } from '../workspaceManager' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' import { resolveWorktreeContext, validateWorktreeContext } from '../worktreeContext' +import { codingAgentCommand, type CodingAgentLaunch } from '../../shared/codingAgentRuns' // Set true during app shutdown so PTY data/exit callbacks no-op instead of // calling into a torn-down JS environment. @@ -238,6 +239,7 @@ async function spawnTerminal( workspaceId?: string panelId?: string placementGroupId?: string + codingAgentLaunch?: CodingAgentLaunch }, ownerWindowId: number, ): Promise { @@ -359,6 +361,9 @@ async function spawnTerminal( rows: options.rows, cwd, shell: options.shell, + ...(options.codingAgentLaunch + ? { command: codingAgentCommand(options.codingAgentLaunch) } + : {}), env: cateApiEnv, agentHooks: true, agentHookConfig, @@ -433,6 +438,7 @@ export function registerHandlers(): void { workspaceId?: string panelId?: string placementGroupId?: string + codingAgentLaunch?: CodingAgentLaunch }): Promise => { const win = windowFromEvent(event) const windowId = win?.id ?? -1 diff --git a/src/main/projectChatsStore.test.ts b/src/main/projectChatsStore.test.ts index dc8fc9d1..4a3b5789 100644 --- a/src/main/projectChatsStore.test.ts +++ b/src/main/projectChatsStore.test.ts @@ -54,6 +54,7 @@ describe('projectChatsStore', () => { createdAt: 1, updatedAt: 2, hostPanelId: 'agent-panel-1', + worktreeId: 'wt-feature', sessionFile: '/tmp/direct-chat.jsonl', model: { provider: 'anthropic', model: 'claude-sonnet' }, } diff --git a/src/main/projectChatsStore.ts b/src/main/projectChatsStore.ts index bed7ed8b..e7de61b3 100644 --- a/src/main/projectChatsStore.ts +++ b/src/main/projectChatsStore.ts @@ -43,6 +43,7 @@ function normalizeChat(raw: unknown): Chat | null { updatedAt: typeof o.updatedAt === 'number' ? o.updatedAt : 0, } if (typeof o.hostPanelId === 'string') chat.hostPanelId = o.hostPanelId + if (typeof o.worktreeId === 'string') chat.worktreeId = o.worktreeId if (typeof o.sessionFile === 'string') chat.sessionFile = o.sessionFile const m = o.model if (m && typeof m === 'object' && typeof (m as Record).provider === 'string' && typeof (m as Record).model === 'string') { diff --git a/src/main/runtime/types.ts b/src/main/runtime/types.ts index c6bd7767..18bb94aa 100644 --- a/src/main/runtime/types.ts +++ b/src/main/runtime/types.ts @@ -29,6 +29,10 @@ export interface PtyCreateOptions { cwd: string /** Requested shell; the host resolves + falls back as today (resolveShell). */ shell?: string + /** Optional exact process launch. Only main-process trusted code may set + * this; renderer IPC accepts a closed CodingAgentLaunch instead and main + * resolves it through the canonical agent registry. */ + command?: { executable: string; args: string[] } /** Caller-provided id. Used over the wire so the client registers its data * stream before the create round-trip resolves (no early-output race). */ id?: string diff --git a/src/main/windowPanels.ts b/src/main/windowPanels.ts index 054664b6..8bdbdf6c 100644 --- a/src/main/windowPanels.ts +++ b/src/main/windowPanels.ts @@ -24,6 +24,7 @@ import { broadcastToAll, focusWindow, getWindow, getWindowType, onWindowClosed, /** The latest panel report from each window, keyed by Electron window id. */ const windowPanels = new Map() +const windowPanelListeners = new Set<() => void>() /** Store a window's reported panels (stamped with its owner id + type) and * rebroadcast the union. Ignored if the window isn't tracked (e.g. a late @@ -48,6 +49,9 @@ export function setWindowPanels(windowId: number, report: WindowPanelReport[]): agentState: p.agentState, agentName: p.agentName, hasPorts: p.hasPorts, + codingAgentRunId: p.codingAgentRunId, + codingAgentOwnerPanelId: p.codingAgentOwnerPanelId, + codingAgentStatus: p.codingAgentStatus, })), ) broadcastWindowPanels() @@ -76,6 +80,9 @@ export function upsertWindowPanel(windowId: number, panel: WindowPanelReport): v agentState: panel.agentState, agentName: panel.agentName, hasPorts: panel.hasPorts, + codingAgentRunId: panel.codingAgentRunId, + codingAgentOwnerPanelId: panel.codingAgentOwnerPanelId, + codingAgentStatus: panel.codingAgentStatus, } const index = panels.findIndex((candidate) => candidate.panelId === panel.panelId) windowPanels.set(windowId, index < 0 @@ -116,6 +123,13 @@ export function getWindowPanels(): WindowPanelInfo[] { return result } +/** Observe real changes to the cross-window union. Mission waits use this same + * event-driven registry instead of polling renderer windows. */ +export function subscribeWindowPanels(listener: () => void): () => void { + windowPanelListeners.add(listener) + return () => windowPanelListeners.delete(listener) +} + // The union is rebroadcast on every window report, so guard on a cheap signature // to push only on real changes. let lastWindowPanelSignature = '' @@ -125,12 +139,13 @@ let lastWindowPanelSignature = '' export function broadcastWindowPanels(): void { const panels = getWindowPanels() const signature = panels - .map((p) => `${p.ownerWindowId}:${p.panelId}:${p.type}:${p.title}:${p.workspaceId}:${p.filePath ?? ''}:${p.url ?? ''}:${p.focused ? 1 : 0}:${p.parentCanvasId ?? ''}:${p.worktreeId ?? ''}:${p.agentState ?? ''}:${p.agentName ?? ''}:${p.hasPorts ? 1 : 0}`) + .map((p) => `${p.ownerWindowId}:${p.panelId}:${p.type}:${p.title}:${p.workspaceId}:${p.filePath ?? ''}:${p.url ?? ''}:${p.focused ? 1 : 0}:${p.parentCanvasId ?? ''}:${p.worktreeId ?? ''}:${p.agentState ?? ''}:${p.agentName ?? ''}:${p.hasPorts ? 1 : 0}:${p.codingAgentRunId ?? ''}:${p.codingAgentOwnerPanelId ?? ''}:${p.codingAgentStatus ?? ''}`) .sort() .join('|') if (signature === lastWindowPanelSignature) return lastWindowPanelSignature = signature broadcastToAll(WINDOW_PANELS_CHANGED, panels) + for (const listener of windowPanelListeners) listener() } /** Focus the window that owns `panelId` and ask it to reveal the panel within diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 9df94d25..4fcba4d4 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -25,6 +25,7 @@ import { setPendingReveal } from '../lib/editor/editorReveal' import { CHAT_DRAG_MIME, readChatDrag } from '../drag/fileDragPayload' import { createSeededChatPanel } from '../drag/openChatDrop' import { endChatDrag } from '../drag/chatDragState' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' // Module-level style injection — shared across all Canvas instances let canvasStyleInjected = false @@ -409,7 +410,14 @@ const Canvas: React.FC = ({ children, onCreateAtPoint, panelId }) = ? store.createTerminal(wsId, undefined, pos, here(), spec.cwd) : store.createCateAgent(wsId, pos, here()) if (panelId && spec.worktreeId) { - store.setPanelWorktreeId(wsId, panelId, spec.worktreeId) + if (spec.panelType === 'terminal') { + store.setPanelWorktreeId(wsId, panelId, spec.worktreeId) + } else { + const rootPath = store.getWorkspace(wsId)?.rootPath + if (rootPath) { + await seedAgentPanelWithWorktreeChat(wsId, rootPath, panelId, spec.worktreeId) + } + } } return } diff --git a/src/renderer/canvas/CanvasNode.tsx b/src/renderer/canvas/CanvasNode.tsx index 604da901..56f9b14e 100644 --- a/src/renderer/canvas/CanvasNode.tsx +++ b/src/renderer/canvas/CanvasNode.tsx @@ -35,6 +35,8 @@ import { collectPanelIds } from '../../shared/collectPanelIds' import { ArrowsOutSimple, ArrowsInSimple, X, Lock, LockOpen } from '@phosphor-icons/react' import { isWorktreePanelType, PANEL_DEFINITIONS } from '../../shared/panels' import { captureRendererException } from '../lib/sentry' +import { useCateAgentStore } from '../../cateAgent/renderer/cateAgentStore' +import { useChatsStore } from '../stores/chatsStore' // Node ids already reported for missing geometry, so a bad node that keeps // re-rendering warns/reports once instead of spamming. @@ -384,21 +386,34 @@ const CanvasNode: React.FC = ({ if (!id) return primaryPanel return currentWorkspace?.panels[id] ?? primaryPanel }, [layout, currentWorkspace, primaryPanel]) + const activeAgentChatId = useCateAgentStore((state) => ( + activePanel?.type === 'cateAgent' ? state.activeChatByPanel[activePanel.id] : undefined + )) + const activeAgentChatWorktreeId = useChatsStore((state) => ( + activeAgentChatId + ? (state.chatsByRoot[currentWorkspace?.rootPath ?? ''] ?? []) + .find((chat) => chat.id === activeAgentChatId)?.worktreeId + : undefined + )) // --- Worktree identity: follows the ACTIVE tab -------------------------- // The node adopts whichever tab is open. Gated on 2+ worktrees (matching the // chip) so single-branch flows show no tint/sludge. const worktrees = currentWorkspace?.worktrees ?? [] const wtEnabled = worktrees.length >= 2 - // Resolve the active tab's worktree. A terminal/agent panel with no explicit + // Resolve the active tab's worktree. Terminals read their panel tag; Cate + // Agent panels read the active chat's tag. A worktree panel with no explicit // tag belongs to the PRIMARY worktree (the record keyed by the workspace root), // so the main checkout gets the same tint / terrace / focus-lens as the others // — mirroring the WorktreePill + tab-title fallback. Non-terminal panels stay // untagged (no territory). const primaryWorktree = worktrees.find((w) => w.path === currentWorkspace?.rootPath) const isWorktreePanel = isWorktreePanelType(activePanel?.type) + const explicitWorktreeId = activePanel?.type === 'cateAgent' + ? activeAgentChatWorktreeId + : activePanel?.worktreeId const activeWorktree = wtEnabled - ? worktrees.find((w) => w.id === activePanel?.worktreeId) ?? (isWorktreePanel ? primaryWorktree : undefined) + ? worktrees.find((w) => w.id === explicitWorktreeId) ?? (isWorktreePanel ? primaryWorktree : undefined) : undefined const activeWorktreeId = activeWorktree?.id ?? null const worktreeColor = activeWorktree?.color ?? null diff --git a/src/renderer/canvas/WorktreePill.tsx b/src/renderer/canvas/WorktreePill.tsx index 0a59d299..ba269293 100644 --- a/src/renderer/canvas/WorktreePill.tsx +++ b/src/renderer/canvas/WorktreePill.tsx @@ -78,8 +78,8 @@ export const WorktreePill: React.FC = ({ panel, workspaceId } useAppStore.getState().respawnPanelTerminal(workspaceId, panel.id, target.path, target.id) }, [worktrees, current, focusedWorktreeId, panel, workspaceId, focusWorktree]) - // The chip is terminal chrome only. Agent worktree selection lives below its - // composer while panel.worktreeId still records its worktree association. + // The chip is terminal chrome only. Agent worktree selection lives on Chat + // and is rendered below its composer. if (panel.type !== 'terminal') return null if (worktrees.length < 2 || !current) return null diff --git a/src/renderer/canvas/WorktreeToolbarMenu.tsx b/src/renderer/canvas/WorktreeToolbarMenu.tsx index c0df5a31..f2661a53 100644 --- a/src/renderer/canvas/WorktreeToolbarMenu.tsx +++ b/src/renderer/canvas/WorktreeToolbarMenu.tsx @@ -38,6 +38,7 @@ import { useAppStore, getWorktreeColorPalette } from '../stores/appStore' import { useParallelWork, runWorktreeContextMenu, type CardCallbacks } from '../stores/useParallelWork' import { useWorktreeStatuses, humanStatus, type PrStatus } from '../stores/useWorktreeStatuses' import type { WorktreePanelType } from '../../shared/panels' +import { useActiveChatWorktreeByPanel } from '../../cateAgent/renderer/cateAgentStore' interface WorktreeToolbarMenuProps { canvasPanelId: string @@ -168,16 +169,18 @@ const WorktreeMenuPopover: React.FC = ({ // What's already open on the canvas, per worktree. const panels = useAppStore((s) => s.workspaces.find((w) => w.id === workspaceId)?.panels) + const activeChatWorktreeByPanel = useActiveChatWorktreeByPanel() const panelCounts = useMemo(() => { const counts: Record = {} for (const p of Object.values(panels ?? {})) { - if (!p.worktreeId) continue - const c = counts[p.worktreeId] ?? (counts[p.worktreeId] = { terminals: 0, agents: 0 }) + const worktreeId = p.type === 'cateAgent' ? activeChatWorktreeByPanel[p.id] : p.worktreeId + if (!worktreeId) continue + const c = counts[worktreeId] ?? (counts[worktreeId] = { terminals: 0, agents: 0 }) if (p.type === 'terminal') c.terminals += 1 else if (p.type === 'cateAgent') c.agents += 1 } return counts - }, [panels]) + }, [activeChatWorktreeByPanel, panels]) const { statusByPath, prByPath, refreshPr } = useWorktreeStatuses(rootPath, live) const { createWorktree, checkoutPr, launchInWorktree, handlePrune, makeCallbacks } = useParallelWork( diff --git a/src/renderer/chat/ChatComposer.sendStop.test.tsx b/src/renderer/chat/ChatComposer.sendStop.test.tsx index 096fd618..f887fe71 100644 --- a/src/renderer/chat/ChatComposer.sendStop.test.tsx +++ b/src/renderer/chat/ChatComposer.sendStop.test.tsx @@ -132,10 +132,27 @@ describe('ChatComposer send/stop', () => { expect(host.textContent).toContain('Manage canvas') }) + it('selects orchestration mode from the plus menu and renders it inline', () => { + const onPromptModeChange = vi.fn() + renderComposer({ onPromptModeChange }) + + act(() => button('Add prompt mode')?.click()) + const orchestrate = Array.from(document.body.querySelectorAll('button')) + .find((candidate) => candidate.textContent?.includes('Orchestrate agents')) as HTMLButtonElement + expect(orchestrate).toBeTruthy() + + act(() => orchestrate.click()) + expect(onPromptModeChange).toHaveBeenCalledWith('orchestrate') + + renderComposer({ promptMode: 'orchestrate', onPromptModeChange }) + expect(button('Remove Orchestrate agents mode')).toBeTruthy() + expect(host.textContent).toContain('Orchestrate agents') + }) + }) describe('ChatComposer popovers', () => { - it('renders thinking controls in viewport space without a canvas node', () => { + it('opens thinking controls below a centred composer', () => { renderComposer({ onPickThinkingLevel: vi.fn() }) const trigger = button('Reasoning effort: medium') as HTMLButtonElement trigger.getBoundingClientRect = () => ({ @@ -158,11 +175,37 @@ describe('ChatComposer popovers', () => { expect(popover).toBeTruthy() expect(host.contains(popover ?? null)).toBe(false) expect(popover?.classList.contains('fixed')).toBe(true) - expect(popover?.style.top).toBe('296px') + expect(popover?.dataset.placement).toBe('below') + expect(popover?.style.top).toBe('324px') expect(popover?.style.left).toBe('260px') }) - it('portals the model picker above overflow-clipped composers', () => { + it('opens thinking controls above a bottom composer', () => { + renderComposer({ onPickThinkingLevel: vi.fn() }) + const trigger = button('Reasoning effort: medium') as HTMLButtonElement + trigger.getBoundingClientRect = () => ({ + top: 700, + right: 420, + bottom: 720, + left: 400, + width: 20, + height: 20, + x: 400, + y: 700, + toJSON: () => ({}), + }) + + act(() => trigger.click()) + + const heading = Array.from(document.body.querySelectorAll('div')) + .find((candidate) => candidate.textContent === 'Thinking level') + const popover = heading?.parentElement + expect(popover?.dataset.placement).toBe('above') + expect(popover?.style.top).toBe('696px') + expect(popover?.style.transform).toBe('translateY(-100%)') + }) + + it('portals the model picker below a centred composer', () => { renderComposer({ models: [{ provider: 'openai', model: 'gpt-test' }], onPickModel: vi.fn(), @@ -187,7 +230,34 @@ describe('ChatComposer popovers', () => { expect(popover).toBeTruthy() expect(host.contains(popover)).toBe(false) expect(popover?.style.position).toBe('fixed') - expect(popover?.style.top).toBe('292px') + expect(popover?.style.top).toBe('328px') expect(popover?.style.left).toBe('100px') + expect(popover?.style.transform).toBe('') + }) + + it('opens slash commands on the available side of the composer', () => { + const commands = [{ name: 'review', description: 'Review changes', source: 'skill' as const }] + renderComposer({ commands }) + const card = host.querySelector('textarea')?.closest('.relative.z-10') as HTMLDivElement + let top = 300 + card.getBoundingClientRect = () => ({ + top, + right: 520, + bottom: top + 80, + left: 100, + width: 420, + height: 80, + x: 100, + y: top, + toJSON: () => ({}), + }) + + renderComposer({ draft: '/', commands }) + expect((host.querySelector('[data-placement]') as HTMLElement).dataset.placement).toBe('below') + + renderComposer({ draft: '', commands }) + top = 680 + renderComposer({ draft: '/', commands }) + expect((host.querySelector('[data-placement]') as HTMLElement).dataset.placement).toBe('above') }) }) diff --git a/src/renderer/chat/ChatComposer.tsx b/src/renderer/chat/ChatComposer.tsx index bb57cb4b..c9d23cd8 100644 --- a/src/renderer/chat/ChatComposer.tsx +++ b/src/renderer/chat/ChatComposer.tsx @@ -6,8 +6,9 @@ // textarea with a control row beneath it — model picker on the left, the run // controls on the right, so the send button always sits at the bottom-right, // never floating mid-height. A second card tucks under the main one and sticks -// out below: the worktree selector. Both menus open UPWARD (the composer lives -// at its panel's bottom edge). +// out below: the worktree selector. Menus choose above or below at runtime +// because the composer can live either in an empty-state centre or at the +// panel's bottom edge. // // Capabilities (from the agent panel composer): image attachments, thinking // level, prompt modes, context compaction, the stats chip, the slash-command @@ -28,6 +29,7 @@ import { Plus, ClipboardText, BoundingBox, + GitBranch, X, Spinner, ArrowsClockwise, @@ -43,6 +45,8 @@ import { NodePopover, useNodePopover, useViewportPopoverPosition, + verticalPopoverPosition, + type ViewportPopoverPosition, COMPOSER_POPOVER_SURFACE, } from '../../cateAgent/renderer/CateAgentPanelChrome' import type { JoinedWorktree } from '../stores/useWorktrees' @@ -57,23 +61,26 @@ import type { const MAX_HEIGHT = 160 export type ModelOption = { provider: string; model: string; label?: string } -export type ComposerPromptMode = 'plan' | 'canvas' +export type ComposerPromptMode = 'plan' | 'canvas' | 'orchestrate' -const promptModeLabel = (mode: ComposerPromptMode): string => - mode === 'plan' ? 'Create plan' : 'Manage canvas' +const promptModeLabel = (mode: ComposerPromptMode): string => { + if (mode === 'plan') return 'Create plan' + if (mode === 'canvas') return 'Manage canvas' + return 'Orchestrate agents' +} const worktreeLabel = (wt: JoinedWorktree | undefined): string => wt?.label || wt?.branch || (wt?.isPrimary ? 'main' : 'worktree') -// --- an upward-opening portal menu anchored above a trigger -------------------- -const UpwardMenu: React.FC<{ - anchor: DOMRect +// --- a portal menu anchored on the roomier side of a trigger ------------------- +const AnchoredMenu: React.FC<{ + pos: ViewportPopoverPosition width: number onClose: () => void triggerRef?: React.RefObject children: React.ReactNode }> = ({ - anchor, + pos, width, onClose, triggerRef, @@ -102,7 +109,13 @@ const UpwardMenu: React.FC<{ ref={ref} role="listbox" className={`fixed z-[9999] max-h-[340px] overflow-y-auto no-scrollbar p-1.5 ${COMPOSER_POPOVER_SURFACE}`} - style={{ left: anchor.left, bottom: window.innerHeight - anchor.top + 6, width }} + data-placement={pos.placement} + style={{ + top: pos.top, + left: pos.left, + width, + transform: pos.placement === 'above' ? 'translateY(-100%)' : undefined, + }} > {children}
, @@ -268,8 +281,9 @@ export const ChatComposer: React.FC = ({ modelPillRef, modelOpen, (rect) => ({ - top: rect.top - 8, left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), + gap: 8, + height: 320, }), ) const [wtAnchor, setWtAnchor] = React.useState(null) @@ -286,10 +300,11 @@ export const ChatComposer: React.FC = ({ } = useNodePopover( promptModeBtn, (rect) => ({ - top: rect.top - 8, left: Math.max(8, Math.min(rect.left, window.innerWidth - 228)), + gap: 8, }), ) + const composerCardRef = React.useRef(null) // Slash popup is active when the draft starts with "/" and has no spaces // before the cursor — i.e. the user is still picking a command name. @@ -368,6 +383,7 @@ export const ChatComposer: React.FC = ({
{/* Main composer card */}
{ @@ -418,6 +434,11 @@ export const ChatComposer: React.FC = ({ selectedIdx={selectedIdx} onPick={acceptCommand} onHover={setSelectedIdx} + placement={ + composerCardRef.current + ? verticalPopoverPosition(composerCardRef.current.getBoundingClientRect(), 6, 240).placement + : 'above' + } /> )} {onRemoveImage && } @@ -434,7 +455,11 @@ export const ChatComposer: React.FC = ({ aria-label={`Remove ${promptModeLabel(promptMode)} mode`} title="Remove prompt mode" > - {promptMode === 'plan' ? : } + {promptMode === 'plan' + ? + : promptMode === 'canvas' + ? + : } {promptModeLabel(promptMode)} @@ -515,7 +540,7 @@ export const ChatComposer: React.FC = ({ top: modelPos.top, left: modelPos.left, width: 280, - transform: 'translateY(-100%)', + transform: modelPos.placement === 'above' ? 'translateY(-100%)' : undefined, zIndex: 9999, }} onPick={(m) => { @@ -590,6 +615,19 @@ export const ChatComposer: React.FC = ({ Control Cate panels with the Cate CLI + { + onPromptModeChange(promptMode === 'orchestrate' ? null : 'orchestrate') + setPromptModeOpen(false) + }} + > + + + Orchestrate agents + Create and supervise coding agents + +
)} @@ -667,7 +705,15 @@ export const ChatComposer: React.FC = ({ )} {onPickWorktree && wtAnchor && ( - + {creating && onCreateWorktree ? ( = ({ )} )} - + )}
) @@ -739,7 +785,7 @@ function CompactButton({ const popW = 200 let left = r.left if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8 - return { top: r.top - 6, left } + return { left, gap: 6 } }, ) return ( @@ -816,7 +862,10 @@ function StatsChip({ const btnRef = React.useRef(null) const { open, setOpen, popoverRef, pos, portalTarget } = useNodePopover( btnRef, - (r) => ({ top: r.top - 6, left: r.left }), + (r) => ({ + left: Math.max(8, Math.min(r.left, window.innerWidth - 268)), + gap: 6, + }), ) if (!stats) return null const ctx = stats.contextUsage @@ -931,14 +980,21 @@ function SlashPopup({ selectedIdx, onPick, onHover, + placement, }: { commands: CodingSlashCommand[] selectedIdx: number onPick: (cmd: CodingSlashCommand) => void onHover: (idx: number) => void + placement: 'above' | 'below' }) { return ( -
+
{commands.map((cmd, i) => { const active = i === selectedIdx return ( diff --git a/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx b/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx index 1fec456d..0a99a71c 100644 --- a/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx +++ b/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx @@ -62,4 +62,52 @@ describe('ChatComposer worktree selector', () => { mouseClick(selector) expect(document.body.querySelector('[role="listbox"]')).toBeNull() }) + + it('opens on the side of the selector with enough viewport space', () => { + act(() => { + root.render( + , + ) + }) + const selector = host.querySelector('button[title="Worktree this task branches off and lands back into"]') as HTMLButtonElement + let top = 300 + selector.getBoundingClientRect = () => ({ + top, + right: 180, + bottom: top + 20, + left: 100, + width: 80, + height: 20, + x: 100, + y: top, + toJSON: () => ({}), + }) + + mouseClick(selector) + expect((document.body.querySelector('[role="listbox"]') as HTMLElement).dataset.placement).toBe('below') + + mouseClick(selector) + top = 700 + mouseClick(selector) + expect((document.body.querySelector('[role="listbox"]') as HTMLElement).dataset.placement).toBe('above') + }) }) diff --git a/src/renderer/docking/DockTabBar.tsx b/src/renderer/docking/DockTabBar.tsx index 736d56b6..50828206 100644 --- a/src/renderer/docking/DockTabBar.tsx +++ b/src/renderer/docking/DockTabBar.tsx @@ -16,6 +16,7 @@ import { useAgentInfoByPanel } from '../hooks/useAgentPanelInfo' import { worktreeTitleStyle } from '../lib/worktreeTitleStyle' import { isMiddleClick } from '../lib/mouse' import { isWorktreePanelType } from '../../shared/panels' +import { useActiveChatWorktreeByPanel } from '../../cateAgent/renderer/cateAgentStore' const AWAIT_COLOR = '#c08a5a' @@ -26,9 +27,11 @@ const AWAIT_COLOR = '#c08a5a' // an agent logo (an , which ignores `color`), and tinting it would clash // with the per-agent icon swap. function useWorktreeColorByPanel(): Record { - return useAppStore(useShallow((s) => { + const workspaces = useAppStore(useShallow((s) => s.workspaces)) + const activeChatWorktreeByPanel = useActiveChatWorktreeByPanel() + return React.useMemo(() => { const out: Record = {} - for (const ws of s.workspaces) { + for (const ws of workspaces) { const worktrees = ws.worktrees ?? [] if (worktrees.length < 2) continue // isPrimary is a live-git fact, no longer persisted; the primary record is @@ -36,12 +39,15 @@ function useWorktreeColorByPanel(): Record { const primary = worktrees.find((w) => w.path === ws.rootPath) for (const panel of Object.values(ws.panels)) { if (!isWorktreePanelType(panel.type)) continue - const wt = worktrees.find((w) => w.id === panel.worktreeId) ?? primary + const worktreeId = panel.type === 'cateAgent' + ? activeChatWorktreeByPanel[panel.id] + : panel.worktreeId + const wt = worktrees.find((w) => w.id === worktreeId) ?? primary if (wt?.color) out[panel.id] = wt.color } } return out - })) + }, [activeChatWorktreeByPanel, workspaces]) } // Type → icon/tint mirrors the Spotlight overlay so tabs, search results, and diff --git a/src/renderer/hooks/useCateHostActionResponder.ts b/src/renderer/hooks/useCateHostActionResponder.ts index e788cd7b..8744653a 100644 --- a/src/renderer/hooks/useCateHostActionResponder.ts +++ b/src/renderer/hooks/useCateHostActionResponder.ts @@ -25,6 +25,7 @@ import { toAbsolutePath, pathKey } from '../../shared/pathUtils' import { parseLocator, formatLocator } from '../../shared/runtimeLocator' import { handleBrowserMethod } from '../lib/browser/browserDriver' import { handleTerminalMethod } from '../lib/terminal/terminalDriver' +import { handleCodingAgentMethod } from '../lib/agent/codingAgentDriver' import { browserPanelUrl, isStartPageUrl, type PanelType, type Point } from '../../shared/types' import type { PanelPlacement } from '../stores/appStore' @@ -139,6 +140,13 @@ export function useCateHostActionResponder(): void { : reply(false, { error: outcome.error }) } + if (method.startsWith('cate.codingAgent.')) { + const outcome = await handleCodingAgentMethod(workspaceId, payload.panelId, method, args) + return outcome.ok + ? reply(true, { result: outcome.result }) + : reply(false, { error: outcome.error }) + } + switch (method) { case 'cate.editor.openFile': { const filePath = typeof args.path === 'string' ? args.path : undefined diff --git a/src/renderer/lib/agent/codingAgentDriver.test.ts b/src/renderer/lib/agent/codingAgentDriver.test.ts new file mode 100644 index 00000000..7dc6635b --- /dev/null +++ b/src/renderer/lib/agent/codingAgentDriver.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + app: {} as any, + settings: { agentHookInjection: { ws: { codex: 'on' } } } as any, + failure: null as string | null, + terminalOutput: 'worker output', +})) +const resolveDriverAgentCli = vi.hoisted(() => vi.fn()) +const getOrCreate = vi.hoisted(() => vi.fn()) +const submitTerminalText = vi.hoisted(() => vi.fn(async () => true)) + +vi.mock('../../stores/appStore', () => ({ + useAppStore: { + getState: () => state.app, + subscribe: vi.fn(() => () => {}), + }, +})) +vi.mock('../../stores/statusStore', () => ({ + useStatusStore: { + getState: () => ({ workspaces: {} }), + subscribe: vi.fn(() => () => {}), + }, +})) +vi.mock('../../stores/settingsStore', () => ({ + useSettingsStore: { getState: () => state.settings }, +})) +vi.mock('../terminal/terminalRegistry', () => ({ + terminalRegistry: { + getEntry: () => ({ + ptyId: 'pty-1', + alive: true, + terminal: {}, + }), + getFailure: () => state.failure, + getOrCreate, + subscribeFailure: vi.fn(() => () => {}), + terminate: vi.fn(), + }, +})) +vi.mock('../terminal/terminalBuffer', () => ({ + terminalBufferTail: () => state.terminalOutput, +})) +vi.mock('../terminal/terminalDriver', () => ({ submitTerminalText })) +vi.mock('../workspace/canvasAccess', () => ({ + placementForBackgroundPanel: (_workspaceId: string, placementGroupId: string) => ({ + target: 'canvas', + placementGroupId, + }), +})) +vi.mock('../../stores/useWorktreeActions', () => ({ + createWorktreeForWorkspace: vi.fn(), +})) +vi.mock('./agentCliHooks', () => ({ resolveDriverAgentCli })) + +import { AGENTS } from '../../../shared/agents' +import { codingAgentSnapshot, handleCodingAgentMethod } from './codingAgentDriver' + +describe('codingAgentDriver mission integration', () => { + beforeEach(() => { + state.failure = null + state.terminalOutput = 'worker output' + state.settings = { agentHookInjection: { ws: { codex: 'on' } } } + const panels: Record = {} + state.app = { + workspaces: [{ + id: 'ws', + rootPath: '/repo', + panels, + worktrees: [], + }], + createTerminal: vi.fn(( + _workspaceId: string, + _initialInput: unknown, + _position: unknown, + placement: { placementGroupId: string }, + cwd: string, + launch: { runId: string; agentId: string; prompt: string; ownerPanelId: string }, + ) => { + panels.worker = { + id: 'worker', + type: 'terminal', + title: 'Terminal', + cwd, + placementGroupId: placement.placementGroupId, + codingAgentLaunch: launch, + codingAgentRun: { + id: launch.runId, + agentId: launch.agentId, + panelId: 'worker', + ownerPanelId: launch.ownerPanelId, + prompt: launch.prompt, + createdAt: 1, + }, + } + return 'worker' + }), + setPanelWorktreeId: vi.fn(), + setPanelCodingAgentRun: vi.fn((_ws: string, panelId: string, run: unknown) => { + panels[panelId].codingAgentRun = run + }), + updatePanelTitle: vi.fn(), + } + resolveDriverAgentCli.mockReset() + resolveDriverAgentCli.mockResolvedValue(AGENTS.find((agent) => agent.id === 'codex')) + getOrCreate.mockReset() + getOrCreate.mockResolvedValue({ ptyId: 'pty-1', alive: true, terminal: {} }) + submitTerminalText.mockClear() + }) + + it('automatically selects a hook-ready canonical agent and starts its PTY headlessly', async () => { + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { prompt: 'Implement it' }, + ) + + expect(outcome.ok).toBe(true) + expect(outcome).toMatchObject({ + result: { + status: 'starting', + alive: true, + }, + }) + expect(resolveDriverAgentCli).toHaveBeenCalledWith('/repo', '', { + fallbackLocator: '/repo', + hookConfig: { codex: 'on' }, + }) + expect(state.app.createTerminal).toHaveBeenCalledWith( + 'ws', + undefined, + undefined, + expect.objectContaining({ placementGroupId: 'coding-agent:primary' }), + '/repo', + expect.objectContaining({ + agentId: 'codex', + ownerPanelId: 'supervisor-1', + prompt: 'Implement it', + }), + ) + expect(getOrCreate).toHaveBeenCalledWith('worker', expect.objectContaining({ + workspaceId: 'ws', + cwd: '/repo', + codingAgentLaunch: expect.objectContaining({ ownerPanelId: 'supervisor-1' }), + })) + }) + + it('rejects a non-ready explicit agent before creating a terminal', async () => { + resolveDriverAgentCli.mockRejectedValue(new Error('Codex hooks are disabled')) + + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + + expect(outcome).toEqual({ + ok: false, + error: 'agent-hooks-not-ready: Codex hooks are disabled', + }) + expect(state.app.createTerminal).not.toHaveBeenCalled() + expect(getOrCreate).not.toHaveBeenCalled() + }) + + it('isolates run lookup to the Cate Agent session that created it', async () => { + await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + const runId = state.app.workspaces[0].panels.worker.codingAgentRun.id + + expect(codingAgentSnapshot('ws', 'supervisor-1', runId)).not.toBeNull() + expect(codingAgentSnapshot('ws', 'supervisor-2', runId)).toBeNull() + await expect(handleCodingAgentMethod( + 'ws', + 'supervisor-2', + 'cate.codingAgent.inspect', + { runId }, + )).resolves.toEqual({ ok: false, error: 'coding-agent-not-found' }) + }) + + it('returns terminal startup failures as actionable mission diagnostics', async () => { + state.failure = 'spawn codex ENOENT' + + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + + expect(outcome).toMatchObject({ + ok: true, + result: { + status: 'failed', + failureReason: 'spawn codex ENOENT', + }, + }) + }) + + it('returns the useful CLI output when a worker exits unsuccessfully', async () => { + await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + const run = state.app.workspaces[0].panels.worker.codingAgentRun + state.app.workspaces[0].panels.worker.codingAgentRun = { + ...run, + endedAt: 2, + exitCode: 1, + } + state.terminalOutput = [ + 'Error: You have no usage remaining for Codex.', + '[Process exited with code 1]', + ].join('\n') + + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.wait', + { runIds: [run.id] }, + ) + + expect(outcome).toMatchObject({ + ok: true, + result: { + timedOut: false, + changedRunIds: [run.id], + runs: [{ + id: run.id, + status: 'failed', + failureReason: 'Process exited with code 1: Error: You have no usage remaining for Codex.', + }], + }, + }) + }) +}) diff --git a/src/renderer/lib/agent/codingAgentDriver.ts b/src/renderer/lib/agent/codingAgentDriver.ts new file mode 100644 index 00000000..202e24ab --- /dev/null +++ b/src/renderer/lib/agent/codingAgentDriver.ts @@ -0,0 +1,425 @@ +import { useAppStore } from '../../stores/appStore' +import { useStatusStore } from '../../stores/statusStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { terminalRegistry } from '../terminal/terminalRegistry' +import { terminalBufferTail } from '../terminal/terminalBuffer' +import { submitTerminalText } from '../terminal/terminalDriver' +import { placementForBackgroundPanel } from '../workspace/canvasAccess' +import { parseLocator, formatLocator } from '../../../shared/runtimeLocator' +import { pathKey } from '../../../shared/pathUtils' +import { createWorktreeForWorkspace } from '../../stores/useWorktreeActions' +import { + MAX_CONCURRENT_CODING_AGENTS, + codingAgentDisplayName, + codingAgentSupportsFollowUp, + deriveCodingAgentRunStatus, + parseCodingAgentId, + type CodingAgentRun, + type CodingAgentRunSnapshot, + type CodingAgentRunStatus, +} from '../../../shared/codingAgentRuns' +import { resolveDriverAgentCli } from './agentCliHooks' +import type { AgentId } from '../../../shared/agents' +import { + actionableCodingAgentRunIds, + changedCodingAgentRunIds, + codingAgentWaitMs, + compactCodingAgentSnapshot, +} from './codingAgentWait' + +export type CodingAgentOutcome = + | { ok: true; result: unknown } + | { ok: false; error: string } + +function workspace(workspaceId: string) { + return useAppStore.getState().workspaces.find((candidate) => candidate.id === workspaceId) +} + +function runPanel(workspaceId: string, ownerPanelId: string, runId: string) { + const ws = workspace(workspaceId) + return Object.values(ws?.panels ?? {}).find((panel) => + panel.codingAgentRun?.id === runId && + panel.codingAgentRun.ownerPanelId === ownerPanelId, + ) +} + +function terminalText(panelId: string, maxChars = 4_000): string { + const terminal = terminalRegistry.getEntry(panelId)?.terminal + return terminal ? terminalBufferTail(terminal, maxChars) : '' +} + +function runStatus(workspaceId: string, panelId: string, run: CodingAgentRun): CodingAgentRunStatus { + const failure = terminalRegistry.getFailure(panelId) + const entry = terminalRegistry.getEntry(panelId) + const runtime = entry?.ptyId + ? useStatusStore.getState().workspaces[workspaceId]?.terminals[entry.ptyId] + : undefined + return deriveCodingAgentRunStatus(run, { + terminalStarted: entry !== undefined, + terminalAlive: entry?.alive === true, + terminalFailed: failure !== null, + agentState: runtime?.agentState, + agentPresent: runtime?.agentPresent === true || Boolean(runtime?.agentName), + }) +} + +export function codingAgentSnapshot( + workspaceId: string, + ownerPanelId: string, + runId: string, +): CodingAgentRunSnapshot | null { + const panel = runPanel(workspaceId, ownerPanelId, runId) + const run = panel?.codingAgentRun + if (!panel || !run) return null + const entry = terminalRegistry.getEntry(panel.id) + const output = terminalText(panel.id) + const lastLine = output.split('\n').reverse().find((line) => line.trim())?.trim() + const status = runStatus(workspaceId, panel.id, run) + const terminalFailure = terminalRegistry.getFailure(panel.id) + const failureDiagnostic = status === 'failed' + ? output + .split('\n') + .reverse() + .map((line) => line.trim()) + .find((line) => line && !/^\[Process exited with code \d+\]$/.test(line)) + : undefined + const failureReason = terminalFailure + ? terminalFailure.slice(0, 500) + : status === 'failed' + ? `Process exited with code ${run.exitCode ?? 'unknown'}${failureDiagnostic ? `: ${failureDiagnostic}` : ''}`.slice(0, 500) + : undefined + return { + ...run, + status, + agentName: codingAgentDisplayName(run.agentId), + cwd: panel.cwd ?? workspace(workspaceId)?.rootPath ?? '', + alive: entry?.alive === true, + followUpSupported: codingAgentSupportsFollowUp(run.agentId), + ...(lastLine ? { statusLine: lastLine.slice(0, 200) } : {}), + ...(failureReason ? { failureReason } : {}), + } +} + +function allSnapshots(workspaceId: string, ownerPanelId: string): CodingAgentRunSnapshot[] { + const ws = workspace(workspaceId) + return Object.values(ws?.panels ?? {}) + .filter((panel) => panel.codingAgentRun?.ownerPanelId === ownerPanelId) + .map((panel) => codingAgentSnapshot(workspaceId, ownerPanelId, panel.codingAgentRun!.id)) + .filter((snapshot): snapshot is CodingAgentRunSnapshot => snapshot !== null) + .sort((a, b) => a.createdAt - b.createdAt) +} + +function runtimeLocatorForPath(rootLocator: string, candidate: string): string { + const root = parseLocator(rootLocator) + const parsed = parseLocator(candidate) + // Worktree metadata may store a bare host path. Keep it on the workspace's + // runtime rather than accidentally treating it as a local path. + return formatLocator({ + runtimeId: parsed.runtimeId === 'local' ? root.runtimeId : parsed.runtimeId, + path: parsed.path, + }) +} + +function resolveTarget( + workspaceId: string, + args: Record, +): { cwd: string; worktreeId?: string } | { error: string } { + const ws = workspace(workspaceId) + if (!ws?.rootPath) return { error: 'workspace-not-found' } + const worktrees = ws.worktrees ?? [] + const requested = typeof args.worktreeId === 'string' ? args.worktreeId : undefined + if (requested) { + const worktree = worktrees.find((candidate) => candidate.id === requested) + if (!worktree) return { error: 'worktree-not-registered' } + return { cwd: runtimeLocatorForPath(ws.rootPath, worktree.path), worktreeId: worktree.id } + } + + const origin = typeof args._cateOriginCwd === 'string' ? args._cateOriginCwd : '' + const rootPath = parseLocator(ws.rootPath).path + if (origin && pathKey(origin) === pathKey(rootPath)) return { cwd: ws.rootPath } + const inherited = worktrees.find((candidate) => + pathKey(parseLocator(candidate.path).path) === pathKey(origin), + ) + if (inherited) { + return { cwd: runtimeLocatorForPath(ws.rootPath, inherited.path), worktreeId: inherited.id } + } + return { cwd: ws.rootPath } +} + +function findRequestedRuns( + workspaceId: string, + ownerPanelId: string, + raw: unknown, +): CodingAgentRunSnapshot[] | { error: string } { + if (raw !== undefined && !Array.isArray(raw)) return { error: 'runIds-must-be-an-array' } + const ids = raw as unknown[] | undefined + if (!ids) return allSnapshots(workspaceId, ownerPanelId) + const snapshots: CodingAgentRunSnapshot[] = [] + for (const value of ids) { + if (typeof value !== 'string') return { error: 'invalid-run-id' } + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, value) + if (!snapshot) return { error: 'coding-agent-not-found' } + snapshots.push(snapshot) + } + return snapshots +} + +async function waitForCodingAgentChange( + workspaceId: string, + ownerPanelId: string, + rawRunIds: unknown, + timeoutMs: number, +): Promise { + const initial = findRequestedRuns(workspaceId, ownerPanelId, rawRunIds) + if ('error' in initial) return { ok: false, error: initial.error } + // With no explicit target, monitor only live mission work. Historical ready + // runs must not make every future wait return immediately. + const watched = rawRunIds === undefined + ? initial.filter((run) => + run.status === 'starting' || run.status === 'working' || run.status === 'waiting') + : initial + if (watched.length === 0) { + return { ok: true, result: { timedOut: false, changedRunIds: [], runs: [] } } + } + const actionable = actionableCodingAgentRunIds(watched) + if (actionable.length > 0) { + return { + ok: true, + result: { + timedOut: false, + changedRunIds: actionable, + runs: watched.map(compactCodingAgentSnapshot), + }, + } + } + const ids = watched.map((run) => run.id) + const baseline = new Map(watched.map((run) => [run.id, run.status])) + + return new Promise((resolve) => { + let settled = false + let unsubscribeApp = () => {} + let unsubscribeStatus = () => {} + let unsubscribeFailure = () => {} + + const finish = (outcome: CodingAgentOutcome): void => { + if (settled) return + settled = true + clearTimeout(timer) + unsubscribeApp() + unsubscribeStatus() + unsubscribeFailure() + resolve(outcome) + } + const current = (): CodingAgentRunSnapshot[] | { error: string } => + findRequestedRuns(workspaceId, ownerPanelId, ids) + const check = (): void => { + const snapshots = current() + if ('error' in snapshots) { + finish({ ok: false, error: snapshots.error }) + return + } + const changedRunIds = changedCodingAgentRunIds(baseline, snapshots) + if (changedRunIds.length > 0) { + finish({ + ok: true, + result: { + timedOut: false, + changedRunIds, + runs: snapshots.map(compactCodingAgentSnapshot), + }, + }) + } + } + + const timer = setTimeout(() => { + const snapshots = current() + finish('error' in snapshots + ? { ok: false, error: snapshots.error } + : { + ok: true, + result: { + timedOut: true, + changedRunIds: [], + runs: snapshots.map(compactCodingAgentSnapshot), + }, + }) + }, timeoutMs) + unsubscribeApp = useAppStore.subscribe(check) + unsubscribeStatus = useStatusStore.subscribe(check) + unsubscribeFailure = terminalRegistry.subscribeFailure(check) + // Close the gap between the initial snapshot and listener registration. + check() + }) +} + +export async function handleCodingAgentMethod( + workspaceId: string, + ownerPanelId: string, + method: string, + args: Record, +): Promise { + const name = method.slice('cate.codingAgent.'.length) + if (!ownerPanelId) return { ok: false, error: 'mission-owner-required' } + + if (name === 'create') { + const requestedAgentId = args.agentId === undefined ? '' : parseCodingAgentId(args.agentId) + const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' + if (args.agentId !== undefined && !requestedAgentId) { + return { ok: false, error: 'unsupported-agent' } + } + if (!prompt) return { ok: false, error: 'prompt-required' } + if (prompt.includes('\0')) return { ok: false, error: 'invalid-prompt' } + if (prompt.length > 50_000) return { ok: false, error: 'prompt-too-long' } + const active = allSnapshots(workspaceId, ownerPanelId).filter((run) => + run.status === 'starting' || run.status === 'working' || run.status === 'waiting', + ) + if (active.length >= MAX_CONCURRENT_CODING_AGENTS) { + return { ok: false, error: 'coding-agent-limit' } + } + if (args.worktreeId && args.newWorktree) { + return { ok: false, error: 'choose-worktreeId-or-newWorktree' } + } + if (typeof args.newWorktree === 'string' && args.newWorktree.trim()) { + const ws = workspace(workspaceId) + if (!ws?.rootPath) return { ok: false, error: 'workspace-not-found' } + try { + const created = await createWorktreeForWorkspace( + ws.rootPath, + workspaceId, + args.newWorktree, + typeof args.baseRef === 'string' ? args.baseRef : undefined, + ) + args = { ...args, worktreeId: created.id } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? `worktree-create-failed: ${error.message}` : 'worktree-create-failed', + } + } + } + const target = resolveTarget(workspaceId, args) + if ('error' in target) return { ok: false, error: target.error } + const ws = workspace(workspaceId) + let agentId: AgentId + try { + const agent = await resolveDriverAgentCli(target.cwd, requestedAgentId || '', { + fallbackLocator: ws?.rootPath, + hookConfig: useSettingsStore.getState().agentHookInjection[workspaceId], + }) + agentId = agent.id + } catch (error) { + return { + ok: false, + error: error instanceof Error + ? `agent-hooks-not-ready: ${error.message}` + : 'agent-hooks-not-ready', + } + } + + const runId = crypto.randomUUID() + const placementGroupId = target.worktreeId + ? `coding-agent:${target.worktreeId}` + : 'coding-agent:primary' + const launch = { runId, agentId, prompt, ownerPanelId } + const panelId = useAppStore.getState().createTerminal( + workspaceId, + undefined, + undefined, + placementForBackgroundPanel(workspaceId, placementGroupId), + target.cwd, + launch, + ) + if (!panelId) return { ok: false, error: 'panel-creation-failed' } + const store = useAppStore.getState() + if (target.worktreeId) store.setPanelWorktreeId(workspaceId, panelId, target.worktreeId) + const panel = workspace(workspaceId)?.panels[panelId] + if (panel?.codingAgentRun) { + store.setPanelCodingAgentRun(workspaceId, panelId, { + ...panel.codingAgentRun, + worktreeId: target.worktreeId, + }) + } + const label = prompt.replace(/\s+/g, ' ').slice(0, 54) + store.updatePanelTitle(workspaceId, panelId, `${codingAgentDisplayName(agentId)} · ${label}`) + + // Mission workers are processes, not a React mount side effect. Starting + // the existing terminal lifecycle here keeps them alive in inactive + // workspaces/canvases; TerminalPanel later attaches to the same entry. + await terminalRegistry.getOrCreate(panelId, { + workspaceId, + cwd: target.cwd, + codingAgentLaunch: launch, + placementGroupId, + }) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) + return { + ok: true, + result: snapshot ? compactCodingAgentSnapshot(snapshot) : { + id: runId, + panelId, + agentId, + status: 'starting', + }, + } + } + + const runId = typeof args.runId === 'string' ? args.runId : '' + if (name !== 'wait' && !runId) return { ok: false, error: 'runId-required' } + + if (name === 'inspect') { + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) + if (!snapshot) return { ok: false, error: 'coding-agent-not-found' } + return { + ok: true, + result: { + ...compactCodingAgentSnapshot(snapshot), + recentOutput: terminalText(snapshot.panelId), + }, + } + } + + if (name === 'send') { + const panel = runPanel(workspaceId, ownerPanelId, runId) + const run = panel?.codingAgentRun + const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' + if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } + if (!prompt) return { ok: false, error: 'prompt-required' } + if (run.stoppedAt) return { ok: false, error: 'coding-agent-stopped' } + if (!codingAgentSupportsFollowUp(run.agentId)) { + return { ok: false, error: 'coding-agent-follow-up-unsupported' } + } + if (!(await submitTerminalText(panel.id, prompt))) { + return { ok: false, error: 'coding-agent-not-ready' } + } + useAppStore.getState().setPanelCodingAgentRun(workspaceId, panel.id, { + ...run, + followUps: [...(run.followUps ?? []), { prompt, sentAt: Date.now() }], + }) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) + return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : null } + } + + if (name === 'stop') { + const panel = runPanel(workspaceId, ownerPanelId, runId) + const run = panel?.codingAgentRun + if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } + terminalRegistry.terminate(panel.id) + useAppStore.getState().setPanelCodingAgentRun(workspaceId, panel.id, { + ...run, + stoppedAt: Date.now(), + }) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) + return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : null } + } + + if (name === 'wait') { + return waitForCodingAgentChange( + workspaceId, + ownerPanelId, + args.runIds, + codingAgentWaitMs(args.timeoutSeconds), + ) + } + + return { ok: false, error: 'unsupported' } +} diff --git a/src/renderer/lib/agent/codingAgentWait.test.ts b/src/renderer/lib/agent/codingAgentWait.test.ts new file mode 100644 index 00000000..6f634875 --- /dev/null +++ b/src/renderer/lib/agent/codingAgentWait.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { CodingAgentRunSnapshot } from '../../../shared/codingAgentRuns' +import { + actionableCodingAgentRunIds, + changedCodingAgentRunIds, + codingAgentWaitMs, + compactCodingAgentSnapshot, +} from './codingAgentWait' + +function run(id: string, status: CodingAgentRunSnapshot['status']): CodingAgentRunSnapshot { + return { + id, + status, + agentId: 'codex', + agentName: 'Codex', + panelId: `panel-${id}`, + ownerPanelId: 'owner-1', + prompt: 'Test', + createdAt: 1, + cwd: '/repo', + alive: true, + followUpSupported: true, + } +} + +describe('coding-agent wait policy', () => { + it('uses a long default while bounding explicit waits', () => { + expect(codingAgentWaitMs(undefined)).toBe(60_000) + expect(codingAgentWaitMs(1)).toBe(15_000) + expect(codingAgentWaitMs(45)).toBe(45_000) + expect(codingAgentWaitMs(999)).toBe(120_000) + }) + + it('wakes for actionable current states and meaningful transitions', () => { + expect(actionableCodingAgentRunIds([ + run('working', 'working'), + run('blocked', 'waiting'), + run('done', 'ready'), + ])).toEqual(['blocked', 'done']) + + const baseline = new Map([ + ['starting', 'starting'], + ['steady', 'working'], + ]) + expect(changedCodingAgentRunIds(baseline, [ + run('starting', 'working'), + run('steady', 'waiting'), + ])).toEqual(['steady']) + }) + + it('keeps routine results free of repeated prompts and follow-up history', () => { + const snapshot = { + ...run('worker', 'working'), + prompt: 'A very long task the supervisor already sent', + followUps: [{ prompt: 'Another long prompt', sentAt: 2 }], + statusLine: 'Running tests', + } + + expect(compactCodingAgentSnapshot(snapshot)).toEqual({ + id: 'worker', + agentId: 'codex', + agentName: 'Codex', + panelId: 'panel-worker', + status: 'working', + cwd: '/repo', + alive: true, + followUpSupported: true, + statusLine: 'Running tests', + }) + }) +}) diff --git a/src/renderer/lib/agent/codingAgentWait.ts b/src/renderer/lib/agent/codingAgentWait.ts new file mode 100644 index 00000000..7599fa69 --- /dev/null +++ b/src/renderer/lib/agent/codingAgentWait.ts @@ -0,0 +1,60 @@ +import type { CodingAgentRunSnapshot } from '../../../shared/codingAgentRuns' + +export const DEFAULT_CODING_AGENT_WAIT_SECONDS = 60 +export const MIN_CODING_AGENT_WAIT_SECONDS = 15 +export const MAX_CODING_AGENT_WAIT_SECONDS = 120 + +/** Routine lifecycle calls return this compact view instead of replaying the + * original task and follow-up history into the supervisor's context. */ +export function compactCodingAgentSnapshot(snapshot: CodingAgentRunSnapshot) { + return { + id: snapshot.id, + agentId: snapshot.agentId, + agentName: snapshot.agentName, + panelId: snapshot.panelId, + status: snapshot.status, + cwd: snapshot.cwd, + alive: snapshot.alive, + followUpSupported: snapshot.followUpSupported, + ...(snapshot.worktreeId ? { worktreeId: snapshot.worktreeId } : {}), + ...(snapshot.statusLine ? { statusLine: snapshot.statusLine } : {}), + ...(snapshot.failureReason ? { failureReason: snapshot.failureReason } : {}), + } +} + +export function codingAgentWaitMs(value: unknown): number { + const requested = Number(value ?? DEFAULT_CODING_AGENT_WAIT_SECONDS) + const seconds = Number.isFinite(requested) + ? Math.max(MIN_CODING_AGENT_WAIT_SECONDS, Math.min(MAX_CODING_AGENT_WAIT_SECONDS, requested)) + : DEFAULT_CODING_AGENT_WAIT_SECONDS + return seconds * 1_000 +} + +export function actionableCodingAgentRunIds(runs: CodingAgentRunSnapshot[]): string[] { + return runs + .filter((run) => + run.status === 'waiting' || + run.status === 'ready' || + run.status === 'stopped' || + run.status === 'failed', + ) + .map((run) => run.id) +} + +/** Only actionable status transitions should wake the supervisor. Terminal + * output changes continuously, and starting → working is normal progress; + * waking for either would recreate the token-heavy poll loop. */ +export function changedCodingAgentRunIds( + baseline: ReadonlyMap, + runs: CodingAgentRunSnapshot[], +): string[] { + return runs + .filter((run) => + baseline.get(run.id) !== run.status && + (run.status === 'waiting' || + run.status === 'ready' || + run.status === 'stopped' || + run.status === 'failed'), + ) + .map((run) => run.id) +} diff --git a/src/renderer/lib/inheritWorktree.test.ts b/src/renderer/lib/inheritWorktree.test.ts index 27bf391f..08dd9c97 100644 --- a/src/renderer/lib/inheritWorktree.test.ts +++ b/src/renderer/lib/inheritWorktree.test.ts @@ -40,10 +40,11 @@ describe('inheritedWorktreeFromSelection', () => { expect(inheritedWorktreeFromSelection(state, ps)).toEqual({ cwd: '/repo/wt', worktreeId: 'wt-a' }) }) - it('inherits the worktreeId from a selected agent (agents carry no cwd)', () => { + it('inherits the active chat worktree from a selected agent, ignoring panel residue', () => { const state = canvasState(['n1'], true, { n1: node('a1') }) - const ps = panels([{ id: 'a1', type: 'cateAgent', title: 'Agent 1', isDirty: false, worktreeId: 'wt-b' }]) - expect(inheritedWorktreeFromSelection(state, ps)).toEqual({ cwd: undefined, worktreeId: 'wt-b' }) + const ps = panels([{ id: 'a1', type: 'cateAgent', title: 'Agent 1', isDirty: false, worktreeId: 'wt-residue' }]) + expect(inheritedWorktreeFromSelection(state, ps, () => 'wt-chat')) + .toEqual({ cwd: undefined, worktreeId: 'wt-chat' }) }) it('returns {} when the selected node is not a terminal or agent', () => { diff --git a/src/renderer/lib/inheritWorktree.ts b/src/renderer/lib/inheritWorktree.ts index f1580768..4cdb334e 100644 --- a/src/renderer/lib/inheritWorktree.ts +++ b/src/renderer/lib/inheritWorktree.ts @@ -15,6 +15,7 @@ import { isWorktreePanelType } from '../../shared/panels' import type { CanvasStoreState } from '../stores/canvas/storeTypes' import { focusedNodeId } from '../stores/canvas/selectionModel' import { activeDockPanelId } from '../../shared/collectPanelIds' +import { activeChatWorktreeIdForPanel } from '../../cateAgent/renderer/cateAgentStore' export interface InheritedWorktree { /** Explicit working directory of the selected terminal (a dropped folder or a @@ -32,11 +33,15 @@ export interface InheritedWorktree { export function inheritedWorktreeFromSelection( canvasState: Pick, panels: Record | undefined, + agentWorktreeIdForPanel: (panelId: string) => string | undefined = activeChatWorktreeIdForPanel, ): InheritedWorktree { const nodeId = focusedNodeId(canvasState) if (!nodeId || !panels) return {} const panelId = activeDockPanelId(canvasState.nodes[nodeId]?.dockLayout) const panel = panelId ? panels[panelId] : undefined if (!panel || !isWorktreePanelType(panel.type)) return {} + if (panel.type === 'cateAgent') { + return { cwd: undefined, worktreeId: agentWorktreeIdForPanel(panel.id) } + } return { cwd: panel.cwd, worktreeId: panel.worktreeId } } diff --git a/src/renderer/lib/runAction.ts b/src/renderer/lib/runAction.ts index 55a92b65..b4603e1a 100644 --- a/src/renderer/lib/runAction.ts +++ b/src/renderer/lib/runAction.ts @@ -22,6 +22,7 @@ import { provideAppStoreForHistory } from '../stores/canvas/historySlice' import { inheritedWorktreeFromSelection } from './inheritWorktree' import { closePanelWithConfirm } from './closePanelWithConfirm' import { activeDockPanelId } from '../../shared/collectPanelIds' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' /** * Ensures the workspace has a rootPath before proceeding. @@ -119,12 +120,15 @@ export async function runAction( const placement = placementForActivePanel() const wsId = await ensureWorkspaceFolder(selectedWorkspaceId) if (wsId) { - // Same worktree inheritance as newTerminal — an agent carries only a - // worktreeId (no cwd), so bind it when the selection has one. + // Same worktree inheritance as newTerminal, but the target belongs to + // the new agent's first chat rather than its panel record. const canvas = canvasStore() const wt = canvas ? inheritedWorktreeFromSelection(canvas, appStore().getWorkspace(wsId)?.panels) : {} const panelId = appStore().createCateAgent(wsId, undefined, placement) - if (panelId && wt.worktreeId) appStore().setPanelWorktreeId(wsId, panelId, wt.worktreeId) + const rootPath = appStore().getWorkspace(wsId)?.rootPath + if (panelId && rootPath && wt.worktreeId) { + await seedAgentPanelWithWorktreeChat(wsId, rootPath, panelId, wt.worktreeId) + } } break } diff --git a/src/renderer/lib/terminal/terminalBuffer.ts b/src/renderer/lib/terminal/terminalBuffer.ts new file mode 100644 index 00000000..d841b5c3 --- /dev/null +++ b/src/renderer/lib/terminal/terminalBuffer.ts @@ -0,0 +1,17 @@ +import type { Terminal } from '@xterm/xterm' + +/** Read the active xterm buffer once, preserving scrollback and dropping only + * trailing empty rows. Shared by the terminal API and mission supervision. */ +export function readTerminalBuffer(terminal: Terminal): { alt: boolean; text: string } { + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let index = 0; index < buffer.length; index++) { + lines.push(buffer.getLine(index)?.translateToString(true) ?? '') + } + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + return { alt: buffer.type === 'alternate', text: lines.join('\n') } +} + +export function terminalBufferTail(terminal: Terminal, maxChars = 4_000): string { + return readTerminalBuffer(terminal).text.slice(-maxChars) +} diff --git a/src/renderer/lib/terminal/terminalDriver.ts b/src/renderer/lib/terminal/terminalDriver.ts index 0b2ec4ca..99ab52b9 100644 --- a/src/renderer/lib/terminal/terminalDriver.ts +++ b/src/renderer/lib/terminal/terminalDriver.ts @@ -25,10 +25,26 @@ import { useAppStore } from '../../stores/appStore' import { getActivePanelId } from '../activePanel' import { getEntry } from './registryState' -import type { Terminal } from '@xterm/xterm' +import { readTerminalBuffer } from './terminalBuffer' export type TerminalOutcome = { ok: true; result?: unknown } | { ok: false; error: string } +/** Paste one complete prompt using xterm's bracketed-paste semantics, then + * submit it through the same PTY write bridge used by terminal input. */ +export async function submitTerminalText(panelId: string, text: string): Promise { + const entry = getEntry(panelId) + if (!entry?.ptyId || entry.alive === false) return false + try { + entry.terminal.paste(text) + // Let xterm emit the paste payload before the trailing Enter. + await new Promise((resolve) => setTimeout(resolve, 0)) + await window.electronAPI.terminalWrite(entry.ptyId, '\r') + return true + } catch { + return false + } +} + /** Resolve which terminal panel a call targets. `allowFocused` is true only for * `read` — input verbs must address their target explicitly. */ function resolveTargetPanelId( @@ -88,20 +104,6 @@ export function sequenceForKey(raw: string): string | null { // --- read -------------------------------------------------------------------- -/** The rendered screen: the alt screen when a TUI holds the alternate buffer, - * otherwise the whole normal buffer including scrollback (the CLI caps the - * printed tail). Trailing blank lines are dropped. */ -function readScreen(terminal: Terminal): { alt: boolean; text: string } { - const buf = terminal.buffer.active - const alt = buf.type === 'alternate' - const lines: string[] = [] - for (let i = 0; i < buf.length; i++) { - lines.push(buf.getLine(i)?.translateToString(true) ?? '') - } - while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() - return { alt, text: lines.join('\n') } -} - // --- Entry point ------------------------------------------------------------- /** Execute one `cate.terminal.*` method. `method` keeps its full @@ -123,7 +125,7 @@ export async function handleTerminalMethod( if (!entry) return { ok: false, error: 'terminal-not-ready' } if (name === 'read') { - const screen = readScreen(entry.terminal) + const screen = readTerminalBuffer(entry.terminal) return { ok: true, result: { panelId: target.panelId, ...screen } } } diff --git a/src/renderer/lib/terminal/terminalLifecycle.test.ts b/src/renderer/lib/terminal/terminalLifecycle.test.ts index 12b48c92..8519d6eb 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.test.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.test.ts @@ -288,6 +288,32 @@ describe('spawn → wire → dispose happy path', () => { LC.dispose('panel-race') }) + it('terminates the PTY without disposing the xterm kept by a stopped panel', async () => { + terminalCreate.mockResolvedValueOnce('pty-stopped') + const entry = await LC.getOrCreate('panel-stopped', { workspaceId: 'ws-1' }) + const fake = terminalInstances[0] + + LC.terminate('panel-stopped') + + expect(terminalKill).toHaveBeenCalledTimes(1) + expect(terminalKill).toHaveBeenCalledWith('pty-stopped') + expect(statusUnregisterTerminal).toHaveBeenCalledWith('pty-stopped', 'ws-1') + expect(RS.has('panel-stopped')).toBe(true) + expect(RS.ptyIdForPanel('panel-stopped')).toBeNull() + expect(RS.panelIdForPty('pty-stopped')).toBeNull() + expect(entry.alive).toBe(false) + expect(fake.disposeCount).toBe(0) + expect(dataDisposers[0]).not.toHaveBeenCalled() + + // Closing the panel still performs the final renderer teardown without + // trying to kill or unregister the already-terminated PTY again. + LC.dispose('panel-stopped') + expect(fake.disposeCount).toBe(1) + expect(dataDisposers[0]).toHaveBeenCalledTimes(1) + expect(terminalKill).toHaveBeenCalledTimes(1) + expect(statusUnregisterTerminal).toHaveBeenCalledTimes(1) + }) + it('writes initialInput into the terminal after spawn', async () => { await LC.getOrCreate('panel-input', { workspaceId: 'ws-1', initialInput: 'npm test\r' }) expect(terminalInstances[0].writes).toContain('npm test\r') diff --git a/src/renderer/lib/terminal/terminalLifecycle.ts b/src/renderer/lib/terminal/terminalLifecycle.ts index e5468d14..fe798b17 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.ts @@ -39,12 +39,14 @@ import { getActiveTheme } from '../themeManager' import { useStatusStore } from '../../stores/statusStore' import { awaitWorkspaceSync, useAppStore } from '../../stores/appStore' import { replayTerminalLog } from '../workspace/session' +import type { CodingAgentLaunch } from '../../../shared/codingAgentRuns' import { noteAgentInputSubmitted } from '../agent/agentScreenDetector' interface CreateOpts { workspaceId: string cwd?: string initialInput?: string + codingAgentLaunch?: CodingAgentLaunch placementGroupId?: string /** Terminal session-restore: a full agent resume command (e.g. * `claude --resume `) typed into the fresh shell right after spawn, via @@ -190,6 +192,15 @@ export function wireTerminalListeners(args: { if (id === ptyId) { const e = registry.get(panelId) if (e) e.alive = false + const panel = useAppStore.getState().workspaces + .find((workspace) => workspace.id === opts.workspaceId)?.panels[panelId] + if (panel?.codingAgentRun && !panel.codingAgentRun.stoppedAt) { + useAppStore.getState().setPanelCodingAgentRun(opts.workspaceId, panelId, { + ...panel.codingAgentRun, + endedAt: Date.now(), + exitCode, + }) + } terminal.write( `\r\n\x1b[90m[Process exited with code ${exitCode}]\x1b[0m\r\n`, ) @@ -319,6 +330,7 @@ export async function getOrCreate(panelId: string, opts: CreateOpts): Promisexterm listeners + shell registration (shared with // reconnectTerminal via wireTerminalListeners). freshSpawn: this is a @@ -549,6 +564,26 @@ function teardownEntry(entry: RegistryEntry): void { try { terminal.dispose() } catch { /* ignore */ } } +/** + * Kill a terminal's PTY while retaining its xterm instance and scrollback. + * Used when a process stops but its panel remains on the canvas; dispose() + * would remove the xterm DOM and leave the still-mounted panel blank. + */ +export function terminate(panelId: string): void { + const entry = registry.get(panelId) + if (!entry) return + + const { ptyId, workspaceId } = entry + entry.alive = false + entry.ptyId = '' + if (ptyId) ptyToPanel.delete(ptyId) + + if (ptyId) { + window.electronAPI.terminalKill(ptyId).catch((err) => log.warn('[terminal] Kill failed:', err)) + useStatusStore.getState().unregisterTerminal(ptyId, workspaceId) + } +} + /** * Fully tears down a terminal: kills the PTY, disposes all xterm addons and * the Terminal instance, removes IPC listeners, and removes the entry from diff --git a/src/renderer/lib/terminal/terminalRegistry.ts b/src/renderer/lib/terminal/terminalRegistry.ts index 94e6a6d1..fd2ba3cb 100644 --- a/src/renderer/lib/terminal/terminalRegistry.ts +++ b/src/renderer/lib/terminal/terminalRegistry.ts @@ -17,6 +17,7 @@ import './terminalSettings' import { getOrCreate, + terminate, dispose, disposeWorkspace, release, @@ -49,6 +50,7 @@ export { isTerminalPasteChord, isTerminalCopyChord } from './terminalInput' export const terminalRegistry = { getOrCreate, + terminate, attach, detach, dispose, diff --git a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts index ae522ce1..6d5e8059 100644 --- a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts +++ b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts @@ -190,6 +190,38 @@ describe('workspace.json + session.json round-trip', () => { expect(JSON.stringify(wsFile)).not.toContain(agentSession.sessionId) }) + it('round-trips Cate-owned run metadata but never its one-shot launch', () => { + const { snapshot } = buildSnapshot() + const codingAgentRun = { + id: 'run-1', + agentId: 'codex' as const, + panelId: 'term-1', + ownerPanelId: 'agent-1', + prompt: 'Implement the parser', + createdAt: 123, + worktreeId: 'wt-1', + followUps: [{ prompt: 'Add the edge-case test', sentAt: 456 }], + } + snapshot.panels!['term-1'] = { + ...snapshot.panels!['term-1'], + codingAgentRun, + codingAgentLaunch: { + runId: 'run-1', + agentId: 'codex', + prompt: 'Implement the parser', + ownerPanelId: 'agent-1', + }, + } + + const wsFile = throughDisk(buildWorkspaceFile(snapshot, ROOT, '')) + const sessFile = throughDisk(buildSessionFile(snapshot)) + const restored = projectFilesToSnapshot(wsFile, sessFile, ROOT) + + expect(restored.panels!['term-1'].codingAgentRun).toEqual(codingAgentRun) + expect(restored.panels!['term-1'].codingAgentLaunch).toBeUndefined() + expect(JSON.stringify(wsFile)).not.toContain('Implement the parser') + }) + it('a file outside the workspace root keeps its absolute path through the round trip', () => { const { snapshot } = buildSnapshot() snapshot.panels!['ed-out'] = panel({ @@ -305,6 +337,23 @@ describe('workspace.json + session.json round-trip', () => { expect(restored.terminalCwds).toEqual({ 'term-1': WORKTREE_PATH }) }) + it('drops legacy worktree tags from Cate Agent panel records', () => { + const { snapshot } = buildSnapshot() + snapshot.panels!['agent-1'] = panel({ + id: 'agent-1', + type: 'cateAgent', + worktreeId: 'wt-residue', + }) + + const wsFile = throughDisk(buildWorkspaceFile(snapshot, ROOT)) + const sessFile = throughDisk(buildSessionFile(snapshot)) + expect(sessFile.panels['agent-1']).toBeUndefined() + + sessFile.panels['agent-1'] = { panelId: 'agent-1', worktreeId: 'wt-residue' } + const restored = projectFilesToSnapshot(wsFile, sessFile, ROOT) + expect(restored.panels!['agent-1'].worktreeId).toBeUndefined() + }) + it('a Windows-style root round-trips editor paths with native separators', () => { const winRoot = 'C:\\Users\\dev\\repo' const { snapshot } = buildSnapshot() diff --git a/src/renderer/lib/workspace/sessionSerialize.ts b/src/renderer/lib/workspace/sessionSerialize.ts index edea2f00..12c873b6 100644 --- a/src/renderer/lib/workspace/sessionSerialize.ts +++ b/src/renderer/lib/workspace/sessionSerialize.ts @@ -84,18 +84,26 @@ export function buildSessionFile( dockWindows?: DetachedDockWindowSnapshot[], ): ProjectSessionFile { // Machine-local per-panel facts for every placed panel, keyed by id: the - // worktree tag, the terminal's live working directory, and unsaved scratch + // terminal worktree tag, live working directory, and unsaved scratch // content — all kept out of the committed workspace.json. const panels: Record = {} for (const p of Object.values(snapshot.panels ?? {})) { const workingDirectory = snapshot.terminalCwds?.[p.id] - if (!p.worktreeId && !workingDirectory && !p.unsavedContent && !p.agentSession) continue + const worktreeId = p.type === 'terminal' ? p.worktreeId : undefined + if ( + !worktreeId && + !workingDirectory && + !p.unsavedContent && + !p.agentSession && + !p.codingAgentRun + ) continue panels[p.id] = { panelId: p.id, workingDirectory, unsavedContent: p.unsavedContent, - worktreeId: p.worktreeId, + worktreeId, agentSession: p.agentSession, + codingAgentRun: p.codingAgentRun, } } @@ -138,11 +146,12 @@ export function projectFilesToSnapshot( filePath: ref.filePath ? toAbsolutePath(ref.filePath, rootPath) : undefined, ...pickPassthroughPanelFields(ref), // Re-attach the machine-local facts kept out of the committed file. - worktreeId: sp?.worktreeId, + worktreeId: ref.type === 'terminal' ? sp?.worktreeId : undefined, unsavedContent: sp?.unsavedContent, // The agent session to resume in this terminal — TerminalPanel types // the resume command into the fresh shell and clears the field. agentSession: sp?.agentSession, + codingAgentRun: sp?.codingAgentRun, // Restore the per-panel cwd (worktree path / dropped folder) so the // terminal respawns there. TerminalPanel reads panel.cwd directly. The // terminalCwds map below feeds the separate scrollback-restore path. diff --git a/src/renderer/lib/workspace/windowPanelSync.test.tsx b/src/renderer/lib/workspace/windowPanelSync.test.tsx index 51c5c6c9..62e2ffd1 100644 --- a/src/renderer/lib/workspace/windowPanelSync.test.tsx +++ b/src/renderer/lib/workspace/windowPanelSync.test.tsx @@ -43,6 +43,44 @@ function panel(id: string, type: PanelState['type'], worktreeId?: string): Panel const tick = () => new Promise((r) => setTimeout(r, 50)) +describe('windowPanelSync — coding-agent status', () => { + it('does not mark a coding-agent run failed when the terminal has no failure', async () => { + const reports: WindowPanelReport[][] = [] + ;(window as any).electronAPI = { + reportWindowPanels: vi.fn(async (r: WindowPanelReport[]) => { reports.push(r) }), + } + + const ws = 'ws-coding-agent' + const worker = { + ...panel('worker', 'terminal'), + codingAgentRun: { + id: 'run-1', + agentId: 'codex', + panelId: 'worker', + ownerPanelId: 'supervisor-1', + prompt: 'Implement it', + createdAt: 1, + }, + } as PanelState + useAppStore.setState({ + workspaces: [{ + id: ws, name: 'W', color: '', rootPath: '/x', rootPathError: null, + isRootPathPending: false, worktrees: [], + panels: { worker }, + } as any], + selectedWorkspaceId: ws, + } as any) + + const stop = setupWindowPanelSync() + await tick() + + const report = reports[reports.length - 1].find((row) => row.panelId === 'worker') + expect(report?.codingAgentStatus).toBe('starting') + + stop() + }) +}) + describe('windowPanelSync — canvas child parentCanvasId', () => { it('attributes a node whose panel lives only in the LIVE per-node dock (stale raw projection)', async () => { const reports: WindowPanelReport[][] = [] diff --git a/src/renderer/lib/workspace/windowPanelSync.ts b/src/renderer/lib/workspace/windowPanelSync.ts index aeeb4baf..d6d3ec1f 100644 --- a/src/renderer/lib/workspace/windowPanelSync.ts +++ b/src/renderer/lib/workspace/windowPanelSync.ts @@ -24,6 +24,12 @@ import { panelRowLabel } from '../panelTitle' import { useActivePanelStore } from '../activePanel' import { parseLocator } from '../../../shared/runtimeLocator' import { browserPanelUrl, isStartPageUrl, type WindowPanelReport } from '../../../shared/types' +import { + activeChatWorktreeIdForPanel, + useCateAgentStore, +} from '../../../cateAgent/renderer/cateAgentStore' +import { useChatsStore } from '../../stores/chatsStore' +import { deriveCodingAgentRunStatus } from '../../../shared/codingAgentRuns' let cleanup: (() => void) | null = null @@ -120,6 +126,9 @@ export function setupWindowPanelSync(): () => void { ...freePanels, ] for (const p of placed) { + const terminalEntry = p.codingAgentRun ? terminalRegistry.getEntry(p.id) : undefined + const terminalFailure = p.codingAgentRun ? terminalRegistry.getFailure(p.id) : null + const workerAgent = agentInfo[p.id] report.push({ panelId: p.id, type: p.type, @@ -133,9 +142,22 @@ export function setupWindowPanelSync(): () => void { : {}), focused: p.id === activePanelId, parentCanvasId: childToCanvas.get(p.id), - worktreeId: p.worktreeId, + worktreeId: p.type === 'cateAgent' + ? activeChatWorktreeIdForPanel(p.id) + : p.type === 'terminal' ? p.worktreeId : undefined, agentState: agentInfo[p.id]?.state, agentName: agentInfo[p.id]?.name ?? null, + codingAgentRunId: p.codingAgentRun?.id, + codingAgentOwnerPanelId: p.codingAgentRun?.ownerPanelId, + codingAgentStatus: p.codingAgentRun + ? deriveCodingAgentRunStatus(p.codingAgentRun, { + terminalStarted: terminalEntry !== undefined, + terminalAlive: terminalEntry?.alive === true, + terminalFailed: terminalFailure !== null, + agentState: workerAgent?.state, + agentPresent: Boolean(workerAgent?.name), + }) + : undefined, hasPorts: withPorts.has(p.id), }) } @@ -175,6 +197,8 @@ export function setupWindowPanelSync(): () => void { schedule() }) const unsubscribeActivePanel = useActivePanelStore.subscribe(schedule) + const unsubscribeActiveChats = useCateAgentStore.subscribe(schedule) + const unsubscribeChats = useChatsStore.subscribe(schedule) syncCanvasSubscriptions() // Re-report when agent state / ports change so detached rows track the owner's @@ -188,11 +212,15 @@ export function setupWindowPanelSync(): () => void { lastStatusSig = sig schedule() }) + const unsubscribeTerminalFailure = terminalRegistry.subscribeFailure(schedule) cleanup = () => { unsubscribeApp() unsubscribeActivePanel() + unsubscribeActiveChats() + unsubscribeChats() unsubscribeStatus() + unsubscribeTerminalFailure() for (const unsub of canvasSubs.values()) unsub() canvasSubs.clear() if (timer) clearTimeout(timer) diff --git a/src/renderer/lib/workspace/worktreePersistence.test.ts b/src/renderer/lib/workspace/worktreePersistence.test.ts index ccc05085..f6d1dc25 100644 --- a/src/renderer/lib/workspace/worktreePersistence.test.ts +++ b/src/renderer/lib/workspace/worktreePersistence.test.ts @@ -1,6 +1,6 @@ // ============================================================================= // Worktree session-persistence regression tests. Worktree colors/labels and each -// terminal/agent panel's worktree tag are machine-local (gitignored checkouts), +// terminal panel's worktree tag are machine-local (gitignored checkouts), // so they live in session.json. These tests pin that they survive a save/restore // round-trip — previously both were dropped, so colors got re-rolled from the // palette on restart and terminals came back tagged to the primary worktree even @@ -89,6 +89,23 @@ function snapshotWithWorktrees(): SessionSnapshot { describe('worktree session persistence', () => { beforeEach(reset) + it('allows terminal tags but strips Cate Agent panel residue', () => { + const ws = useAppStore.getState().addWorkspace('WT', ROOT, 'ws') + useAppStore.getState().addPanel(ws, { + id: 'terminal-1', type: 'terminal', title: 'Terminal', isDirty: false, + }) + useAppStore.getState().addPanel(ws, { + id: 'agent-1', type: 'cateAgent', title: 'Agent', isDirty: false, worktreeId: 'legacy', + }) + + useAppStore.getState().setPanelWorktreeId(ws, 'terminal-1', 'wt-x') + useAppStore.getState().setPanelWorktreeId(ws, 'agent-1', 'wt-x') + + const panels = useAppStore.getState().getWorkspace(ws)!.panels + expect(panels['terminal-1'].worktreeId).toBe('wt-x') + expect(panels['agent-1'].worktreeId).toBeUndefined() + }) + it('restoreSession hydrates the worktree registry (colors/labels) into the workspace', async () => { const ws = useAppStore.getState().addWorkspace('WT', ROOT, 'ws') await restoreSession(snapshotWithWorktrees(), ws) diff --git a/src/renderer/panels/CanvasPanel.tsx b/src/renderer/panels/CanvasPanel.tsx index 9bb55a87..ce641788 100644 --- a/src/renderer/panels/CanvasPanel.tsx +++ b/src/renderer/panels/CanvasPanel.tsx @@ -32,6 +32,7 @@ import { import { getPanelDef } from '../panels/registry' import { inheritedWorktreeFromSelection } from '../lib/inheritWorktree' import { activeDockPanelId } from '../../shared/collectPanelIds' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' // Re-export the lookup helpers so existing callers (drag dispatcher, drop // resolver) keep working through the same import path. New code should import @@ -257,7 +258,10 @@ export default function CanvasPanel({ panelId, workspaceId, renderPanelContent } const app = useAppStore.getState() const wt = inheritedWorktreeFromSelection(store.getState(), app.getWorkspace(wsId)?.panels) const newId = app.createCateAgent(wsId, undefined, here()) - if (newId && wt.worktreeId) app.setPanelWorktreeId(wsId, newId, wt.worktreeId) + const rootPath = app.getWorkspace(wsId)?.rootPath + if (newId && rootPath && wt.worktreeId) { + await seedAgentPanelWithWorktreeChat(wsId, rootPath, newId, wt.worktreeId) + } }, [workspaceId, here, store]) return ( diff --git a/src/renderer/panels/TerminalPanel.tsx b/src/renderer/panels/TerminalPanel.tsx index 1833c30e..a45892a9 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -66,6 +66,7 @@ export default function TerminalPanel({ workspaceId, nodeId, initialInput, + codingAgentLaunch, }: TerminalPanelProps) { const containerRef = useRef(null) const renderBoxRef = useRef(null) @@ -356,6 +357,7 @@ export default function TerminalPanel({ workspaceId, cwd: rootPathRef.current || undefined, initialInput, + codingAgentLaunch, resumeCommand, placementGroupId, }) @@ -403,7 +405,7 @@ export default function TerminalPanel({ detachAndDisconnect() } - }, [panelId, workspaceId, nodeId, initialInput, placementGroupId, retryKey, ptyEpoch]) + }, [panelId, workspaceId, nodeId, initialInput, codingAgentLaunch, placementGroupId, retryKey, ptyEpoch]) // ------------------------------------------------------------------------- // Focus xterm when this node becomes the focused node diff --git a/src/renderer/panels/keepMountedPanels.test.tsx b/src/renderer/panels/keepMountedPanels.test.tsx index 6c869a7a..e8c4f07f 100644 --- a/src/renderer/panels/keepMountedPanels.test.tsx +++ b/src/renderer/panels/keepMountedPanels.test.tsx @@ -43,6 +43,33 @@ describe('keepMountedOffscreenPanelIds', () => { expect(keepMountedOffscreenPanelIds(panels).has('p-editor')).toBe(false) }) + it('keeps a live Cate-owned coding-agent terminal mounted', () => { + const runPanel: PanelState = { + id: 'worker', + type: 'terminal', + title: 'Codex worker', + isDirty: false, + codingAgentRun: { + id: 'run-1', + agentId: 'codex', + panelId: 'worker', + ownerPanelId: 'agent-1', + prompt: 'Implement it', + createdAt: 1, + }, + } + expect(keepMountedOffscreenPanelIds({ worker: runPanel }).has('worker')).toBe(true) + runPanel.codingAgentRun = { ...runPanel.codingAgentRun!, stoppedAt: 2 } + expect(keepMountedOffscreenPanelIds({ worker: runPanel }).has('worker')).toBe(false) + runPanel.codingAgentRun = { + ...runPanel.codingAgentRun!, + stoppedAt: undefined, + endedAt: 3, + exitCode: 0, + } + expect(keepMountedOffscreenPanelIds({ worker: runPanel }).has('worker')).toBe(false) + }) + it('tolerates a workspace with no panels', () => { expect(keepMountedOffscreenPanelIds(undefined).size).toBe(0) }) diff --git a/src/renderer/panels/keepMountedPanels.ts b/src/renderer/panels/keepMountedPanels.ts index ea80d885..043c3f82 100644 --- a/src/renderer/panels/keepMountedPanels.ts +++ b/src/renderer/panels/keepMountedPanels.ts @@ -25,7 +25,12 @@ export function keepMountedOffscreenPanelIds( const ids = new Set() if (!panels) return ids for (const p of Object.values(panels)) { - if (keepsMountedOffscreen(p.type)) ids.add(p.id) + if ( + keepsMountedOffscreen(p.type) || + (p.codingAgentRun && !p.codingAgentRun.stoppedAt && !p.codingAgentRun.endedAt) + ) { + ids.add(p.id) + } } return ids } diff --git a/src/renderer/panels/registry.ts b/src/renderer/panels/registry.ts index 35cd51a4..57179dfb 100644 --- a/src/renderer/panels/registry.ts +++ b/src/renderer/panels/registry.ts @@ -105,7 +105,10 @@ export const PANEL_REGISTRY: Record = { Component: TerminalPanel, create: ({ workspaceId, canvasPoint, placement, initialInput }) => trackCreated('terminal', useAppStore.getState().createTerminal(workspaceId, initialInput, canvasPoint, placement) || null), - props: baseProps, + props: (panel, ctx) => ({ + ...baseProps(panel, ctx), + codingAgentLaunch: panel.codingAgentLaunch, + }), }, browser: { ...PANEL_DEFINITIONS.browser, diff --git a/src/renderer/panels/types.ts b/src/renderer/panels/types.ts index fd32c1ee..cbc6a2fb 100644 --- a/src/renderer/panels/types.ts +++ b/src/renderer/panels/types.ts @@ -3,6 +3,7 @@ // ============================================================================= import type { BrowserTab } from '../../shared/types' +import type { CodingAgentLaunch } from '../../shared/codingAgentRuns' // ----------------------------------------------------------------------------- // Base panel props @@ -20,6 +21,7 @@ export interface PanelProps { export interface TerminalPanelProps extends PanelProps { initialInput?: string + codingAgentLaunch?: CodingAgentLaunch } export interface EditorPanelProps extends PanelProps { diff --git a/src/renderer/sidebar/WorkspaceTab.tsx b/src/renderer/sidebar/WorkspaceTab.tsx index d068a986..29eb2081 100644 --- a/src/renderer/sidebar/WorkspaceTab.tsx +++ b/src/renderer/sidebar/WorkspaceTab.tsx @@ -31,6 +31,7 @@ import { InlineEditInput } from './InlineEditInput' import { WorkspaceSkillsTree } from './WorkspaceSkillsTree' import { canvasKey, toggleCollapsed, useTreeCollapseStore } from './treeCollapse' import { Tooltip } from '../ui/Tooltip' +import { useActiveChatWorktreeByPanel } from '../../cateAgent/renderer/cateAgentStore' // Stable empty map so the ports selector returns a referentially-constant value // when a workspace has no status entry (a fresh `{}` each render would defeat @@ -291,6 +292,7 @@ export const WorkspaceTab: React.FC = ({ const ws = s.workspaces.find((w) => w.id === workspace.id) return ws?.worktrees ?? workspace.worktrees ?? [] })) + const activeChatWorktreeByPanel = useActiveChatWorktreeByPanel() // Ports in the status store are keyed by ptyId, but panel rows are keyed by @@ -567,8 +569,12 @@ export const WorkspaceTab: React.FC = ({ const wt = worktrees.find((w) => w.id === worktreeId) ?? worktrees.find((w) => w.path === workspace.rootPath) return wt?.color } - const worktreeColorFor = (panelId: string): string | undefined => - worktreeColorForId(panels[panelId]?.worktreeId) + const worktreeColorFor = (panelId: string): string | undefined => { + const panel = panels[panelId] + return worktreeColorForId( + panel?.type === 'cateAgent' ? activeChatWorktreeByPanel[panelId] : panel?.worktreeId, + ) + } // A panel living in another window — click focuses that window and reveals it. // Read-only (no rename/close), since it isn't hosted here, but otherwise diff --git a/src/renderer/stores/appStore/panelSlice.ts b/src/renderer/stores/appStore/panelSlice.ts index 7d94939b..591831a3 100644 --- a/src/renderer/stores/appStore/panelSlice.ts +++ b/src/renderer/stores/appStore/panelSlice.ts @@ -55,6 +55,8 @@ type PanelSliceActions = Pick< | 'setPanelUnsavedContent' | 'setPanelInitialChat' | 'setPanelAgentSession' + | 'setPanelCodingAgentLaunch' + | 'setPanelCodingAgentRun' | 'addPanel' | 'removePanelRecord' | 'clearCanvas' @@ -79,7 +81,7 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { return { // --- Panel creation --- - createTerminal(workspaceId, initialInput?, position?, placement?, cwd?) { + createTerminal(workspaceId, initialInput?, position?, placement?, cwd?, codingAgentLaunch?) { const panelId = generateId() // Auto-number terminal titles so `cate ask "Terminal 2"` and similar // inter-panel calls address each one unambiguously — unique across ALL @@ -90,6 +92,17 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { title: nextNumberedTitle(get, workspaceId, 'terminal', 'Terminal'), isDirty: false, ...(cwd ? { cwd } : {}), + ...(codingAgentLaunch ? { + codingAgentLaunch, + codingAgentRun: { + id: codingAgentLaunch.runId, + agentId: codingAgentLaunch.agentId, + panelId, + ownerPanelId: codingAgentLaunch.ownerPanelId, + prompt: codingAgentLaunch.prompt, + createdAt: Date.now(), + }, + } : {}), } return addAndPlacePanel(set, get, workspaceId, panel, withDefaultSize('terminal', placement), position) }, @@ -331,6 +344,20 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { }) }, + setPanelCodingAgentLaunch(workspaceId, panelId, launch) { + setPanelField(set, workspaceId, panelId, (panel) => ({ + ...panel, + codingAgentLaunch: launch, + })) + }, + + setPanelCodingAgentRun(workspaceId, panelId, run) { + setPanelField(set, workspaceId, panelId, (panel) => ({ + ...panel, + codingAgentRun: run, + })) + }, + addPanel(workspaceId, panel) { set((state) => ({ workspaces: state.workspaces.map((ws) => diff --git a/src/renderer/stores/appStore/types.ts b/src/renderer/stores/appStore/types.ts index b47fedbe..fcba0e64 100644 --- a/src/renderer/stores/appStore/types.ts +++ b/src/renderer/stores/appStore/types.ts @@ -17,6 +17,7 @@ import type { RuntimeConnection, RuntimePhase, } from '../../../shared/types' +import type { CodingAgentLaunch, CodingAgentRun } from '../../../shared/codingAgentRuns' // ----------------------------------------------------------------------------- // Panel placement — specifies where a newly created panel should go @@ -81,7 +82,14 @@ export interface AppStoreActions { removeWorkspace: (id: string, forgetRecent?: boolean) => void // Panel creation — each adds a PanelState to the workspace AND places it - createTerminal: (workspaceId: string, initialInput?: string, position?: Point, placement?: PanelPlacement, cwd?: string) => string + createTerminal: ( + workspaceId: string, + initialInput?: string, + position?: Point, + placement?: PanelPlacement, + cwd?: string, + codingAgentLaunch?: CodingAgentLaunch, + ) => string createBrowser: (workspaceId: string, url?: string, position?: Point, placement?: PanelPlacement, proxyUrl?: string) => string createEditor: (workspaceId: string, filePath?: string, position?: Point, placement?: PanelPlacement) => string createDiffEditor: (workspaceId: string, filePath: string, diffMode: 'staged' | 'working', position?: Point, placement?: PanelPlacement) => string @@ -119,6 +127,8 @@ export interface AppStoreActions { setPanelUnsavedContent: (workspaceId: string, panelId: string, content: string | undefined) => void setPanelInitialChat: (workspaceId: string, panelId: string, chatId: string) => void setPanelAgentSession: (workspaceId: string, panelId: string, session: TerminalAgentSession | null) => void + setPanelCodingAgentLaunch: (workspaceId: string, panelId: string, launch: CodingAgentLaunch | undefined) => void + setPanelCodingAgentRun: (workspaceId: string, panelId: string, run: CodingAgentRun | undefined) => void addPanel: (workspaceId: string, panel: PanelState) => void removePanelRecord: (workspaceId: string, panelId: string) => void diff --git a/src/renderer/stores/appStore/worktreeSlice.ts b/src/renderer/stores/appStore/worktreeSlice.ts index a735f8c7..5e0823a6 100644 --- a/src/renderer/stores/appStore/worktreeSlice.ts +++ b/src/renderer/stores/appStore/worktreeSlice.ts @@ -8,7 +8,8 @@ import type { AppSet, AppGet, AppStoreActions } from './types' import { pickWorktreeColor, setPanelField } from './helpers' import { terminalRegistry } from '../../lib/terminal/terminalRegistry' import { useSettingsStore } from '../settingsStore' -import { isWorktreePanelType } from '../../../shared/panels' +import { activeChatWorktreeIdForPanel } from '../../../cateAgent/renderer/cateAgentStore' +import { useChatsStore } from '../chatsStore' type WorktreeSliceActions = Pick< AppStoreActions, @@ -77,16 +78,29 @@ export function createWorktreeSlice(set: AppSet, get: AppGet): WorktreeSliceActi }, removeWorktree(wsId, worktreeId) { - // Optionally destroy the worktree's terminal/agent panels (PTYs killed, - // pi sessions disposed) before we drop the worktree record. Done outside + // Optionally destroy the worktree's terminal panels (PTYs killed) before + // we drop the worktree record. Agent targets live on chats, not panels. + // Done outside // the set() updater because closePanel runs its own teardown + set(). if (useSettingsStore.getState().closeWorktreePanelsOnDelete) { const ws = get().workspaces.find((w) => w.id === wsId) const doomed = Object.values(ws?.panels ?? {}).filter( - (p) => p.worktreeId === worktreeId && isWorktreePanelType(p.type), + (p) => ( + (p.type === 'terminal' && p.worktreeId === worktreeId) || + (p.type === 'cateAgent' && activeChatWorktreeIdForPanel(p.id) === worktreeId) + ), ) for (const p of doomed) get().closePanel(wsId, p.id) } + const ws = get().workspaces.find((w) => w.id === wsId) + const chats = useChatsStore.getState() + if (ws?.rootPath && chats.loadedRoots[ws.rootPath]) { + for (const chat of chats.getChats(ws.rootPath)) { + if (chat.worktreeId === worktreeId) { + chats.patchChat(ws.rootPath, chat.id, { worktreeId: undefined }) + } + } + } set((state) => ({ workspaces: state.workspaces.map((ws) => { if (ws.id !== wsId) return ws @@ -130,7 +144,11 @@ export function createWorktreeSlice(set: AppSet, get: AppGet): WorktreeSliceActi }, setPanelWorktreeId(wsId, panelId, worktreeId) { - setPanelField(set, wsId, panelId, (panel) => ({ ...panel, worktreeId })) + setPanelField(set, wsId, panelId, (panel) => ( + panel.type === 'terminal' + ? { ...panel, worktreeId } + : { ...panel, worktreeId: undefined } + )) }, respawnPanelTerminal(wsId, panelId, cwd, worktreeId) { diff --git a/src/renderer/stores/chatsStore.test.ts b/src/renderer/stores/chatsStore.test.ts index 8317f8b1..725b68f3 100644 --- a/src/renderer/stores/chatsStore.test.ts +++ b/src/renderer/stores/chatsStore.test.ts @@ -69,10 +69,11 @@ describe('chat host ownership', () => { it('creates new chats in the requested host', () => { const sidebar = useChatsStore.getState().createChat(ROOT, 'Sidebar chat') - const panel = useChatsStore.getState().createChat(ROOT, 'Panel chat', 'agent-a') + const panel = useChatsStore.getState().createChat(ROOT, 'Panel chat', 'agent-a', 'wt-feature') expect(sidebar.hostPanelId).toBeUndefined() expect(panel.hostPanelId).toBe('agent-a') + expect(panel.worktreeId).toBe('wt-feature') }) it('coalesces concurrent loads so a late response cannot overwrite a move', async () => { diff --git a/src/renderer/stores/chatsStore.ts b/src/renderer/stores/chatsStore.ts index b52b2690..99fe4ee0 100644 --- a/src/renderer/stores/chatsStore.ts +++ b/src/renderer/stores/chatsStore.ts @@ -38,7 +38,7 @@ interface ChatsStoreActions { /** Find one chat by id (undefined if absent). */ getChat: (rootPath: string, id: string) => Chat | undefined /** Create a fresh empty Cate Agent chat in the sidebar (default) or one panel. */ - createChat: (rootPath: string, title: string, hostPanelId?: string) => Chat + createChat: (rootPath: string, title: string, hostPanelId?: string, worktreeId?: string) => Chat /** Move one chat to an Agent panel, or to the sidebar when panelId is null. */ moveChat: (rootPath: string, id: string, panelId: string | null) => void /** Return every chat owned by any of these closing panels to the sidebar. */ @@ -100,7 +100,7 @@ export const useChatsStore = create((set, get) => ({ return (get().chatsByRoot[rootPath] ?? []).find((c) => c.id === id) }, - createChat(rootPath, title, hostPanelId) { + createChat(rootPath, title, hostPanelId, worktreeId) { const now = Date.now() const chat: Chat = { id: generateId(), @@ -108,6 +108,7 @@ export const useChatsStore = create((set, get) => ({ createdAt: now, updatedAt: now, ...(hostPanelId ? { hostPanelId } : {}), + ...(worktreeId ? { worktreeId } : {}), } const next = [...(get().chatsByRoot[rootPath] ?? []), chat] set((s) => ({ diff --git a/src/renderer/stores/useParallelWork.ts b/src/renderer/stores/useParallelWork.ts index aae97755..27346299 100644 --- a/src/renderer/stores/useParallelWork.ts +++ b/src/renderer/stores/useParallelWork.ts @@ -20,9 +20,11 @@ import type { WorktreeMeta } from '../../shared/types' import type { PrListItem } from '../sidebar/CreateWorktreeForm' import type { NativeContextMenuItem } from '../../shared/electron-api' import { isLocalLocator } from '../../shared/runtimeLocator' -import { isWorktreePanelType, type WorktreePanelType } from '../../shared/panels' +import type { WorktreePanelType } from '../../shared/panels' import { errorMessage } from '../lib/errorMessage' import { pathKey } from '../../shared/pathUtils' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' +import { activeChatWorktreeIdForPanel } from '../../cateAgent/renderer/cateAgentStore' /** Apply a color/label change to a worktree's UI metadata, creating the metadata * record when none exists yet (a worktree discovered only from git has its path @@ -166,9 +168,11 @@ export function useParallelWork( type === 'terminal' ? s.createTerminal(workspaceId, undefined, undefined, placement, wt.path) : s.createCateAgent(workspaceId, undefined, placement) - if (panelId) s.setPanelWorktreeId(workspaceId, panelId, wt.id) + if (!panelId) return + if (type === 'terminal') s.setPanelWorktreeId(workspaceId, panelId, wt.id) + else void seedAgentPanelWithWorktreeChat(workspaceId, rootPath, panelId, wt.id) }, - [workspaceId], + [rootPath, workspaceId], ) const handlePublish = useCallback( @@ -287,12 +291,15 @@ export function useParallelWork( } const dirty = !!status?.dirty const branchAhead = (status?.ahead ?? 0) > 0 - // When the close-on-delete setting is on, count the terminal/agent panels - // bound to this worktree so the prompt warns about what it'll tear down. + // When close-on-delete is on, count terminals by their panel tag and + // agents by their active chat tag. const ws = useAppStore.getState().workspaces.find((w) => w.id === workspaceId) const panelCount = useSettingsStore.getState().closeWorktreePanelsOnDelete ? Object.values(ws?.panels ?? {}).filter( - (p) => p.worktreeId === wt.id && isWorktreePanelType(p.type), + (p) => ( + (p.type === 'terminal' && p.worktreeId === wt.id) || + (p.type === 'cateAgent' && activeChatWorktreeIdForPanel(p.id) === wt.id) + ), ).length : 0 const ok = window.confirm( diff --git a/src/renderer/stores/useWorktreeActions.ts b/src/renderer/stores/useWorktreeActions.ts index 72e270a9..372362e5 100644 --- a/src/renderer/stores/useWorktreeActions.ts +++ b/src/renderer/stores/useWorktreeActions.ts @@ -50,6 +50,38 @@ export interface WorktreeActions { checkoutPr: (pr: PrListItem) => Promise } +/** Imperative core shared by the React hook and Cate Agent's orchestration + * driver. Keeping one path preserves branch sanitization, symlink settings, + * metadata colors, additional-root registration, and git refresh behavior. */ +export async function createWorktreeForWorkspace( + rootPath: string, + workspaceId: string, + rawName: string, + baseRef?: string, +): Promise { + const branch = toBranchName(rawName) + if (!branch) throw new Error('Please enter a name') + const targetPath = worktreePathFor(rootPath, branch) + await window.electronAPI.gitWorktreeAdd(rootPath, branch, targetPath, { + createBranch: true, + baseRef, + symlinkPaths: configuredSymlinkPaths(), + }, workspaceId) + + const store = useAppStore.getState() + const ws = store.workspaces.find((workspace) => workspace.id === workspaceId) + const meta: WorktreeMeta = { + id: newWorktreeId(), + path: targetPath, + label: rawName.trim() !== branch ? rawName.trim() : undefined, + color: pickWorktreeColor(ws?.worktrees ?? []), + } + store.upsertWorktree(workspaceId, meta) + store.addAdditionalRoot(workspaceId, targetPath) + gitStatusStore.refresh(rootPath) + return meta +} + export function useWorktreeActions(rootPath: string, workspaceId: string | null): WorktreeActions { const upsertWorktree = useAppStore((s) => s.upsertWorktree) const addAdditionalRoot = useAppStore((s) => s.addAdditionalRoot) @@ -57,29 +89,9 @@ export function useWorktreeActions(rootPath: string, workspaceId: string | null) const createWorktree = useCallback( async (rawName: string, baseRef?: string) => { if (!rootPath || !workspaceId) return null - const branch = toBranchName(rawName) - if (!branch) throw new Error('Please enter a name') - const targetPath = worktreePathFor(rootPath, branch) - await window.electronAPI.gitWorktreeAdd(rootPath, branch, targetPath, { - createBranch: true, - baseRef, - symlinkPaths: configuredSymlinkPaths(), - }, workspaceId) - - const ws = useAppStore.getState().workspaces.find((w) => w.id === workspaceId) - const meta: WorktreeMeta = { - id: newWorktreeId(), - path: targetPath, - // Keep the friendly name when it differs from the slugged branch. - label: rawName.trim() !== branch ? rawName.trim() : undefined, - color: pickWorktreeColor(ws?.worktrees ?? []), - } - upsertWorktree(workspaceId, meta) - addAdditionalRoot(workspaceId, targetPath) - gitStatusStore.refresh(rootPath) - return meta + return createWorktreeForWorkspace(rootPath, workspaceId, rawName, baseRef) }, - [rootPath, workspaceId, upsertWorktree, addAdditionalRoot], + [rootPath, workspaceId], ) const checkoutPr = useCallback( diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index e8008621..49bb912d 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -214,6 +214,19 @@ background-color: var(--scrollbar-thumb-hover); } +/* Chat tabs reserve their horizontal scrollbar lane, but reveal its thumb only + while the pointer is over the tab strip. */ +.cate-agent-chat-tabs .cate-agent-chat-tabs-scroll::-webkit-scrollbar-thumb { + background-color: transparent; + transition: background-color 160ms ease; +} +.cate-agent-chat-tabs:hover .cate-agent-chat-tabs-scroll::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); +} +.cate-agent-chat-tabs:hover .cate-agent-chat-tabs-scroll::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} + /* Terminal scrollbar: the same thin rounded floating pill as the base, but only visible while the pointer is over the terminal or the terminal is focused (xterm toggles `.focus` on its root element). The thumb is transparent until diff --git a/src/runtime/capabilities/process.agentHooks.test.ts b/src/runtime/capabilities/process.agentHooks.test.ts index 4f91d43c..d83404fd 100644 --- a/src/runtime/capabilities/process.agentHooks.test.ts +++ b/src/runtime/capabilities/process.agentHooks.test.ts @@ -50,4 +50,34 @@ describe('process agent hook preparation', () => { ) processCapability.kill(handle.id) }) + + test('spawns an exact trusted command instead of interpolating it through a shell', async () => { + const processCapability = createProcessCapability({ + resolveShell: () => ({ path: '/bin/sh', args: ['-l'] }), + getEnv: () => ({ PATH: '/usr/bin:/bin' }), + }) + + const handle = await processCapability.create( + { + id: 'pty-codex', + cols: 80, + rows: 24, + cwd: '/repo/worktree', + command: { + executable: 'codex', + args: ['Fix it; touch /tmp/not-shell-syntax'], + }, + }, + () => {}, + () => {}, + ) + + expect(ptySpawn).toHaveBeenCalledWith( + 'codex', + ['Fix it; touch /tmp/not-shell-syntax'], + expect.objectContaining({ cwd: '/repo/worktree' }), + ) + expect(handle.shell).toBe('codex') + processCapability.kill(handle.id) + }) }) diff --git a/src/runtime/capabilities/process.ts b/src/runtime/capabilities/process.ts index 55547fad..66f43d8c 100644 --- a/src/runtime/capabilities/process.ts +++ b/src/runtime/capabilities/process.ts @@ -275,6 +275,8 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { const id = opts.id ?? `pty-${Date.now()}-${Math.round(seq++ + Math.random() * 1e6).toString(36)}` const ptySpawn = await getPtySpawn() const shell = deps.resolveShell(opts.shell) + const executable = opts.command?.executable ?? shell.path + const args = opts.command?.args ?? shell.args const cwd = opts.cwd || os.homedir() // Merge caller env over the host env; when a CLI endpoint was injected // (CATE_API), also put the bundled `cate` on PATH so agents can run it. @@ -289,7 +291,7 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { await deps.hooks.prepareWorkspace(cwd, opts.agentHookConfig, opts.workspaceBaseCwd) } catch { /* hook injection unavailable */ } } - const pty = ptySpawn(shell.path, shell.args, { + const pty = ptySpawn(executable, args, { name: 'xterm-256color', cols: opts.cols, rows: opts.rows, @@ -314,7 +316,12 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { deps.agentPresence?.drop(id) onExit(id, exitCode) }) - return { id, pid: pty.pid, notice: shell.notice, shell: shell.path } + return { + id, + pid: pty.pid, + notice: opts.command ? undefined : shell.notice, + shell: executable, + } }, write(id: string, data: string): void { diff --git a/src/shared/agentErrorMessage.test.ts b/src/shared/agentErrorMessage.test.ts new file mode 100644 index 00000000..da60c12b --- /dev/null +++ b/src/shared/agentErrorMessage.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { agentErrorMessage } from './agentErrorMessage' + +describe('agentErrorMessage', () => { + it('hides extension paths and runtime diagnostics', () => { + const raw = + 'Agent process exited (code 1). Error: Failed to load extension ' + + '"/Users/anton/Dev/repo/.cate/cate-agent/extensions/cate-orchestrator/index.ts": ' + + 'Extension runtime not initialized. Action methods cannot be called during extension loading.' + + const message = agentErrorMessage(raw) + + expect(message).toBe( + 'Cate couldn’t load its agent tools. Restart Cate and start a new chat.', + ) + expect(message).not.toContain('/Users/') + expect(message).not.toContain('Extension runtime') + }) + + it('maps common provider failures to actionable copy', () => { + expect(agentErrorMessage('HTTP 429 rate_limit_exceeded')).toContain('rate-limiting') + expect(agentErrorMessage('model not supported')).toContain('Choose another model') + expect(agentErrorMessage('connect ECONNREFUSED 127.0.0.1')).toContain('Check your connection') + }) + + it('never echoes an unknown raw failure', () => { + expect(agentErrorMessage('secret internal stack and /private/path')).toBe( + 'The Cate agent ran into a problem. Try again.', + ) + }) +}) diff --git a/src/shared/agentErrorMessage.ts b/src/shared/agentErrorMessage.ts new file mode 100644 index 00000000..fc8af1b3 --- /dev/null +++ b/src/shared/agentErrorMessage.ts @@ -0,0 +1,56 @@ +const SAFE_PREFIXES = [ + 'Cate couldn’t', + 'The Cate agent', + 'The selected model', + 'The model provider', + 'This chat', +] + +function rawMessage(error: unknown): string { + if (typeof error === 'string') return error + if (error instanceof Error) return error.message + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' + ) { + return (error as { message: string }).message + } + return '' +} + +/** Convert agent/runtime failures into bounded user-facing copy. Technical + * details stay in logs; unknown messages are never echoed into the chat UI. */ +export function agentErrorMessage( + error: unknown, + fallback = 'The Cate agent ran into a problem. Try again.', +): string { + const message = rawMessage(error).trim() + if (!message) return fallback + if (!message.includes('\n') && SAFE_PREFIXES.some((prefix) => message.startsWith(prefix))) { + return message + } + if (/failed to load extension|extension runtime not initialized|extension[_\s-]error/i.test(message)) { + return 'Cate couldn’t load its agent tools. Restart Cate and start a new chat.' + } + if (/agent process exited|pi process exited|agent exited/i.test(message)) { + return 'The Cate agent stopped unexpectedly. Start a new chat and try again.' + } + if (/authentication|unauthorized|invalid api key|HTTP 401|HTTP 403/i.test(message)) { + return 'The selected model couldn’t authenticate. Check its provider connection.' + } + if (/rate.?limit|too many requests|HTTP 429/i.test(message)) { + return 'The model provider is rate-limiting requests. Wait a moment and try again.' + } + if (/model not supported|unknown model|model .* not found/i.test(message)) { + return 'The selected model is unavailable. Choose another model and try again.' + } + if (/context length|context window|too many tokens|maximum context/i.test(message)) { + return 'This chat exceeded the model’s context limit. Compact it or start a new chat.' + } + if (/ECONNREFUSED|ETIMEDOUT|ENOTFOUND|network|socket hang up|fetch failed/i.test(message)) { + return 'Cate couldn’t reach the model provider. Check your connection and try again.' + } + return fallback +} diff --git a/src/shared/agents.ts b/src/shared/agents.ts index 81501555..8d8a0a27 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -80,6 +80,10 @@ export interface AgentDef { /** The CLI command that launches this agent in a terminal — usually the same * as the detected process name. */ command: string + /** Build the shell-free argv for a new Cate-owned mission worker. */ + codingAgentArgs: (prompt: string) => string[] + /** Whether the launched surface accepts another prompt on the same PTY. */ + codingAgentFollowUp: boolean /** True when a shell child process with this (already-lowercased) name means * this agent is the one running in that terminal. */ matchProcess: (procName: string) => boolean @@ -120,6 +124,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'claude-code', displayName: 'Claude Code', command: 'claude', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'claude' || n === 'claude-code' || n.startsWith('claude'), resumeArgs: (sid) => ['--resume', sid], // claude is the standard's origin: it REQUIRES frontmatter name === dir name. @@ -129,6 +135,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'codex', displayName: 'Codex', command: 'codex', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'codex', resumeArgs: (sid) => ['resume', sid], skills: folderSkills('codex', ['.codex', 'skills']), @@ -140,6 +148,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'cursor', displayName: 'Cursor', command: 'cursor-agent', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'cursor-agent' || n === 'cursor', // --resume ADOPTS an unknown id (fresh chat under that id, exit 0) rather // than failing — a stale stamp degrades to a fresh session, never a wrong one. @@ -156,6 +166,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'grok', displayName: 'Grok', command: 'grok', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'grok' || /^grok-\d/.test(n), // --resume ERRORS on an id with no session on disk (pinned live), so a stale // stamp falls back to a plain shell instead of silently opening a fresh chat. @@ -170,6 +182,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'opencode', displayName: 'OpenCode', command: 'opencode', + codingAgentArgs: (prompt) => ['run', prompt], + codingAgentFollowUp: false, matchProcess: (n) => n === 'opencode', resumeArgs: (sid) => ['--session', sid], skills: folderSkills('opencode', ['.opencode', 'skills']), @@ -179,6 +193,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'pi', displayName: 'PI Agent', command: 'pi', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'pi', // pi's --resume is an interactive picker; --session takes an exact id. resumeArgs: (sid) => ['--session', sid], diff --git a/src/shared/codingAgentRuns.test.ts b/src/shared/codingAgentRuns.test.ts new file mode 100644 index 00000000..363c7f68 --- /dev/null +++ b/src/shared/codingAgentRuns.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { codingAgentCommand, parseCodingAgentId } from './codingAgentRuns' + +describe('codingAgentCommand', () => { + it('resolves only canonical agent ids to exact argv without a shell', () => { + expect(codingAgentCommand({ + agentId: 'codex', + prompt: 'Fix it; touch /tmp/pwned', + })).toEqual({ + executable: 'codex', + args: ['Fix it; touch /tmp/pwned'], + }) + expect(codingAgentCommand({ + agentId: 'opencode', + prompt: 'Implement the parser', + })).toEqual({ + executable: 'opencode', + args: ['run', 'Implement the parser'], + }) + }) + + it('rejects unknown ids and blank tasks', () => { + expect(parseCodingAgentId('/tmp/fake-agent')).toBeNull() + expect(parseCodingAgentId('codex')).toBe('codex') + expect(() => codingAgentCommand({ agentId: 'pi', prompt: ' ' })).toThrow( + 'A coding-agent prompt is required', + ) + }) +}) diff --git a/src/shared/codingAgentRuns.ts b/src/shared/codingAgentRuns.ts new file mode 100644 index 00000000..4366af45 --- /dev/null +++ b/src/shared/codingAgentRuns.ts @@ -0,0 +1,114 @@ +import { AGENTS, type AgentId } from './agents' + +/** A coding agent process Cate created and owns inside a terminal panel. */ +export interface CodingAgentRun { + id: string + agentId: AgentId + panelId: string + /** Cate Agent panel/session that owns and may control this run. */ + ownerPanelId: string + prompt: string + createdAt: number + worktreeId?: string + /** Follow-up prompts sent after the initial task. Kept with panel state so + * mission context survives a Cate restart. */ + followUps?: Array<{ prompt: string; sentAt: number }> + endedAt?: number + exitCode?: number + stoppedAt?: number +} + +/** One-shot launch data consumed when the terminal's PTY is first spawned. */ +export interface CodingAgentLaunch { + runId: string + agentId: AgentId + prompt: string + ownerPanelId: string +} + +export type CodingAgentRunStatus = + | 'starting' + | 'working' + | 'waiting' + | 'ready' + | 'stopped' + | 'failed' + +export interface CodingAgentRuntimeState { + terminalStarted: boolean + terminalAlive: boolean + terminalFailed: boolean + agentState?: 'notRunning' | 'running' | 'waitingForInput' | 'finished' + agentPresent?: boolean +} + +/** One status policy shared by the renderer supervisor and the cross-window + * discovery report. Keeping this pure prevents detached-worker waits from + * interpreting the same terminal differently. */ +export function deriveCodingAgentRunStatus( + run: CodingAgentRun, + runtime: CodingAgentRuntimeState, +): CodingAgentRunStatus { + if (run.stoppedAt) return 'stopped' + if (run.endedAt) return run.exitCode === 0 ? 'ready' : 'failed' + if (runtime.terminalFailed) return 'failed' + if (!runtime.terminalStarted) return 'starting' + if (!runtime.terminalAlive) return 'ready' + switch (runtime.agentState) { + case 'running': return 'working' + case 'waitingForInput': return 'waiting' + case 'finished': return 'ready' + case 'notRunning': + default: + return runtime.agentPresent ? 'working' : 'starting' + } +} + +export interface CodingAgentRunSnapshot extends CodingAgentRun { + status: CodingAgentRunStatus + agentName: string + cwd: string + alive: boolean + /** Derived from the canonical agent capability registry. */ + followUpSupported: boolean + statusLine?: string + failureReason?: string +} + +export const MAX_CONCURRENT_CODING_AGENTS = 5 + +/** Resolve an untrusted tool argument to the closed, canonical agent registry. */ +export function parseCodingAgentId(value: unknown): AgentId | null { + if (typeof value !== 'string') return null + return AGENTS.some((agent) => agent.id === value) ? (value as AgentId) : null +} + +/** + * Build the exact executable + argv for a Cate-owned coding-agent PTY. + * + * No shell is involved, so task text cannot become shell syntax. Every + * executable comes from AGENTS; callers cannot provide a path or extra flags. + * OpenCode's prompt-bearing surface is its `run` command. Other supported CLIs + * accept the first positional prompt while remaining interactive. + */ +export function codingAgentCommand( + launch: Pick, +): { executable: string; args: string[] } { + const agent = AGENTS.find((candidate) => candidate.id === launch.agentId) + if (!agent) throw new Error(`Unsupported coding agent: ${launch.agentId}`) + const prompt = launch.prompt.trim() + if (!prompt) throw new Error('A coding-agent prompt is required') + if (prompt.includes('\0')) throw new Error('Coding-agent prompts cannot contain NUL bytes') + return { + executable: agent.command, + args: agent.codingAgentArgs(prompt), + } +} + +export function codingAgentDisplayName(agentId: AgentId): string { + return AGENTS.find((agent) => agent.id === agentId)?.displayName ?? agentId +} + +export function codingAgentSupportsFollowUp(agentId: AgentId): boolean { + return AGENTS.find((agent) => agent.id === agentId)?.codingAgentFollowUp ?? false +} diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 88d2d5b3..889efa53 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -3,6 +3,7 @@ // ============================================================================= import type { CodingCreateOptions, CodingEventEnvelope, CodingExtensionUIResponse, CodingImageAttachment, CateAgentModelRef, CodingModelDescriptor, CodingRpcState, CodingSessionListEntry, CodingSessionStats, CodingSlashCommand, CodingThinkingLevel, AppSettings, AgentState, AuthProviderDescriptor, AuthProviderStatus, CustomOpenAIProvider, DockWindowInitPayload, DockWindowSyncState, DetachedDockWindowSnapshot, WindowPanelInfo, WindowPanelReport, FileSearchOptions, FileSearchResult, FileTreeNode, SearchOptions, SearchResultBatch, SearchDoneEvent, NotificationAction, OAuthFlowEvent, PanelTransferSnapshot, PerfSnapshot, Point, ProviderVerification, SidebarSession, TerminalActivity, TerminalAgentSession, WorkspaceInfo, WorkspaceMutationResult, RemoteConnectSpec, RuntimeConnectResult, RuntimeStatusEvent, RuntimeConnection, RuntimePhase, RemoteProjectEntry, SshHostEntry, UIState } from './types' +import type { CodingAgentLaunch } from './codingAgentRuns' import type { SavedSkill, InstalledSkill, SkillEntry, SkillSource, SkillTargetId } from './skills' import type { AgentHookEvent, AgentHookAgentState } from './agentHooks' import type { ExtensionListEntry, ExtensionManifest } from './extensions' @@ -61,6 +62,8 @@ export interface ElectronAPI { panelId?: string /** Opaque canvas affinity, exposed to the shell for host-API creates. */ placementGroupId?: string + /** Closed agent launch contract; main resolves the executable and argv. */ + codingAgentLaunch?: CodingAgentLaunch }): Promise /** Write data (keystrokes) to a terminal. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 9b8210a8..2de08cf8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -7,6 +7,7 @@ import type { Theme } from './theme' export type { Theme } from './theme' import type { AgentId } from './agents' import type { AgentHookMode } from './agentHooks' +import type { CodingAgentLaunch, CodingAgentRun, CodingAgentRunStatus } from './codingAgentRuns' // ----------------------------------------------------------------------------- // Geometry primitives @@ -114,9 +115,8 @@ export interface PanelState { cwd?: string /** Document panels only: sub-type discriminator for the viewer. */ documentType?: 'pdf' | 'docx' | 'image' - /** Id of the WorktreeMeta in the parent workspace that this panel is - * associated with. Drives the per-panel color accent for terminal + agent - * panels; terminals also expose the title-bar "switch worktree" pill. */ + /** Terminal panels only: id of the WorktreeMeta in the parent workspace that + * this terminal is associated with. Cate Agent worktrees live on Chat. */ worktreeId?: string /** Terminal panels only. Set to true the first time the user renames the * tab so that subsequent OSC-0/1/2 title escapes from the running agent @@ -133,6 +133,13 @@ export interface PanelState { * On restore, TerminalPanel types the agent's resume command into the * fresh shell and clears this. */ agentSession?: TerminalAgentSession + /** Terminal panels created by Cate Agent carry durable ownership metadata. + * This is the native mission/run record; live status is derived from the + * terminal registry and agent hooks rather than persisted stale state. */ + codingAgentRun?: CodingAgentRun + /** One-shot direct-process launch. Cleared immediately after PTY creation so + * restoring a workspace never repeats an already-started task. */ + codingAgentLaunch?: CodingAgentLaunch /** Extension panels only: which installed extension + which of its declared * panels this instance renders. */ extensionId?: string @@ -334,6 +341,11 @@ export interface WindowPanelReport { /** Whether the owner window's scan found listening ports for this panel, so a * detached row shows the same port dot as a local one. */ hasPorts?: boolean + /** Mission identity used only for exact owner-window routing of a live + * Cate-owned worker after cross-window terminal transfer. */ + codingAgentRunId?: string + codingAgentOwnerPanelId?: string + codingAgentStatus?: CodingAgentRunStatus } export interface CateWindowParams { @@ -1078,12 +1090,16 @@ export interface ProjectSessionPanel { ptyId?: string workingDirectory?: string unsavedContent?: string - /** Worktree this terminal/agent panel is tagged with. Machine-local (worktree - * ids are runtime uuids), so it lives in session.json, not workspace.json. */ + /** Worktree this terminal panel is tagged with. Machine-local (worktree ids + * are runtime uuids), so it lives in session.json, not workspace.json. */ worktreeId?: string /** Agent-CLI session running in this terminal at save time. Machine-local * (session ids reference stores on this machine's runtime host). */ agentSession?: TerminalAgentSession + /** Cate-owned coding-agent mission metadata. The initial one-shot launch is + * deliberately excluded; restoring may resume a stamped CLI session but + * never repeats the original task. */ + codingAgentRun?: CodingAgentRun } // ----------------------------------------------------------------------------- @@ -1101,6 +1117,8 @@ export interface Chat { /** The Agent panel that owns this chat. Absence means the workspace Cate * sidebar owns it. A chat is rendered by exactly one of those hosts. */ hostPanelId?: string + /** Worktree this chat's agent runs in. Follows the chat between hosts. */ + worktreeId?: string /** Per-chat model override. The Cate Agent otherwise uses the global default. */ model?: CateAgentModelRef /** On-disk Pi transcript for this chat's sole main-agent session. */