Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
});
39 changes: 39 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/turnFoldStyle.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, TurnFoldStyle>> = {
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;
}
33 changes: 18 additions & 15 deletions webviews/src/agent-session/acpmux/conversation/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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 [];
Expand All @@ -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.
Expand All @@ -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) =>
Expand All @@ -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.
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions webviews/test/transcript-claude-turn-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
Loading