diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 771bb6ec16ae..0566b6c95b93 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -7494,7 +7494,7 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) { liveRuns: codexRuns, sessionStatus, lineBelow, - // A finished turn folds under one Worked for; opened, a Claude turn folds per run, as Claude desktop does. + // A finished turn folds as its harness family's app does (turnFoldStyle.ts). agentFamily: snapshot.summary?.family, // Other sessions' messages among them, two or more in a run, draw as one card. waitingSteers: snapshot.waitingSteers, diff --git a/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.test.ts b/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.test.ts new file mode 100644 index 000000000000..6424c36d99e6 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { turnFoldStyle } from "./turnFoldStyle"; + +test("only ChatGPT and Codex fold to the conclusion with their calls drawn one under another", () => { + expect(turnFoldStyle("codex")).toEqual({ conclusion: true, runs: false }); + expect(turnFoldStyle(undefined)).toEqual({ conclusion: true, runs: false }); +}); + +test("Claude takes one Worked for with its runs folded inside, a variant id too", () => { + expect(turnFoldStyle("claude")).toEqual({ conclusion: true, runs: true }); + expect(turnFoldStyle("claude-sr2")).toEqual({ conclusion: true, runs: true }); +}); + +test("Gemini and Muse draw no Worked for; their runs fold in place", () => { + for (const family of ["gemini", "gemini-cli", "muse", "muse-code"]) + expect(turnFoldStyle(family)).toEqual({ conclusion: false, runs: true }); +}); + +test("a key on the object prototype is not a family", () => { + expect(turnFoldStyle("constructor")).toEqual({ conclusion: true, runs: false }); +}); diff --git a/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.ts b/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.ts new file mode 100644 index 000000000000..af3943cb817b --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.ts @@ -0,0 +1,39 @@ +// How a finished turn folds, per harness family: one table, read once per turn by turns.ts. +// +// Leo, 2026-11-04: "doesn't make sense to fold claude's transcripts like that. do it for chatgpt, +// do the proper folds for however the other harnessses/model families fold (muse, gemini) but +// chatgpt-style minimalism conclusion folding can only necessarily make sense for them". Spec: +// leo-workspace design/chat-transcript.md "Turn fold per harness family". +import { agentKey } from "../../shared/agentKey"; + +export type TurnFoldStyle = { + /// A finished turn's work, and the interim notes between it, goes under one closed "Worked for" + /// line with only its conclusion in sight. False: no turn-level line; the work stays where it ran. + conclusion: boolean; + /// Each run of calls, the short notes inside it with it, folds into its own summary line ("Ran 3 + /// commands"): inside an open "Worked for", or in place where the turn has none. False: the calls + /// draw one under another once the fold opens (a running turn's live runs aside). + runs: boolean; +}; + +/// ChatGPT and Codex: the conclusion fold, the minimal end of turn only their apps draw. +const CODEX: TurnFoldStyle = { conclusion: true, runs: false }; + +/// Keyed by the harness family's agent (agentKey: "claude-sr2" is "claude"). A family missing here +/// folds as Codex does. +export const TURN_FOLD_STYLES: Readonly> = { + codex: CODEX, + // Claude: one "Worked for" at the top of the turn (Leo, 2026-11-04: "replacing the top level + // thingy"); opened, its runs fold per run as Claude desktop draws them (cmux#30176). + claude: { conclusion: true, runs: true }, + // Gemini CLI draws a turn as it ran: each run of calls in one tool box, no turn-level fold. + gemini: { conclusion: false, runs: true }, + // Muse Code draws its turn as it ran too, with no conclusion fold: per-run lines in place. + muse: { conclusion: false, runs: true }, +}; + +/// The fold style of a harness family (acpmux's summary `family`). +export function turnFoldStyle(family: string | undefined): TurnFoldStyle { + const key = agentKey(family); + return (key !== undefined && Object.hasOwn(TURN_FOLD_STYLES, key) ? TURN_FOLD_STYLES[key] : undefined) ?? CODEX; +} diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index 88940d814d6c..1dd2115b133e 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -41,6 +41,7 @@ import { foldBackgroundTurns, openBackgroundTurns, } from "./backgroundTurns"; +import { turnFoldStyle } from "./turnFoldStyle"; import { foldMessageRuns, messageRunFoldFor, messageRunRevealKey, openMessageRuns } from "./messageRuns"; import { backgroundRunFoldFor, foldBackgroundRuns, openBackgroundRuns } from "./backgroundRuns"; @@ -619,8 +620,7 @@ function shapedTurn( ); // Where its runs fold, a run of them that carries messages between agents folds under one line // (messageRuns.ts; Leo, 2026-11-01: "this should be collapsible"). - const messaged = - liveRuns || agentFamily?.startsWith("claude") === true ? foldMessageRuns(shapedBody, expanded) : shapedBody; + const messaged = liveRuns || turnFoldStyle(agentFamily).runs ? foldMessageRuns(shapedBody, expanded) : shapedBody; // A run of background-task notifications folds under one line (backgroundRuns.ts; Leo, // 2026-11-02: "why do you still do these"). // A stretch the person came in after ends there, with the end-of-turn footer (`supersededFooters`). @@ -779,13 +779,15 @@ function shapeTurn( const summary = turn[end]!; const body = turn.slice(0, end).filter((row) => !isVisiblyEmptyMessage(row)); // Each harness family ends a turn as its own app does (Leo, 2026-11-01: "separate it per - // harness/model family. claude gets claude behaviour, codex gets codex"). Codex and ChatGPT fold - // the whole turn's work under one "Worked for". Claude Code has no "Worked for" of its own, but - // its turn takes one at the top too (Leo, 2026-11-04: "ofc claude code doesn't actually have the - // worked for... but i think we should be able to do that replacing the top level thingy maybe"): - // opened, each run of calls, the short notes inside it with it, folds into its own summary line, - // and a longer message between runs stays in place, as Claude desktop draws them. - const claudeGroups = agentFamily?.startsWith("claude") === true; + // harness/model family. claude gets claude behaviour, codex gets codex"), from one table + // (turnFoldStyle.ts). Codex and ChatGPT fold the whole turn's work under one "Worked for". Claude + // Code has no "Worked for" of its own, but its turn takes one at the top too (Leo, 2026-11-04: "ofc + // claude code doesn't actually have the worked for... but i think we should be able to do that + // replacing the top level thingy maybe"): opened, each run of calls, the short notes inside it + // with it, folds into its own summary line, and a longer message between runs stays in place, as + // Claude desktop draws them. Gemini and Muse draw no "Worked for": their runs fold in place. + const style = turnFoldStyle(agentFamily); + const inPlace = !style.conclusion; // Notification turns can contain an empty assistant segment and a summary solely for // bookkeeping. With no visible prompt or work, omit the whole turn, including its footer. if (isVisiblyEmptyMessage(user) && body.length === 0 && summary.status === "completed") return []; @@ -806,7 +808,7 @@ function shapeTurn( const steerDialogue = steerDialogueIn(body); let folded = false; // Where the sub-sessions a folded turn started stay in sight (taskLine.experiment.ts). - const taskLook = experimentArm(finishedTaskLook); + const taskLook = inPlace ? undefined : experimentArm(finishedTaskLook); // A completed turn gets one fold for its work; personal steers keep their dialogue visible. The // row boundary above is still opensTurn/continuesTurn: an unrelated incoming prompt starts a // separate turn and is never gathered by adjacency alone. @@ -817,7 +819,8 @@ function shapeTurn( // Work after the last answer folds with the work before it, so the answer stays last. // Edits after it join the card too, so its Undo covers the whole turn. const id = `${WORKED}-${segment.anchor.id}`; - const open = foldOpen || expanded.has(id); + // A family with no "Worked for" draws its turn as an open one without the line. + const open = inPlace || foldOpen || expanded.has(id); // A call still waiting for its permission stays in sight: beside the closed fold, and in its // folded run once the fold opens (TurnRows.tsx `staysInSight`). const out = (row: AcpmuxRow) => @@ -839,7 +842,7 @@ function shapeTurn( ? { id: `${TASK_LINE}-${segment.anchor.id}`, version, at: segment.anchor.at, kind: TASK_LINE, tasks } : undefined; if (work.length && !dialogue) { - folded = true; + folded = !inPlace; const from = segment.anchor.at; // Its label counts the fold's messages (`previous`), which can change while the summary's and // the answer's versions do not: the count is part of the version, so the memoized row redraws. @@ -860,16 +863,16 @@ function shapeTurn( // "Worked for". ...(previous !== undefined && { previous }), }; - shaped.push(worked); + if (!inPlace) shaped.push(worked); if (taskLine && taskLook === "after-fold") shaped.push(taskLine); // Open, each run of its work folds into its summary line between the commentary, as in a // running turn (codexRuns). A run that edited folds too: its edits stay in sight between the // summary lines of the work around them (TurnRows.tsx `runStretches`). // A steer's reply never joins a run: it stays in sight beside the steer. - const runs = liveRuns || claudeGroups; + const runs = liveRuns || style.runs; if (open) shaped.push( - ...(runs ? mergeShortNotes(work, steerDialogue, claudeGroups) : work).flatMap(withoutDrafts).map((row) => ({ + ...(runs ? mergeShortNotes(work, steerDialogue, style.runs) : work).flatMap(withoutDrafts).map((row) => ({ ...endedPlan(row), id: isEdit(row) ? `${row.id}${FOLDED}` : row.id, settled: true, diff --git a/webviews/test/transcript-claude-turn-groups.test.ts b/webviews/test/transcript-claude-turn-groups.test.ts index 174f516a3f45..b4f5b9bae942 100644 --- a/webviews/test/transcript-claude-turn-groups.test.ts +++ b/webviews/test/transcript-claude-turn-groups.test.ts @@ -206,3 +206,41 @@ test("a steered Codex turn still folds everything but the steer, the reply befor const view = turnView(steered(), new Set(), { now: Date.UTC(2026, 10, 1, 10), liveRuns: true, agentFamily: "codex" }); expect(view.map((row) => row.id)).toEqual(["u", `${WORKED}-u`, "r1", `${SUPERSEDED}-s1`, "s1", "answer", "sum"]); }); + +// Gemini CLI and Muse Code draw a turn as it ran, with no "Worked for" over the conclusion (Leo, +// 2026-11-04: "chatgpt-style minimalism conclusion folding can only necessarily make sense for +// them"): each run folds into its own line in place, the longer messages between them. +for (const family of ["gemini", "muse"]) { + test(`the recorded turn in a ${family} chat draws no Worked for; each run folds in place`, async () => { + const rows = await replayRows(); + const view = turnView(rows, new Set(), { now: history.at(-1).at + 60_000, liveRuns: true, agentFamily: family }); + expect(view.some((row) => row.kind === WORKED)).toBe(false); + const runs = view.filter((row) => row.kind === "activity" && !row.merged); + expect(runs.length).toBeGreaterThan(1); + expect(runs.every((row) => row.foldRun === true)).toBe(true); + expect( + assistants(view) + .filter((row) => LONG.test(row.text ?? "")) + .map(longAt), + ).toEqual([14, 16, 17]); + expect(view.find((row) => row.kind === "turnSummary")?.folded).toBe(false); + }); + + test(`a steered ${family} turn keeps every run in place, its notes inside them`, () => { + const view = turnView(steered(), new Set(), { + now: Date.UTC(2026, 10, 1, 10), + liveRuns: true, + agentFamily: family, + }); + expect(view.map((row) => row.id)).toEqual(["u", "b1", "r1", "b3", `${SUPERSEDED}-s1`, "s1", "b4", "answer", "sum"]); + }); +} + +test("a family the table does not name folds as Codex does", () => { + const view = turnView(steered(), new Set(), { + now: Date.UTC(2026, 10, 1, 10), + liveRuns: true, + agentFamily: "opencode", + }); + expect(view.map((row) => row.id)).toEqual(["u", `${WORKED}-u`, "r1", `${SUPERSEDED}-s1`, "s1", "answer", "sum"]); +});