From e480620a481c0b061cfd3dfc5ffae70f7b5a8b7a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 22:24:32 -0700 Subject: [PATCH 1/4] test(agent-pane): a subagent's pane draws none of the chat's composer, queue or PR bars; new subagents open no pane Red: a pane opened on subagent: renders the chat's composer, its queued prompt and its pull request bar (Leo, 2026-10-20). The auto-open test covers the default-off hook on a subagent that starts while the chat shows. Agent-Session: 01a15ba9-9614-7832-8e39-d8ca0407c193 Co-Authored-By: Claude Opus 5.5 --- webviews/test/subagent-pane-own-state.test.ts | 190 ++++++++++++++++++ .../test/subagent-panes-auto-open.test.tsx | 67 ++++++ 2 files changed, 257 insertions(+) create mode 100644 webviews/test/subagent-pane-own-state.test.ts create mode 100644 webviews/test/subagent-panes-auto-open.test.tsx diff --git a/webviews/test/subagent-pane-own-state.test.ts b/webviews/test/subagent-pane-own-state.test.ts new file mode 100644 index 000000000000..ebcc47e056b3 --- /dev/null +++ b/webviews/test/subagent-pane-own-state.test.ts @@ -0,0 +1,190 @@ +// l10n-allow-file: test fixture prompts, replies and subagent names, not shipped UI. +// A subagent's own pane (a tab on the chat's session that opens on `subagent:`, +// subagents/subagentPanes.ts) shows the subagent, read only. It never draws the chat's composer, +// queue or pull request bars: they are the chat's, and a prompt typed there went to the chat. +// Leo 2026-10-20: a reviewer's pane showed the chat's queued "this is a bit spammy" and its #663 +// bar. The chat's own pane keeps all of them. The page is the pane's dev entry with its browser +// host, talking JSON-RPC to a stand-in acpmux daemon; the app's `subagent:` reveal is the page +// bridge's `revealTurn`. +// +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("subagent-pane-own-state.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("subagent-pane-own-state: skipping (run `bunx playwright install chromium`)"); + describe.skip("subagent pane state", () => test("chromium", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + + const CHAT = "0190a000-0000-7000-8000-0000000000e1"; + const START = Date.now() - 60_000; + const update = (update: Record, meta?: Record) => ({ + method: "session/update", + params: { sessionId: "agent-1", update, ...(meta ? { _meta: { acpmux: meta } } : {}) }, + }); + type Step = [dir: string, kind: string, msg: Record]; + const command = 'gh pr create --title "Add retry"'; + // A turn that opened a pull request, started a reviewer and still works, with a prompt queued + // behind it. + const steps: Step[] = [ + ["mux", "user_message", { text: "Open the PR and have an agent review it" }], + ["mux", "turn_started", {}], + [ + "in", + "tool_call", + update({ + sessionUpdate: "tool_call", + toolCallId: "t1", + title: command, + kind: "execute", + status: "in_progress", + rawInput: { command }, + }), + ], + [ + "in", + "tool_call_update", + update({ + sessionUpdate: "tool_call_update", + toolCallId: "t1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "https://github.com/acme/uploader/pull/663" } }], + rawOutput: { exit_code: 0 }, + }), + ], + [ + "in", + "subagent_spawned", + update( + { sessionUpdate: "subagent_spawned" }, + { subagents: [{ id: "a1", parent: null, name: "Reviewer", state: "running", startedAt: START + 4000 }] }, + ), + ], + [ + "in", + "agent_message_chunk", + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "The reviewer is on it." } }), + ], + ["mux", "queued", { text: "this is a bit spammy", promptId: "q1", position: 1 }], + ]; + const events = steps.map(([dir, kind, msg], index) => ({ + sessionId: CHAT, + seq: index + 1, + at: START + (index + 1) * 1000, + dir, + kind, + msg, + })); + const session = { + sessionId: CHAT, + cwd: "/home/dev/uploader", + title: "Release review", + harness: "claude", + family: "claude", + status: "working", + turnCount: 1, + updatedAt: START, + }; + + function answer(method: string): unknown { + switch (method) { + case "initialize": + return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; + case "_acpmux/watch": + return { sessions: [session] }; + case "_acpmux/attach": + return { session, lastSeq: steps.length, events }; + case "_acpmux/events": + return { events: [], hasMore: false, lastSeq: 0 }; + default: + return null; + } + } + + let daemon: ReturnType | undefined; + let vite: Awaited> | undefined; + let browser: Browser | undefined; + const open = async (): Promise => { + const { port } = vite!.httpServer!.address() as { port: number }; + const endpoint = `ws://127.0.0.1:${daemon!.port}/`; + const page = await browser!.newPage({ viewport: { width: 900, height: 760 } }); + await page.goto(`http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${CHAT}`); + await page.getByText("The reviewer is on it.").waitFor({ timeout: 25_000 }); + return page; + }; + /// What the pane's bottom shows: the chat's composer, its queued prompt and its pull request bar. + const bottom = (page: Page) => + page.evaluate(() => ({ + composer: document.querySelectorAll(".acpmux-composer").length, + queued: [...document.querySelectorAll(".acpmux-queued")].map((row) => row.textContent ?? ""), + prDock: document.querySelectorAll(".acpmux-pr-dock").length, + readOnly: document.querySelector(".cv-subagent-pane-footer")?.textContent?.trim() ?? "", + })); + beforeAll(async () => { + daemon = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + return server.upgrade(request, { data: undefined }) ? undefined : new Response("acpmux", { status: 426 }); + }, + websocket: { + message(ws, raw) { + const message = JSON.parse(String(raw)) as { id?: number; method: string }; + if (message.id !== undefined) + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + }, + }, + }); + vite = await createServer({ + configFile: path.join(webviews, "vite.config.acpmux-pane.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/"); + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + await daemon?.stop(true); + }); + + test("the chat's own pane keeps its composer, its queue and its pull request bar", async () => { + const page = await open(); + await page.locator(".acpmux-queued").first().waitFor({ timeout: 10_000 }); + const shown = await bottom(page); + expect(shown.composer).toBe(1); + expect(shown.queued.some((text) => text.includes("this is a bit spammy"))).toBe(true); + expect(shown.prDock).toBe(1); + expect(shown.readOnly).toBe(""); + await page.close(); + }); + + test("a subagent's pane shows the subagent read only, with none of the chat's queue or bars", async () => { + const page = await open(); + await page.locator(".acpmux-queued").first().waitFor({ timeout: 10_000 }); + await page.evaluate(() => + (window as { cmuxAcpmuxBridge?: { revealTurn?(value: string): void } }).cmuxAcpmuxBridge?.revealTurn?.( + "subagent:a1", + ), + ); + await page.locator(".cv-subagent-of").waitFor({ timeout: 10_000 }); + const shown = await bottom(page); + expect(shown.composer).toBe(0); + expect(shown.queued).toEqual([]); + expect(shown.prDock).toBe(0); + expect(shown.readOnly).toContain("Release review"); + await page.close(); + }); +}); diff --git a/webviews/test/subagent-panes-auto-open.test.tsx b/webviews/test/subagent-panes-auto-open.test.tsx new file mode 100644 index 000000000000..c0557a4a3bdd --- /dev/null +++ b/webviews/test/subagent-panes-auto-open.test.tsx @@ -0,0 +1,67 @@ +// l10n-allow-file: test fixture subagent names, not shipped UI. +// A subagent the chat starts stays in the chat: no pane of its own opens unless the user turned +// that on (subagents/subagentPanes.ts `useOpenNewSubagents`). Leo 2026-10-20: a reviewer subagent +// opened a pane right of the chat by itself. The switch's stored value is +// subagent-panes-default-off.test.ts; this one runs the hook on a subagent that starts while the +// pane shows the chat. +import { afterAll, expect, test } from "bun:test"; +import { JSDOM, VirtualConsole } from "jsdom"; + +const dom = new JSDOM("
", { + url: "http://localhost/", + virtualConsole: new VirtualConsole(), +}); +const scope = globalThis as Record; +const replacements: Record = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}; +const saved = Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const); +Object.assign(scope, replacements); + +const { act, createElement } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { useOpenNewSubagents } = await import("../src/agent-session/acpmux/subagents/subagentPanes"); +const { SUBAGENTS } = await import("../src/agent-session/acpmux/subagents/subagentFold"); + +afterAll(() => { + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +const CHAT = "0190a000-0000-7000-8000-0000000000d1"; +const group = (state: string) => [ + { + id: "group-1", + kind: SUBAGENTS, + subagents: [{ id: "reviewer", parent: null, name: "Reviewer", state, startedAt: Date.now() }], + }, +]; + +/// Shows the chat with no subagent, then with one that just started; the panes it asked for. +async function opened(enabled: boolean) { + const asked: string[] = []; + const Probe = ({ rows }: { rows: unknown[] }) => { + useOpenNewSubagents(CHAT, rows as never, undefined, enabled, (id) => asked.push(id)); + return null; + }; + const root = createRoot(document.getElementById("root")!); + await act(async () => root.render(createElement(Probe, { rows: [] }))); + await act(async () => root.render(createElement(Probe, { rows: group("running") }))); + await act(async () => root.render(createElement(Probe, { rows: group("running") }))); + await act(async () => root.unmount()); + return asked; +} + +test("a subagent the chat starts opens no pane while the switch is off", async () => { + expect(await opened(false)).toEqual([]); +}); + +test("with the switch on, a subagent the chat starts opens one pane, once", async () => { + expect(await opened(true)).toEqual(["reviewer"]); +}); From 45be9755f8a55c341cdf5f20410afb62925c1b88 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 22:25:53 -0700 Subject: [PATCH 2/4] agent-pane: a subagent's own pane shows it read only, never the chat's composer, queue or PR bars A subagent pane is a tab on the chat's session, so it drew the chat's composer: its queued prompt, its pull request bar, the agents pill, and a prompt typed there went to the chat. A pane opened on subagent: now ends in one line naming the chat that takes messages. Agent-Session: 01a15ba9-9614-7832-8e39-d8ca0407c193 Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/App.tsx | 656 +++++++++--------- .../acpmux/Localizable.xcstrings | 132 ++++ .../acpmux/subagents/SubagentPaneFooter.tsx | 10 + .../acpmux/subagents/subagents.css | 11 + 4 files changed, 486 insertions(+), 323 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/subagents/SubagentPaneFooter.tsx diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index ef0134e2f47f..21fec7185d35 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -230,6 +230,7 @@ import { ChatHeaderTools, HEADER_ACTIONS, menuGroups, type ChatMenuItem } from " import { headerState, setHeaderState, useHeaderState } from "./header/headerState"; import { setTabRow, tabRowStyle, useTabRow, useTabRowTools } from "./header/tabRow"; import { PullRequestDock } from "./summary/PullRequestDock"; +import { SubagentPaneFooter } from "./subagents/SubagentPaneFooter"; import { RecapCard, useRecapCard } from "./RecapCard"; import { configureQuickActions, quickActions, type QuickActionMode } from "./header/quickActions"; import { chatColumnOf, configureChatWidth, useChatWidth } from "./chatWidth"; @@ -3207,10 +3208,13 @@ function AcpmuxPane() { /// once its chat is; such a pane shows the chat's other subagents in place, never in more panes. const [requestedSubagent, setRequestedSubagent] = useState(); const subagentPane = useRef(false); + /// This pane is a subagent's own: it shows the subagent read only, never the chat's composer. + const [ownSubagentPane, setOwnSubagentPane] = useState(false); const reveal = (value: string) => { const agent = revealedSubagent(value); if (agent === undefined) return void revealTurnWhenShown(value); subagentPane.current = true; + setOwnSubagentPane(true); setRequestedSubagent(agent); }; const revealRef = useRef(reveal); @@ -6171,341 +6175,347 @@ function AcpmuxPane() { : {})} /> ); - const composer = !reviewing && ( - <> - - {recapCard.recap && ( - - )} - - - - -

- {importError} -

-
- - - instructionsFailure && openInstructions(instructionsFailure.cwd, instructionsFailure.failure.file) - } - onDismiss={() => setInstructionsFailure(undefined)} - /> - - {harnessCard ? ( - - {harnessCard} + // A subagent's own pane is a tab on the chat's session: the chat's composer, queue and pull + // request bars there were the chat's, and a prompt typed in it went to the chat. + const composer = ownSubagentPane ? ( + + ) : ( + !reviewing && ( + <> + + {recapCard.recap && ( + + )} + + - ) : ( - - void callNative("chat.harness.retry")} /> + +

+ {importError} +

- )} - {!harnessCard && ( - entry.id === healthHarness)?.name ?? healthHarness ?? ""} - {...(snapshot.summary?.cwd ? { cwd: snapshot.summary.cwd } : {})} - open={(params) => { - healthStale.current = true; - void callNative("tab.open", params).catch(() => undefined); - }} - native={callNative} - turnRunning={snapshot.isWorking} - onUpdated={recheckHealth} - onOffer={setUpdateOffer} - /> - )} - - { - void chooseChatFolder( - () => callNative("chat.folder.choose"), - async (adopt) => { - const client = directClient.current; - if (!client) throw new Error(t("error.nativeUnavailable")); - const sessionId = await client.resume(adopt); - if (sessionId) await callNative("chat.persistSession", { sessionId }); - }, - ).then( - (result) => { - if (result.done) setFolderNeeded(undefined); - else if (result.reason) setFolderNeeded({ reason: result.reason }); - }, - (error: unknown) => - setFolderNeeded((current) => current && { ...current, error: errorMessage(error) || String(error) }), - ); - }} - /> - - {pendingFolder ? ( - { - // The question closes on the click, so a second click cannot use the folder twice. - const pending = pendingFolder; - setFolderAsk(undefined); - void confirmFolder(callNative, pending.ask, pending.use).catch((error: unknown) => - setFolderAsk((current) => current ?? { ...pending, error: errorMessage(error) || String(error) }), - ); - }} - onCancel={() => { - pendingFolder.cancel?.(); - setFolderAsk(undefined); - }} - /> - ) : ( - - + + instructionsFailure && openInstructions(instructionsFailure.cwd, instructionsFailure.failure.file) + } + onDismiss={() => setInstructionsFailure(undefined)} + /> + + {harnessCard ? ( + + {harnessCard} + + ) : ( + + void callNative("chat.harness.retry")} /> + + )} + {!harnessCard && ( + entry.id === healthHarness)?.name ?? healthHarness ?? ""} + {...(snapshot.summary?.cwd ? { cwd: snapshot.summary.cwd } : {})} + open={(params) => { + healthStale.current = true; + void callNative("tab.open", params).catch(() => undefined); + }} + native={callNative} + turnRunning={snapshot.isWorking} + onUpdated={recheckHealth} + onOffer={setUpdateOffer} + /> + )} + + { - setFolderError(undefined); - void callNative<{ cwd?: string }>("workspace.chooseFolder") - .then((result) => { - if (!result?.cwd) return; - setChooseFolder(false); - chooseProject(result.cwd); - }) - .catch((error: unknown) => setFolderError(errorMessage(error) || undefined)); + void chooseChatFolder( + () => callNative("chat.folder.choose"), + async (adopt) => { + const client = directClient.current; + if (!client) throw new Error(t("error.nativeUnavailable")); + const sessionId = await client.resume(adopt); + if (sessionId) await callNative("chat.persistSession", { sessionId }); + }, + ).then( + (result) => { + if (result.done) setFolderNeeded(undefined); + else if (result.reason) setFolderNeeded({ reason: result.reason }); + }, + (error: unknown) => + setFolderNeeded((current) => current && { ...current, error: errorMessage(error) || String(error) }), + ); }} /> - )} - - {t("worktree.creating")} - - - setWorktreeError(undefined)} /> - - {aside && ( - setAside(undefined)} - onAdd={() => { - setAside(undefined); - ignoreFailure(callNative("chat.send", { text: aside.question, attachments: [] })); - }} - /> - )} - {/* The agents pill (subagents/AgentsPill.tsx): in the bottom stack, right above the composer, - while the turn works; an agent a cancel or a reconnect left "running" shows none. */} - {!quick && !freshView && snapshot.isWorking && chatTones && chatAgents.some(running) && ( - - - - )} - {/* An attached image opens in the chat's image viewer, as a transcript image does. */} - - { - if (command.action === "btw") { - const question = args?.trim(); - const sessionId = snapshot.sessionId; - if (!asksAside || !question || !sessionId) return false; - const ask = ++asideAsks.current; - setAside({ sessionId, ask, question }); - const settle = (outcome: Partial) => - setAside((current) => (current?.ask === ask ? { ...current, ...outcome } : current)); - callNative("chat.ask", { question }).then( - (answer) => settle({ answer: typeof answer === "string" ? answer : "" }), - (error: unknown) => settle({ error: errorMessage(error) || String(error) }), + {pendingFolder ? ( + { + // The question closes on the click, so a second click cannot use the folder twice. + const pending = pendingFolder; + setFolderAsk(undefined); + void confirmFolder(callNative, pending.ask, pending.use).catch((error: unknown) => + setFolderAsk((current) => current ?? { ...pending, error: errorMessage(error) || String(error) }), ); + }} + onCancel={() => { + pendingFolder.cancel?.(); + setFolderAsk(undefined); + }} + /> + ) : ( + + { + setFolderError(undefined); + void callNative<{ cwd?: string }>("workspace.chooseFolder") + .then((result) => { + if (!result?.cwd) return; + setChooseFolder(false); + chooseProject(result.cwd); + }) + .catch((error: unknown) => setFolderError(errorMessage(error) || undefined)); + }} + /> + + )} + + {t("worktree.creating")} + + + setWorktreeError(undefined)} /> + + {aside && ( + setAside(undefined)} + onAdd={() => { + setAside(undefined); + ignoreFailure(callNative("chat.send", { text: aside.question, attachments: [] })); + }} + /> + )} + {/* The agents pill (subagents/AgentsPill.tsx): in the bottom stack, right above the composer, + while the turn works; an agent a cancel or a reconnect left "running" shows none. */} + {!quick && !freshView && snapshot.isWorking && chatTones && chatAgents.some(running) && ( + + + + )} + {/* An attached image opens in the chat's image viewer, as a transcript image does. */} + + { + if (command.action === "btw") { + const question = args?.trim(); + const sessionId = snapshot.sessionId; + if (!asksAside || !question || !sessionId) return false; + const ask = ++asideAsks.current; + setAside({ sessionId, ask, question }); + const settle = (outcome: Partial) => + setAside((current) => (current?.ask === ask ? { ...current, ...outcome } : current)); + callNative("chat.ask", { question }).then( + (answer) => settle({ answer: typeof answer === "string" ? answer : "" }), + (error: unknown) => settle({ error: errorMessage(error) || String(error) }), + ); + return true; + } + if (command.action === "fork") { + if (!forkable || forkSeq === undefined || args?.trim()) return false; + ignoreFailure(callNative("chat.fork", { throughSeq: forkSeq })); + return true; + } + if (command.action !== "continue" || !canContinue) return false; + if (!args?.trim()) { + setContinuing(true); + return true; + } + const target = resolveHarnessTarget(args, handoffTargets); + if (!target) return false; + setContinuing(false); + ignoreFailure(callNative("chat.handoff.prepare", { harness: target.id })); return true; + }} + onSend={(text, chips, steer) => { + // Until acpmux connects nothing takes a prompt; the composer keeps it. + if (!window.cmuxAcpmuxActions?.["chat.send"]) return false; + // Shell mode's chips carry their commands' output as it is now. + const attachments = shellContextAttachments(chips ?? [], (id) => shellRuns.get(id)); + if (!snapshot.sessionId) claimShellRuns.current = true; + // A started chat never holds the prompt (plans/cmux-next/composer-delivery-DESIGN.md): + // it leaves the composer at once and shows as its own row, which says how far it got, + // and a refusal stays on that row with Retry, Edit and Remove. One refused before any + // row showed (the chat could not take prompts) comes back to the composer. + if (snapshot.sessionId && surfaceRef.current !== "quick" && !projectDraft) { + const holder = composerHandle.current; + callNative("chat.send", { text, attachments, steer }).then( + () => promptLanded.current(), + (error: unknown) => { + cancelOpenInWindow(); + if (failedRowOf(error) || isTrustRefusal(error)) return; + // A prompt a harness switch handed back (harnessSwitch.ts) is in the composer already. + if ((error as { handedBack?: unknown } | null)?.handedBack === true) return; + (composerHandle.current ?? holder)?.restore(text, attachments); + }, + ); + return true; + } + // A new chat's composer keeps the prompt until acpmux takes it: a refusal (folder + // trust, the remote guard, the sandbox) leaves it there to send again. + let accept: (taken: true) => void = () => undefined; + const taken = new Promise((resolve) => (accept = resolve)); + const send = async () => { + if (projectDraft && !snapshot.sessionId) + // The chat's start is part of this send: a trust refusal there asks the folder's + // question, keeps this prompt (with its send's gesture) and sends it after Trust + // (cx-nn3e P0b; the prompt was lost when only the start ran again). + await callNative("chat.new", { cwd: projectDraft, ...heldPick(snapshot) }).catch((error: unknown) => { + if (isTrustRefusal(error)) { + holdPrompt.current?.(); + trustRefused.current?.(error, () => composerHandle.current?.send()); + } + throw error; + }); + return callNative("chat.send", { text, attachments, accepted: () => accept(true), steer }); + }; + // The composer that holds the prompt: a refusal that comes once it is gone (the pane + // swaps it when the chat's session starts) puts the prompt in the one shown now. + const holder = composerHandle.current; + // Start Agent's Return; ⌘Return (onOpenInWindow, right after this) opens a window instead. + if (surfaceRef.current === "quick") startInBackground(text, taken); + const turn = send(); + turn.then(() => promptLanded.current(), cancelOpenInWindow); + // Taken, or refused before acpmux took it (the turn's later failure is the transcript's). + const held = Promise.race([taken, turn.then(() => true as const)]); + held.catch(() => { + if (composerHandle.current === holder) return; + if (composerHandle.current) composerHandle.current.restore(text, attachments); + else heldBack.current = [...(heldBack.current ?? []), { text, attachments }]; + }); + return held; + }} + onStop={() => void callNative("chat.cancel")} + onQueueRemove={removeQueued} + onQueueChange={editQueued} + // A prompt that started while it was edited comes back to the prompt, so the edit is kept. + onQueueLost={(text) => composerHandle.current?.restore(text, [])} + onProject={chooseProject} + onNewWorktree={freshChat && !quick && !remoteChat ? startInWorktree : undefined} + onConnect={connectMachine} + onRefreshMachines={() => void directClient.current?.refreshPeers()} + // A started local chat lists the same folders to move to (dogfood 09); Start Agent's + // panel lists them before its first prompt (cx-hkat). + projectChoices={freshChat || (!quick && canMove) ? newTabProjects : undefined} + onBrowseProject={ + freshChat && !quick + ? () => { + void callNative<{ cwd?: string }>("project.browse") + .then((result) => { + if (result?.cwd) chooseProject(result.cwd); + }) + .catch(() => undefined); + } + : undefined } - if (command.action === "fork") { - if (!forkable || forkSeq === undefined || args?.trim()) return false; - ignoreFailure(callNative("chat.fork", { throughSeq: forkSeq })); - return true; - } - if (command.action !== "continue" || !canContinue) return false; - if (!args?.trim()) { - setContinuing(true); - return true; + // The host runs commands on this Mac: a Cloud chat has no shell mode. + onShell={ + composerSnapshot.summary?.hostKind === "cloud" + ? undefined + : (command) => + shellRuns.start(command, { + ...(composerSnapshot.summary?.cwd ? { cwd: composerSnapshot.summary.cwd } : {}), + ...(snapshot.sessionId ? { sessionId: snapshot.sessionId } : {}), + }) } - const target = resolveHarnessTarget(args, handoffTargets); - if (!target) return false; - setContinuing(false); - ignoreFailure(callNative("chat.handoff.prepare", { harness: target.id })); - return true; - }} - onSend={(text, chips, steer) => { - // Until acpmux connects nothing takes a prompt; the composer keeps it. - if (!window.cmuxAcpmuxActions?.["chat.send"]) return false; - // Shell mode's chips carry their commands' output as it is now. - const attachments = shellContextAttachments(chips ?? [], (id) => shellRuns.get(id)); - if (!snapshot.sessionId) claimShellRuns.current = true; - // A started chat never holds the prompt (plans/cmux-next/composer-delivery-DESIGN.md): - // it leaves the composer at once and shows as its own row, which says how far it got, - // and a refusal stays on that row with Retry, Edit and Remove. One refused before any - // row showed (the chat could not take prompts) comes back to the composer. - if (snapshot.sessionId && surfaceRef.current !== "quick" && !projectDraft) { - const holder = composerHandle.current; - callNative("chat.send", { text, attachments, steer }).then( - () => promptLanded.current(), - (error: unknown) => { - cancelOpenInWindow(); - if (failedRowOf(error) || isTrustRefusal(error)) return; - // A prompt a harness switch handed back (harnessSwitch.ts) is in the composer already. - if ((error as { handedBack?: unknown } | null)?.handedBack === true) return; - (composerHandle.current ?? holder)?.restore(text, attachments); - }, - ); - return true; + localName={machineName} + environments={preview} + onBrowseFolder={ + canMove + ? () => + callNative<{ cwd?: string }>("project.browse") + .then((result) => result?.cwd) + .catch(() => undefined) + : undefined } - // A new chat's composer keeps the prompt until acpmux takes it: a refusal (folder - // trust, the remote guard, the sandbox) leaves it there to send again. - let accept: (taken: true) => void = () => undefined; - const taken = new Promise((resolve) => (accept = resolve)); - const send = async () => { - if (projectDraft && !snapshot.sessionId) - // The chat's start is part of this send: a trust refusal there asks the folder's - // question, keeps this prompt (with its send's gesture) and sends it after Trust - // (cx-nn3e P0b; the prompt was lost when only the start ran again). - await callNative("chat.new", { cwd: projectDraft, ...heldPick(snapshot) }).catch((error: unknown) => { - if (isTrustRefusal(error)) { - holdPrompt.current?.(); - trustRefused.current?.(error, () => composerHandle.current?.send()); + // A started local chat's folder is fixed (Leo 2026-10-13): its folder menu starts a new chat + // in a tab of its own; a Cloud chat's folder is a label. + // The floating chat has no tabs to open one in. Another machine's `~` is not this Mac's: a + // remote chat starts one only in an absolute folder. + onNewChat={ + canMove && !quick + ? (cwd) => { + if (!remoteChat || cwd.startsWith("/")) ignoreFailure(callNative("chat.newHere", { cwd })); } - throw error; - }); - return callNative("chat.send", { text, attachments, accepted: () => accept(true), steer }); - }; - // The composer that holds the prompt: a refusal that comes once it is gone (the pane - // swaps it when the chat's session starts) puts the prompt in the one shown now. - const holder = composerHandle.current; - // Start Agent's Return; ⌘Return (onOpenInWindow, right after this) opens a window instead. - if (surfaceRef.current === "quick") startInBackground(text, taken); - const turn = send(); - turn.then(() => promptLanded.current(), cancelOpenInWindow); - // Taken, or refused before acpmux took it (the turn's later failure is the transcript's). - const held = Promise.race([taken, turn.then(() => true as const)]); - held.catch(() => { - if (composerHandle.current === holder) return; - if (composerHandle.current) composerHandle.current.restore(text, attachments); - else heldBack.current = [...(heldBack.current ?? []), { text, attachments }]; - }); - return held; - }} - onStop={() => void callNative("chat.cancel")} - onQueueRemove={removeQueued} - onQueueChange={editQueued} - // A prompt that started while it was edited comes back to the prompt, so the edit is kept. - onQueueLost={(text) => composerHandle.current?.restore(text, [])} - onProject={chooseProject} - onNewWorktree={freshChat && !quick && !remoteChat ? startInWorktree : undefined} - onConnect={connectMachine} - onRefreshMachines={() => void directClient.current?.refreshPeers()} - // A started local chat lists the same folders to move to (dogfood 09); Start Agent's - // panel lists them before its first prompt (cx-hkat). - projectChoices={freshChat || (!quick && canMove) ? newTabProjects : undefined} - onBrowseProject={ - freshChat && !quick - ? () => { - void callNative<{ cwd?: string }>("project.browse") - .then((result) => { - if (result?.cwd) chooseProject(result.cwd); - }) - .catch(() => undefined); - } - : undefined - } - // The host runs commands on this Mac: a Cloud chat has no shell mode. - onShell={ - composerSnapshot.summary?.hostKind === "cloud" - ? undefined - : (command) => - shellRuns.start(command, { - ...(composerSnapshot.summary?.cwd ? { cwd: composerSnapshot.summary.cwd } : {}), - ...(snapshot.sessionId ? { sessionId: snapshot.sessionId } : {}), - }) - } - localName={machineName} - environments={preview} - onBrowseFolder={ - canMove - ? () => - callNative<{ cwd?: string }>("project.browse") - .then((result) => result?.cwd) - .catch(() => undefined) - : undefined - } - // A started local chat's folder is fixed (Leo 2026-10-13): its folder menu starts a new chat - // in a tab of its own; a Cloud chat's folder is a label. - // The floating chat has no tabs to open one in. Another machine's `~` is not this Mac's: a - // remote chat starts one only in an absolute folder. - onNewChat={ - canMove && !quick - ? (cwd) => { - if (!remoteChat || cwd.startsWith("/")) ignoreFailure(callNative("chat.newHere", { cwd })); - } - : undefined - } - onShellInterrupt={() => { - const running = shellRuns.running(snapshot.sessionId); - if (running) shellRuns.stop(running.id); - return running !== undefined; - }} - onMode={(modeId) => callNative("chat.mode", { modeId })} - // Without a folder there is nothing to search; the + menu leaves the item out. - searchFiles={fileRoot ? searchFiles : undefined} - capture={captureScreen} - upload={uploadFiles} - onOpenInWindow={quick ? openInWindow : undefined} - onEmptyCommandReturn={recapCard.recap ? recapCard.reply : undefined} - onShellChange={setComposerShell} - prompt={prompt} - handle={composerRef} - // No prompt goes while a picked folder waits for the user's answer. - blocked={pendingFolder ? {} : trustAsk.blocked} - accessory={} - onImportFile={importFile} - onEdit={(command) => ignoreFailure(callNative("pane.edit", { command }))} - /> - - + : undefined + } + onShellInterrupt={() => { + const running = shellRuns.running(snapshot.sessionId); + if (running) shellRuns.stop(running.id); + return running !== undefined; + }} + onMode={(modeId) => callNative("chat.mode", { modeId })} + // Without a folder there is nothing to search; the + menu leaves the item out. + searchFiles={fileRoot ? searchFiles : undefined} + capture={captureScreen} + upload={uploadFiles} + onOpenInWindow={quick ? openInWindow : undefined} + onEmptyCommandReturn={recapCard.recap ? recapCard.reply : undefined} + onShellChange={setComposerShell} + prompt={prompt} + handle={composerRef} + // No prompt goes while a picked folder waits for the user's answer. + blocked={pendingFolder ? {} : trustAsk.blocked} + accessory={} + onImportFile={importFile} + onEdit={(command) => ignoreFailure(callNative("pane.edit", { command }))} + /> + + + ) ); const hostErrorCard = ( diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index 4a9174abbc41..01e01bef9754 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -120620,6 +120620,138 @@ } } }, + "subagents.paneReadOnly": { + "comment": "Agent pane: where a subagent's own pane would show the chat's composer; {name} is the chat that takes messages", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read only · messages go to {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "للقراءة فقط · تُرسل الرسائل إلى {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Samo za čitanje · poruke idu u {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skrivebeskyttet · beskeder går til {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur lesen · Nachrichten gehen an {name}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo lectura · los mensajes van a {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture seule · les messages vont à {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sola lettura · i messaggi vanno a {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧のみ · メッセージは {name} へ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អានតែប៉ុណ្ណោះ · សារទៅកាន់ {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 전용 · 메시지는 {name}(으)로" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skrivebeskyttet · meldinger går til {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tylko do odczytu · wiadomości trafiają do {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Somente leitura · as mensagens vão para {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только чтение · сообщения уходят в {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อ่านอย่างเดียว · ข้อความไปที่ {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Salt okunur · mesajlar {name} sohbetine gider" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Лише читання · повідомлення йдуть у {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chỉ đọc · tin nhắn gửi tới {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只读 · 消息发送到 {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "唯讀 · 訊息傳送到 {name}" + } + } + } + }, "subagents.open": { "comment": "Agent pane", "extractionState": "manual", diff --git a/webviews/src/agent-session/acpmux/subagents/SubagentPaneFooter.tsx b/webviews/src/agent-session/acpmux/subagents/SubagentPaneFooter.tsx new file mode 100644 index 000000000000..dfad8bcd527a --- /dev/null +++ b/webviews/src/agent-session/acpmux/subagents/SubagentPaneFooter.tsx @@ -0,0 +1,10 @@ +// A subagent's own pane ends in one quiet line where the chat's composer would be: the pane shows +// the subagent read only, and the chat it belongs to takes messages. The pane is a tab on the +// chat's session (subagents/subagentPanes.ts), so a composer there drew the chat's queue and pull +// request bars and sent its prompts to the chat (Leo 2026-10-20). +import { useT } from "../i18n"; + +export function SubagentPaneFooter({ chat }: { chat: string }) { + const t = useT(); + return

{t("subagents.paneReadOnly", { name: chat })}

; +} diff --git a/webviews/src/agent-session/acpmux/subagents/subagents.css b/webviews/src/agent-session/acpmux/subagents/subagents.css index a4a95bd94fb7..7dc37517e16b 100644 --- a/webviews/src/agent-session/acpmux/subagents/subagents.css +++ b/webviews/src/agent-session/acpmux/subagents/subagents.css @@ -411,3 +411,14 @@ button.cv-subagent > :last-child { font-size: var(--text-detail); font-variant-numeric: tabular-nums; } +/* A subagent's own pane: where the chat's composer would be, the line naming the chat that takes + messages (SubagentPaneFooter.tsx). */ +.cv-subagent-pane-footer { + margin: 0; + padding: 12px 16px; + font-family: var(--cv-font); + font-size: var(--text-detail); + color: var(--cv-text-muted); + text-align: center; + user-select: none; +} From 714c6d534bc0359e1f2a686b73883ea30cc0af86 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 23:31:25 -0700 Subject: [PATCH 3/4] test(agent-pane): the stand-in acpmux lists the queued prompt, as acpmux's session list now carries the queue Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15d18-aabc-7850-af29-f0655b1d2a1a&host=cosmic-gray --- webviews/test/subagent-pane-own-state.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webviews/test/subagent-pane-own-state.test.ts b/webviews/test/subagent-pane-own-state.test.ts index ebcc47e056b3..1d8760371739 100644 --- a/webviews/test/subagent-pane-own-state.test.ts +++ b/webviews/test/subagent-pane-own-state.test.ts @@ -93,6 +93,8 @@ await requireBrowserLane("subagent-pane-own-state.test.ts", async () => { family: "claude", status: "working", turnCount: 1, + // acpmux's list holds the queue; the replayed `queued` record gives the prompt's whole text. + queue: [{ promptId: "q1", prompt: "this is a bit spammy" }], updatedAt: START, }; From 08c03a59e20e60c8f3be64ff8193145ff979a631 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 23:42:49 -0700 Subject: [PATCH 4/4] agent-pane: read only only while the pane shows a subagent; no PR chip or Sources + there "Subagent of", a branch pill or another harness shows a chat in the pane: that chat's composer, queue and bars come back. The header's pull request chip and the summary's Sources + are the chat's and stay off while the pane shows the subagent. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15d18-aabc-7850-af29-f0655b1d2a1a&host=cosmic-gray --- webviews/src/agent-session/acpmux/App.tsx | 15 ++++++++++++--- .../acpmux/header/ChatHeaderTools.tsx | 7 ++++++- webviews/test/subagent-pane-own-state.test.ts | 7 +++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 752116d4884b..276745d632cb 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -3238,7 +3238,8 @@ function AcpmuxPane() { /// once its chat is; such a pane shows the chat's other subagents in place, never in more panes. const [requestedSubagent, setRequestedSubagent] = useState(); const subagentPane = useRef(false); - /// This pane is a subagent's own: it shows the subagent read only, never the chat's composer. + /// This pane was opened on a subagent (a `subagent:` reveal): while it shows a subagent, it shows + /// it read only (`readOnlySubagent`). const [ownSubagentPane, setOwnSubagentPane] = useState(false); const reveal = (value: string) => { const agent = revealedSubagent(value); @@ -3677,6 +3678,10 @@ function AcpmuxPane() { setSubagentView(requestedSubagent); setRequestedSubagent(undefined); }, [requestedSubagent, shownSessionId]); + // A subagent's pane shows the subagent read only, never the chat's composer, queue or pull + // request: they are the chat's. Once it shows a chat ("Subagent of", a branch pill, another + // harness), that chat's composer is back: a prompt there goes to the chat it shows. + const readOnlySubagent = ownSubagentPane && (subagentView !== undefined || requestedSubagent !== undefined); // Find in Chat over the transcript (ChatFindScope, below the pane); the page actions read it // through `findRef`. const findRef = useRef(undefined); @@ -6224,7 +6229,7 @@ function AcpmuxPane() { ); // A subagent's own pane is a tab on the chat's session: the chat's composer, queue and pull // request bars there were the chat's, and a prompt typed in it went to the chat. - const composer = ownSubagentPane ? ( + const composer = readOnlySubagent ? ( ) : ( !reviewing && ( @@ -6673,7 +6678,10 @@ function AcpmuxPane() { subagents={cardSubagents} // The picks are this computer's files: only a chat whose folder is here (not a peer's or // another host's) and not Quick Chat. - onAttach={localCwd && !quick ? () => void attachLinks().catch(() => undefined) : undefined} + // A subagent's pane has no composer to attach to. + onAttach={ + localCwd && !quick && !readOnlySubagent ? () => void attachLinks().catch(() => undefined) : undefined + } /> @@ -6857,6 +6865,7 @@ function AcpmuxPane() { )} void; + /// The pull request chip (default on); a subagent's own pane draws none: the pull request is the chat's. + pullRequestChip?: boolean; /// Merge on the PR chip (cx-ncc.77.4): merges that pull request; rejects with why not. onMergePullRequest?: (pullRequest: { number: number; url: string }) => Promise; /// The chat's folder on this Mac and its `git.status`: a worktree chat's branch shows (cx-lyqio.3b), @@ -395,7 +398,9 @@ export function ChatHeaderTools({ return (
{worktree && } - {pullRequest && } + {pullRequest && pullRequestChip !== false && ( + + )} {buttons ? (
{onCustomize ? ( diff --git a/webviews/test/subagent-pane-own-state.test.ts b/webviews/test/subagent-pane-own-state.test.ts index 1d8760371739..88924c3723e6 100644 --- a/webviews/test/subagent-pane-own-state.test.ts +++ b/webviews/test/subagent-pane-own-state.test.ts @@ -187,6 +187,13 @@ await requireBrowserLane("subagent-pane-own-state.test.ts", async () => { expect(shown.queued).toEqual([]); expect(shown.prDock).toBe(0); expect(shown.readOnly).toContain("Release review"); + // "Subagent of" shows the chat in this pane: the chat's composer, queue and bar are back. + await page.locator(".cv-subagent-of__pill").click(); + await page.locator(".acpmux-queued").first().waitFor({ timeout: 10_000 }); + const chat = await bottom(page); + expect(chat.composer).toBe(1); + expect(chat.prDock).toBe(1); + expect(chat.readOnly).toBe(""); await page.close(); }); });