diff --git a/webviews/src/agent-session/acpmux/conversation/TurnFoldPerHarness.gallery.ts b/webviews/src/agent-session/acpmux/conversation/TurnFoldPerHarness.gallery.ts new file mode 100644 index 000000000000..39ac3f5bf0af --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/TurnFoldPerHarness.gallery.ts @@ -0,0 +1,100 @@ +// l10n-allow-file: gallery fixtures (sample prompts, commands and replies), not shipped UI. +// One finished turn per harness family, folded as its row of turnFoldStyle.ts says (Leo, 2026-11-04: +// "do it for chatgpt, do the proper folds for however the other harnessses/model families fold +// (muse, gemini)", then, on Claude: "i think it should be the top level collapsible thing IF +// POSSIBLE... SOMETIMES there's just claude chatter that we could prob group up"). The same turn +// (two runs of calls, the chatter between them, then the conclusion) draws as ChatGPT's "Worked for +// 12m 16s ›" over the reply for Codex and Claude, opened as Claude desktop's per-run lines for +// Claude, and in place with no turn fold for Gemini and Muse. +import { agentPaneEntry, type AgentPaneVariant } from "../../../gallery/format"; +import { activity, assistant, chat, summary, tool, user } from "../../../gallery/fixtures/acpmux"; +import type { Play } from "../../../gallery/play"; +import type { AcpmuxActivity } from "../model"; + +const run = (description: string, command: string): AcpmuxActivity => + tool(command, "execute", "completed", { description, command, output: "ok\n", exitCode: 0 }); + +function turn() { + return [ + user("Make the fetch helper stop retrying client errors", 20), + activity( + [ + run("Found the retry loop", "rg -n 'retry' src/net/fetchWithRetry.ts"), + run("Listed every caller", "rg -l fetchWithRetry src"), + run("Read the retry tests", "sed -n '1,60p' src/net/fetchWithRetry.test.ts"), + ], + 19, + ), + assistant("The loop retries any response that isn't ok, so I'm adding a status check there.", 15), + activity( + [ + run("Ran the retry tests", "bun test src/net"), + run("Checked the branch is clean", "git status --short"), + ], + 10, + ), + assistant( + "Done. The helper now fails a 4xx at once, except 408 and 429, which still retry; a new test covers a 404.", + 8, + ), + summary(7.8, { status: "completed", durationMs: 736_000, toolCount: 5 }), + ]; +} + +const snapshot = (family: string, harness: string) => { + const base = chat(turn(), { title: "Stop retrying client errors", harness }); + return { ...base, summary: { ...base.summary!, family } }; +}; + +/// Opens the turn's "Worked for" line. +const openWorked: Play = async (ctx) => { + const worked = () => ctx.document.querySelector(".cv-worked"); + await ctx.waitFor(worked, { capMs: 10_000 }); + if (worked()?.getAttribute("aria-expanded") === "false") { + await ctx.click({ selector: ".cv-worked" }); + await ctx.waitFor(() => worked()?.getAttribute("aria-expanded") === "true", { capMs: 10_000 }); + } +}; + +const variant = (family: string, harness: string, note: string, play?: Play): AgentPaneVariant => ({ + note, + snapshot: snapshot(family, harness), + ...(play && { play }), +}); + +export default agentPaneEntry({ + id: "agent-pane.turn-fold-per-harness", + title: "Turn fold per harness family", + area: "Agent pane", + height: 640, + widths: { narrow: 520, normal: 760, wide: 900 }, + covers: ["agent-session/acpmux/conversation/turnFoldStyle.ts", "agent-session/acpmux/conversation/turns.ts"], + variants: { + claude: variant( + "claude", + "claude", + "Claude, chosen: ChatGPT's closed “Worked for 12m 16s” over the conclusion; the runs and the chatter between them fold under it.", + ), + "claude-open": variant( + "claude", + "claude", + "Claude, opened: each run folds into its own line with the chatter between them, as Claude desktop draws a turn.", + openWorked, + ), + codex: variant( + "codex", + "codex", + "Codex and ChatGPT: the same closed fold; with messages between the work, Codex's header counts them.", + ), + gemini: variant( + "gemini", + "gemini", + "Gemini CLI: no turn fold; each run of calls folds into its own line in place, as its tool boxes group them.", + ), + muse: variant( + "muse", + "muse", + "Muse Code: no reference of how its app ends a turn, so no turn fold; per-run lines in place.", + ), + }, +}); diff --git a/webviews/src/agent-session/acpmux/conversation/codexHeader.test.ts b/webviews/src/agent-session/acpmux/conversation/codexHeader.test.ts index 807e9d616308..be0d78d874c6 100644 --- a/webviews/src/agent-session/acpmux/conversation/codexHeader.test.ts +++ b/webviews/src/agent-session/acpmux/conversation/codexHeader.test.ts @@ -2,7 +2,7 @@ // A finished Codex turn's header, as Codex's own (Leo, 2026-11-02: "i assume the x previous messages // are when there's a mix and the worked for is if it's one contiguous thing ... let's follow those"): // work with the agent's messages between it reads "N previous messages", work alone "Worked for". -// Claude turns keep their per-run folding (#30176) and draw no header. +// Claude turns fold under ChatGPT's "Worked for" and never count messages (turnFoldStyle.ts). import { expect, test } from "bun:test"; import type { AcpmuxRow } from "../model"; import { WORKED, turnView, workedLabel } from "./turns"; @@ -63,8 +63,8 @@ test("work with the agent's messages between it reads N previous messages", () = }); // 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". -test("a Claude turn draws the same header; its runs fold inside it", () => { +// able to do that replacing the top level thingy maybe"; ChatGPT's header reads "Worked for 12m 16s". +test("a Claude turn reads Worked for, never Codex's message count", () => { const rows = [ prompt, work("w1", 1_000, 2), @@ -73,8 +73,9 @@ test("a Claude turn draws the same header; its runs fold inside it", () => { say("a", 50_000, "Done."), end, ]; - expect(header(rows, "claude")?.previous).toBeGreaterThan(0); - expect(header(rows, "claude")?.previous).toBe(header(rows)?.previous); + const row = header(rows, "claude"); + expect(row?.previous).toBeUndefined(); + expect(workedLabel(t, row!)).toBe("Worked for 1m"); }); test("a message joining the fold redraws the header: its count is part of the row's version", () => { 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..dea873291bff --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.test.ts @@ -0,0 +1,128 @@ +// l10n-allow-file: test fixtures. +// One finished turn per harness family, folded as its table row says (turnFoldStyle.ts). Leo, +// 2026-11-04: "do it for chatgpt, do the proper folds for however the other harnessses/model +// families fold (muse, gemini)", then, on Claude: "i think it should be the top level collapsible +// thing IF POSSIBLE". +import { expect, test } from "bun:test"; +import type { AcpmuxRow } from "../model"; +import { WORKED, turnView } from "./turns"; +import { TURN_FOLD_STYLES, turnFoldStyle } from "./turnFoldStyle"; + +const call = (id: string) => ({ + kind: "tool", + text: "Run", + tool: { id, title: "Run", kind: "execute", status: "completed" }, +}); +const prompt: AcpmuxRow = { id: "p", version: 1, at: 0, kind: "user", text: "Fix it" }; +const work = (id: string, at: number, n: number) => + ({ + id, + version: 1, + at, + kind: "activity", + items: Array.from({ length: n }, (_, i) => call(`${id}-${i}`)), + }) as AcpmuxRow; +const say = (id: string, at: number, text: string): AcpmuxRow => ({ id, version: 1, at, kind: "assistant", text }); +const end: AcpmuxRow = { + id: "s", + version: 1, + at: 60_000, + kind: "turnSummary", + status: "completed", + durationMs: 60_000, +}; +const NOTE = + "Found the cause in the parser: it reads the trailing comma as the start of another item, so every list " + + "that ends a line gets an empty entry, and the retry loop then treats that entry as a failed request."; +// Two runs of calls with chatter between them, then the conclusion. +const finished = [ + prompt, + work("w1", 1_000, 2), + // Longer than a short note, so it stands between the runs instead of joining one (mergeShortNotes). + say("n1", 10_000, NOTE), + work("w2", 20_000, 3), + say("a", 50_000, "Done: the parser keeps the trailing comma now."), + end, +]; +const drawn = (agentFamily: string, open: ReadonlySet = new Set()) => + turnView(finished, open, { now: 70_000, agentFamily }).filter((row) => row.kind !== "user"); +const ids = (rows: readonly AcpmuxRow[]) => rows.map((row) => row.id); + +test("the table is the one place a family's fold is named", () => { + expect(Object.keys(TURN_FOLD_STYLES).sort()).toEqual(["claude", "codex", "gemini", "muse"]); + // Variants share their agent's row, and an unknown family folds as Codex does. + expect(turnFoldStyle("claude-sr2")).toBe(TURN_FOLD_STYLES.claude!); + expect(turnFoldStyle("muse-code")).toBe(TURN_FOLD_STYLES.muse!); + expect(turnFoldStyle("opencode")).toBe(TURN_FOLD_STYLES.codex!); + expect(turnFoldStyle(undefined)).toBe(TURN_FOLD_STYLES.codex!); +}); + +test("codex: one closed header over the conclusion, counting the messages it folds", () => { + const rows = drawn("codex"); + const worked = rows.find((row) => row.kind === WORKED)!; + expect(worked.foldOpen).toBe(false); + expect(worked.previous).toBe(6); + expect(ids(rows.filter((row) => row.kind !== "turnSummary"))).toEqual([worked.id, "a"]); +}); + +test("claude: Worked for over the conclusion, the chatter and runs folded under it", () => { + const rows = drawn("claude"); + const worked = rows.find((row) => row.kind === WORKED)!; + expect(worked.foldOpen).toBe(false); + // ChatGPT's header: always "Worked for", never Codex's message count. + expect(worked.previous).toBeUndefined(); + expect(worked.durationMs).toBe(60_000); + expect(ids(rows.filter((row) => row.kind !== "turnSummary"))).toEqual([worked.id, "a"]); + expect(rows.find((row) => row.kind === "turnSummary")?.folded).toBe(true); +}); + +test("claude: opened, its runs fold per run with the chatter between them, as Claude desktop's", () => { + const closed = drawn("claude").find((row) => row.kind === WORKED)!; + const rows = drawn("claude", new Set([closed.id])); + const runs = rows.filter((row) => row.kind === "activity"); + expect(runs.length).toBe(2); + expect(runs.every((row) => row.foldRun === true)).toBe(true); + expect(ids(rows)).toContain("n1"); +}); + +test("claude: a turn that ends on a call keeps its last takeaway in sight, the call folded", () => { + const takeaway = say( + "t", + 30_000, + "The parser drops the trailing comma when the list ends a line, which is why every request looked failed " + + "to the retry loop; I'm sending the fix to the lane that owns the parser now and will check it lands.", + ); + const rows = turnView([prompt, work("w1", 1_000, 2), takeaway, work("w2", 40_000, 1), end], new Set(), { + now: 70_000, + agentFamily: "claude", + }); + const worked = rows.find((row) => row.kind === WORKED)!; + expect(worked.foldOpen).toBe(false); + expect(ids(rows.filter((row) => row.kind !== "turnSummary" && row.kind !== "user"))).toEqual([worked.id, "t"]); +}); + +test("claude: a turn with only its reply draws no Worked for", () => { + const rows = turnView([prompt, say("a", 5_000, "Yes."), end], new Set(), { now: 70_000, agentFamily: "claude" }); + expect(rows.some((row) => row.kind === WORKED)).toBe(false); + expect(ids(rows)).toContain("a"); +}); + +test("claude: a running turn draws its work as it happens, no Worked for yet", () => { + const rows = turnView([prompt, work("w1", 1_000, 2), say("n1", 10_000, "Looking.")], new Set(), { + now: 20_000, + agentFamily: "claude", + working: true, + }); + expect(rows.some((row) => row.kind === WORKED)).toBe(false); + expect(ids(rows)).toContain("n1"); +}); + +for (const family of ["gemini", "muse"]) + test(`${family}: no turn fold; each run folds into its own line in place, the chatter between them`, () => { + const rows = drawn(family); + expect(rows.some((row) => row.kind === WORKED)).toBe(false); + const order = ids(rows.filter((row) => row.kind !== "turnSummary")); + expect(order).toEqual(["w1", "n1", "w2", "a"]); + expect(rows.filter((row) => row.kind === "activity").every((row) => row.foldRun === true)).toBe(true); + expect(rows.find((row) => row.kind === "turnSummary")?.folded).toBe(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..b9838963a35f --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/turnFoldStyle.ts @@ -0,0 +1,47 @@ +// 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)", then: +// "i think it should be the top level collapsible thing IF POSSIBLE... SOMETIMES there's just claude +// chatter that we could prob group up". 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 chatter between it, goes under one closed "Worked for" + /// line with only the conclusion (its last reply after the last call) in sight, as ChatGPT and + /// Codex end a turn. 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"), as Claude desktop draws a turn: inside an open "Worked for", or in place where + /// the turn has none. False: the calls draw one under another (a running turn's live runs aside). + runs: boolean; + /// The closed line counts the fold's messages, "6 previous messages", when the agent's own + /// messages sit between its work, as Codex's header does. False: it always reads "Worked for Ns", + /// as ChatGPT's. + countsMessages: boolean; +}; + +/// ChatGPT and Codex: the conclusion fold, under Codex's own header. +const CODEX: TurnFoldStyle = { conclusion: true, runs: false, countsMessages: true }; + +/// Keyed by the harness family's agent (agentKey: "claude-sr2" is "claude"). A family missing here +/// folds as Codex does, as every family but Claude did before this table. +export const TURN_FOLD_STYLES: Readonly> = { + codex: CODEX, + // Claude: ChatGPT's "Worked for 12m 16s ›" over the conclusion; opened, its runs fold per run as + // Claude desktop's do (cmux#30176), so the chatter between them reads in place. + claude: { conclusion: true, runs: true, countsMessages: false }, + // Gemini CLI draws a turn as it ran, with each run of calls grouped in one tool box and no + // turn-level fold: per-run lines, no "Worked for". + gemini: { conclusion: false, runs: true, countsMessages: false }, + // Muse Code: no reference of how its app ends a turn, so no turn fold (the per-run lines Claude + // turns had before), until one shows otherwise. + muse: { conclusion: false, runs: true, countsMessages: false }, +}; + +/// The fold style of a harness family (acpmux's summary `family`); undefined folds as Codex does. +export function turnFoldStyle(family: string | undefined): TurnFoldStyle { + const key = agentKey(family); + return (key && 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..71c250738203 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -43,6 +43,7 @@ import { } from "./backgroundTurns"; import { foldMessageRuns, messageRunFoldFor, messageRunRevealKey, openMessageRuns } from "./messageRuns"; import { backgroundRunFoldFor, foldBackgroundRuns, openBackgroundRuns } from "./backgroundRuns"; +import { turnFoldStyle } from "./turnFoldStyle"; /// A row added by this pass: the "Worked for" disclosure of the turn opened by `turnId`. export const WORKED = "worked"; @@ -164,7 +165,7 @@ export function turnView( liveRuns?: boolean; sessionStatus?: boolean; lineBelow?: boolean; - /** The session's harness family; Claude keeps assistant prose outside the work fold. */ + /** The session's harness family, which picks how a finished turn folds (turnFoldStyle.ts). */ agentFamily?: string; /** The running turn's foreground subagents the running tray took out of `rows`. */ trayAgents?: number; @@ -620,7 +621,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; + 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`). @@ -785,7 +786,9 @@ function shapeTurn( // 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; + // The family's row of the one table (turnFoldStyle.ts) says how: Codex, ChatGPT and Claude under + // one "Worked for" (Claude's never counting messages), Gemini and Muse with no turn fold. + const style = turnFoldStyle(agentFamily); // 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 +809,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 = style.conclusion ? experimentArm(finishedTaskLook) : undefined; // 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 +820,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 turn fold draws as an open "Worked for" without the line. + const open = !style.conclusion || 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,11 +843,11 @@ 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 = style.conclusion; 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. - const previous = foldMixesMessages(work) ? foldedMessages(work) : undefined; + const previous = style.countsMessages && foldMixesMessages(work) ? foldedMessages(work) : undefined; const worked: AcpmuxRow = { id, version: (version * 2 + (open ? 1 : 0)) * PREVIOUS_SPAN + Math.min(PREVIOUS_SPAN - 1, previous ?? 0), @@ -860,16 +864,16 @@ function shapeTurn( // "Worked for". ...(previous !== undefined && { previous }), }; - shaped.push(worked); + if (style.conclusion) 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,