From 38e54cad37aa67a977e7e2594f6534484b058ea6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 05:21:18 -0700 Subject: [PATCH 01/11] test(agent-pane): the todo list draws as a card with Claude's marks Red on feat-cmux-next: the row takes the composer Plan toggle's 28px height and clips the list, the progress is a visible heading, done steps are struck through, the step in progress is bold and nothing spins. Co-Authored-By: Claude Opus 5.5 --- webviews/test/agent-pane-plan.test.ts | 49 +++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/webviews/test/agent-pane-plan.test.ts b/webviews/test/agent-pane-plan.test.ts index 00fcc14647cd..0b781b10c96b 100644 --- a/webviews/test/agent-pane-plan.test.ts +++ b/webviews/test/agent-pane-plan.test.ts @@ -173,7 +173,8 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => { await page.getByText("All three done.").waitFor(); const lists = await page.evaluate(() => [...document.querySelectorAll(".cv-plan")].map((list) => ({ - heading: list.querySelector(".cv-plan__heading")?.textContent, + // Heard, not drawn, like each item's state: the marks show the progress. + progress: list.querySelector(".cv-plan__progress")?.textContent, items: [...list.querySelectorAll(".cv-plan__item")].map((item) => [ item.querySelector(".cv-plan__text")?.textContent, item.getAttribute("data-status"), @@ -190,7 +191,7 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => { ).toBe(true); expect(lists).toEqual([ { - heading: "2 of 3 tasks done", + progress: "2 of 3 tasks done", items: [ ["Add retry to the uploader", "completed", "Done"], ["Cover it with a test", "completed", "Done"], @@ -199,6 +200,50 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => { }, ]); + // Claude desktop's card: the marks carry the state, with no strike-through or bold, and the + // step the agent left in progress stops spinning once its turn has ended. + expect( + await page.evaluate(() => + [...document.querySelectorAll(".cv-plan__item")].map((item) => { + const style = getComputedStyle(item); + return [style.textDecorationLine, style.fontWeight]; + }), + ), + ).toEqual([ + ["none", "400"], + ["none", "400"], + ["none", "400"], + ]); + expect( + await page + .locator('.cv-plan__item[data-status="in_progress"] .cv-plan__mark') + .evaluate((mark) => getComputedStyle(mark).animationName), + ).toBe("none"); + expect(await page.locator(".cv-plan").evaluate((list) => getComputedStyle(list).boxShadow)).not.toBe("none"); + + await page.close(); + }); + test("while the turn runs, the step in progress spins and the next one waits as a dashed circle", async () => { + const page = await browser!.newPage({ viewport: { width: 900, height: 760 } }); + await page.goto(url(LIVE)); + await page.getByText("Adding retry now.").waitFor(); + const mark = (status: string) => + page.locator(`.cv-plan__item[data-status="${status}"] .cv-plan__mark`).evaluate((node) => { + const style = getComputedStyle(node); + return { animation: style.animationName, border: style.borderTopStyle }; + }); + // Every step shows: the row is not held to the composer Plan toggle's 28px (.acpmux-plan). + expect( + await page.evaluate(() => { + const row = document.querySelector(".acpmux-row:has(.cv-plan)")!.getBoundingClientRect(); + return [...document.querySelectorAll(".cv-plan__item")].every((item) => { + const box = item.getBoundingClientRect(); + return box.top >= row.top && box.bottom <= row.bottom; + }); + }), + ).toBe(true); + expect((await mark("in_progress")).animation).toBe("cv-plan-spin"); + expect(await mark("pending")).toEqual({ animation: "none", border: "dashed" }); await page.close(); }); test("while the turn runs, the checklist stands for its TodoWrite calls", async () => { From c15a5afc7692089ffc21438153d2ac01d263039d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 05:47:53 -0700 Subject: [PATCH 02/11] fix(agent-pane): the todo list draws as Claude's quiet card, unclipped The composer Plan toggle's rules matched the transcript's todo row, which is also .acpmux-plan, so the row took the toggle's 28px height and its paint containment cut the list to about one step. The toggle's rules now name the button, as #20833 did. The list itself draws as Claude desktop's card: a filled check is done, a thin spinner runs and a dashed circle waits, with no visible heading, strike-through or bold. A step left in progress stops spinning once its turn ends. Screen readers still hear the progress and each state. Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/App.tsx | 6 ++- .../agent-session/acpmux/composerStates.css | 11 ++++-- .../acpmux/conversation/PlanList.gallery.ts | 4 +- .../acpmux/conversation/PlanList.tsx | 14 ++++--- .../acpmux/conversation/conversation.css | 37 +++++++++++++------ .../acpmux/conversation/turns.ts | 9 ++++- webviews/src/agent-session/acpmux/model.ts | 6 ++- webviews/src/agent-session/acpmux/styles.css | 6 +-- 8 files changed, 61 insertions(+), 32 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 18583faf2d17..1fbcdec6c12a 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -667,7 +667,11 @@ const SummaryRow = memo( ); const PlanRow = memo( function PlanRow({ row }: RowProps) { - return row.plan ? :
{row.text}
; + return row.plan ? ( + + ) : ( +
{row.text}
+ ); }, (a, b) => a.row.id === b.row.id && a.row.version === b.row.version, ); diff --git a/webviews/src/agent-session/acpmux/composerStates.css b/webviews/src/agent-session/acpmux/composerStates.css index 131d89ef8a2c..14fc95cb4d6d 100644 --- a/webviews/src/agent-session/acpmux/composerStates.css +++ b/webviews/src/agent-session/acpmux/composerStates.css @@ -40,9 +40,12 @@ --acpmux-composer-shadow: 0 0 transparent; } -/* Composer chips: Plan, the mode, model and effort pickers, and +. */ +/* Composer chips: Plan, the mode, model and effort pickers, and +. The Plan toggle's rules name the + button (`:where`, so their specificity stays a class's): the transcript's todo list row is + `.acpmux-plan` too (App.tsx's `acpmux-row acpmux-${kind}`), and the toggle's 28px height clipped + the list. */ .acpmux-picker-button:active, -.acpmux-plan:active { +:where(button).acpmux-plan:active { background: var(--acpmux-composer-press); } .acpmux-plan[aria-pressed="true"]:hover { @@ -54,7 +57,7 @@ /* A press shows at once (cg-composer click polish, as Codex's chips): the hover's ease below never delays the tone under the button going down. */ .acpmux-picker-button:active, -.acpmux-plan:active, +:where(button).acpmux-plan:active, .acpmux-location-button:active { transition-duration: 0s; } @@ -159,7 +162,7 @@ button.acpmux-send:disabled:not(.acpmux-cancel):not(.acpmux-send-ready) { /* Hover and press colors ease, and the picker and slash menus fade in; with Reduce Motion they apply at once. */ @media (prefers-reduced-motion: no-preference) { .acpmux-picker-button, - .acpmux-plan { + :where(button).acpmux-plan { transition: background-color var(--agent-motion-hover) var(--agent-ease-out), color var(--agent-motion-hover) var(--agent-ease-out); diff --git a/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts b/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts index fb5a8a77cb56..e79bf3108a7d 100644 --- a/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts @@ -1,6 +1,6 @@ // l10n-allow-file: gallery fixtures (sample prompts, replies and todo items), not shipped UI. // The agent's todo list (PlanList.tsx, cx-ncc.80): Claude's TodoWrite or Codex's plan draws as a -// checklist under its progress, outside a finished turn's "Worked for" fold, and live while the +// quiet card, outside a finished turn's "Worked for" fold, and live while the // turn runs. import { agentPaneEntry } from "../../../gallery/format"; import { assistant, chat, row, summary, user } from "../../../gallery/fixtures/acpmux"; @@ -16,7 +16,7 @@ export default agentPaneEntry({ covers: ["agent-session/acpmux/conversation/PlanList.tsx"], variants: { running: { - note: "A turn under way: one item done, one in progress (bold), one waiting.", + note: "A turn under way: one item done (filled check), one in progress (spinner), one waiting (dashed circle).", snapshot: chat([ user("Add retry to the uploader, cover it and update the docs", 3), row("plan", 2, { diff --git a/webviews/src/agent-session/acpmux/conversation/PlanList.tsx b/webviews/src/agent-session/acpmux/conversation/PlanList.tsx index 96e67515d75e..6c893d009fa6 100644 --- a/webviews/src/agent-session/acpmux/conversation/PlanList.tsx +++ b/webviews/src/agent-session/acpmux/conversation/PlanList.tsx @@ -1,7 +1,8 @@ // The agent's todo list (cx-ncc.80, Claude parity): an ACP `plan` update (Claude's TodoWrite through -// acpmux, or Codex's own plan) draws as a checklist under its progress, and a turn's later updates -// redraw its one list (direct.ts). Done items are struck through, the one in progress is bold; a -// screen reader hears each item's state. +// acpmux, or Codex's own plan) draws as a quiet card, and a turn's later updates redraw its one list +// (direct.ts). As in Claude desktop, the marks carry the state: a filled check is done, a thin +// spinner runs and a dashed circle waits, with no heading, strike-through or bold. Once the turn +// ends a step left in progress stops spinning. A screen reader hears the progress and each state. import { useT, type StringKey } from "../i18n"; import type { AcpmuxPlanEntry } from "../model"; import { Check } from "./icons"; @@ -12,12 +13,13 @@ const STATUS: Record = { pending: "plan.status.pending", }; -export function PlanList({ entries }: { entries: readonly AcpmuxPlanEntry[] }) { +export function PlanList({ entries, ended = false }: { entries: readonly AcpmuxPlanEntry[]; ended?: boolean }) { const t = useT(); const done = entries.filter((entry) => entry.status === "completed").length; return ( -
-
+
+ {/* Heard, not drawn: the marks show the progress. */} +
{entries.length === 1 ? t("plan.progress.one", { done }) : t("plan.progress", { done, total: entries.length })}
    diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 3f7cfb233d09..1e57689aaaf8 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -2315,16 +2315,17 @@ pre.cv-math-source { backdrop-filter: none; } -/* The agent's todo list (PlanList.tsx, cx-ncc.80). */ +/* The agent's todo list (PlanList.tsx, cx-ncc.80): a quiet card, as in Claude desktop. A filled + check is done, a thin spinner runs and a dashed circle waits; no heading, strike-through or bold. */ .cv-plan { + position: relative; margin: 8px 0; + padding: 8px 12px; + border-radius: 10px; + background: var(--cv-card-fill); + box-shadow: inset 0 0 0 1px var(--cv-card-ring); font-size: var(--text-body); } -.cv-plan__heading { - height: 20px; - font-size: var(--text-caption); - color: var(--cv-text-muted); -} .cv-plan__items { margin: 0; padding: 0; @@ -2334,7 +2335,7 @@ pre.cv-math-source { display: flex; align-items: center; gap: 8px; - min-height: 22px; + min-height: 24px; } .cv-plan__mark { flex: 0 0 auto; @@ -2347,7 +2348,8 @@ pre.cv-math-source { border-radius: 50%; box-sizing: border-box; } -/* The item's state for screen readers only (the pane does not load ui.css's .ui-sr-only). */ +/* The progress and each item's state for screen readers only (the pane does not load ui.css's + .ui-sr-only). */ .cv-plan__state { position: absolute; width: 1px; @@ -2358,18 +2360,29 @@ pre.cv-math-source { } .cv-plan__item[data-status="completed"] { color: var(--cv-text-muted); - text-decoration: line-through; } .cv-plan__item[data-status="completed"] .cv-plan__mark { border-color: transparent; background: var(--cv-text-muted); color: var(--agent-accent-text, #fff); } -.cv-plan__item[data-status="in_progress"] { - font-weight: 600; +.cv-plan__item[data-status="pending"] .cv-plan__mark { + border-style: dashed; } +/* A thin arc on a faint ring; it turns while the turn runs. */ .cv-plan__item[data-status="in_progress"] .cv-plan__mark { - border-color: var(--agent-text); + border-color: var(--cv-card-ring); + border-top-color: var(--agent-text); +} +@media (prefers-reduced-motion: no-preference) { + .cv-plan:not([data-ended]) .cv-plan__item[data-status="in_progress"] .cv-plan__mark { + animation: cv-plan-spin 0.9s linear infinite; + } +} +@keyframes cv-plan-spin { + to { + transform: rotate(1turn); + } } /* ---------- A background shell's end (BackgroundTaskNote.tsx, cx-ncc.84) ---------- */ diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index 7338422406d0..e76f89057815 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -322,7 +322,7 @@ function shapeTurn( if (open) shaped.push(...work.map((row) => ({ ...row, id: isEdit(row) ? `${row.id}${FOLDED}` : row.id, settled: true }))); } - shaped.push(...groups, ...renderRows(body, version)); + shaped.push(...groups.map(endedPlan), ...renderRows(body, version)); if (answer) shaped.push(answer); shaped.push(...rest, ...editsCard(edits)); const preview = turnPreviewUrl(user, turn); @@ -392,6 +392,11 @@ const staysOut = (row: AcpmuxRow) => // A session offer waits for the person's Start or Dismiss. row.kind === "sessionOffer"; +/// A finished turn's todo list: a step the agent left in progress no longer spins. The version +/// moves off the live one (v to 2v + 1), so the memoized row draws again when the turn ends. +const endedPlan = (row: AcpmuxRow): AcpmuxRow => + row.kind === "plan" && row.plan ? { ...row, turnEnded: true, version: row.version * 2 + 1 } : row; + /// A turn that ended without a summary (history paged in from before acpmux kept one, or a /// turn a restart cut off): its work folds under "N previous messages", like any /// reloaded turn without timing, and its answer and edits draw after it. @@ -409,7 +414,7 @@ function settledWithoutSummary(user: AcpmuxRow, turn: AcpmuxRow[], expanded: Rea return [ { id, version: version * 2 + (open ? 1 : 0), at: user.at, kind: WORKED, previous }, ...(open ? work.map((row) => ({ ...row, id: isEdit(row) ? `${row.id}${FOLDED}` : row.id, settled: true })) : []), - ...before.filter(staysOut), + ...before.filter(staysOut).map(endedPlan), ...(final >= 0 ? rows.slice(final) : []), ...editsCard(edits), ]; diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 5cdc28fd1983..a36b4870ff6b 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -77,6 +77,8 @@ export type AcpmuxRow = { prompt?: string; /// An edited-files card of a turn that has ended, which offers Undo (conversation/turns.ts). ended?: boolean; + /// A todo list whose turn has ended: a step left in progress no longer spins (conversation/turns.ts). + turnEnded?: boolean; /// The edits an edited-files card merges (their row ids), so Find reaches each (chatFind.ts). merged?: string[]; /// A shell mode command's block (shell/shellRuns.ts), which the page adds; never from acpmux. @@ -462,8 +464,8 @@ function fallbackRowHeight(row: AcpmuxRow, width: number): number { const started = row.offers.filter((offer) => offer.outcome === "accepted").length; return 8 + 8 + 28 + open * 84 + started * 30; } - // The plan's 20px progress line over its 22px items, inside 8px margins (conversation/PlanList.tsx). - if (row.kind === "plan" && row.plan) return 24 + 20 + row.plan.length * 22; + // The plan card's 24px items inside 8px of padding and 8px margins (conversation/PlanList.tsx). + if (row.kind === "plan" && row.plan) return 16 + 16 + row.plan.length * 24; if ( row.kind === "turnSummary" || row.kind === "notice" || diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index 48e1fdfc10cb..416389da5af9 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -74,10 +74,10 @@ .acpmux-picker-button{display:flex;align-items:center;gap:4px;min-width:0;height:28px;padding:0 8px;border:0;border-radius:6px;background:none;color:var(--agent-text);white-space:nowrap} .acpmux-picker-button>span{overflow:hidden;text-overflow:ellipsis} .acpmux-picker-button[aria-expanded=true]{background:var(--acpmux-pill-open,color-mix(in srgb,var(--agent-text) 16%,transparent));box-shadow:inset 0 0 0 1px var(--acpmux-pill-edge,color-mix(in srgb,var(--agent-text) 24%,transparent))}@media (hover:hover) and (pointer:fine){.acpmux-picker-button:hover{background:var(--acpmux-pill-hover,color-mix(in srgb,var(--agent-text) 11%,transparent))}} -.acpmux-picker-button:focus-visible,.acpmux-plan:focus-visible,.acpmux-send:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:2px}.acpmux-model .acpmux-picker-button:active,.acpmux-effort .acpmux-picker-button:active,.acpmux-access .acpmux-picker-button:active{background:var(--acpmux-pill-press,color-mix(in srgb,var(--agent-text) 22%,transparent))}.acpmux-model .acpmux-picker-button:focus-visible,.acpmux-effort .acpmux-picker-button:focus-visible,.acpmux-access .acpmux-picker-button:focus-visible{background:var(--acpmux-pill-open,color-mix(in srgb,var(--agent-text) 16%,transparent));box-shadow:inset 0 0 0 1px var(--acpmux-pill-edge,color-mix(in srgb,var(--agent-text) 24%,transparent))} +.acpmux-picker-button:focus-visible,:where(button).acpmux-plan:focus-visible,.acpmux-send:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:2px}.acpmux-model .acpmux-picker-button:active,.acpmux-effort .acpmux-picker-button:active,.acpmux-access .acpmux-picker-button:active{background:var(--acpmux-pill-press,color-mix(in srgb,var(--agent-text) 22%,transparent))}.acpmux-model .acpmux-picker-button:focus-visible,.acpmux-effort .acpmux-picker-button:focus-visible,.acpmux-access .acpmux-picker-button:focus-visible{background:var(--acpmux-pill-open,color-mix(in srgb,var(--agent-text) 16%,transparent));box-shadow:inset 0 0 0 1px var(--acpmux-pill-edge,color-mix(in srgb,var(--agent-text) 24%,transparent))} .acpmux-mode .acpmux-picker-button{gap:6px;padding:0 8px 0 6px;color:var(--agent-muted)} -.acpmux-plan{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 6px;border:0;border-radius:6px;background:none;color:var(--agent-muted);font:inherit;cursor:default;white-space:nowrap} -.acpmux-plan:hover{background:var(--acpmux-composer-hover);color:var(--agent-text)} +:where(button).acpmux-plan{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 6px;border:0;border-radius:6px;background:none;color:var(--agent-muted);font:inherit;cursor:default;white-space:nowrap} +:where(button).acpmux-plan:hover{background:var(--acpmux-composer-hover);color:var(--agent-text)} .acpmux-plan[aria-pressed=true]{background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 16%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--agent-accent,var(--agent-text)) 45%,transparent);color:var(--agent-accent,var(--agent-text))} .acpmux-separator{flex:none;width:1px;height:18px;margin:0 4px;background:var(--acpmux-composer-edge)} .acpmux-chips>.acpmux-plan+.acpmux-chips-spacer,.acpmux-chips>.acpmux-mode+.acpmux-chips-spacer{margin-left:0} From 54c194ff3cc65ec30a7d7d212eee7d49ee015479 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 06:32:03 -0700 Subject: [PATCH 03/11] Test that every ended turn's plan stops spinning Four ways a turn ends still leave its plan row live: the last turn without a summary, an earlier one with no other work, and a plan sent after the answer or after the summary. Co-Authored-By: Claude Opus 5.5 --- webviews/test/transcript-plan-ended.test.ts | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 webviews/test/transcript-plan-ended.test.ts diff --git a/webviews/test/transcript-plan-ended.test.ts b/webviews/test/transcript-plan-ended.test.ts new file mode 100644 index 000000000000..4583b0ddc68d --- /dev/null +++ b/webviews/test/transcript-plan-ended.test.ts @@ -0,0 +1,42 @@ +// A finished turn's todo list stops spinning (PlanList's `ended`): every way a turn ends marks +// its plan row `turnEnded`, and only a running turn's plan stays live. +import { expect, test } from "bun:test"; +import type { AcpmuxRow } from "../src/agent-session/acpmux/model"; +import { turnView } from "../src/agent-session/acpmux/conversation/turns"; + +let at = 0; +const row = (id: string, kind: string, extra: Partial = {}): AcpmuxRow => ({ + id, + kind, + version: 1, + at: (at += 1_000), + ...extra, +}); +const plan = (id: string) => row(id, "plan", { plan: [{ content: "Run the tests", status: "in_progress" }] }); +const summary = (id: string) => row(id, "turnSummary", { status: "completed" }); + +const planRow = (rows: AcpmuxRow[], id: string, working = false) => + turnView(rows, new Set(), { working }).find((shaped) => shaped.id === id); + +test("a running turn's plan stays live", () => { + expect(planRow([row("u", "user"), plan("p")], "p", true)?.turnEnded).toBeUndefined(); +}); + +test("the last turn without a summary, no longer working, ends its plan", () => { + expect(planRow([row("u", "user"), plan("p")], "p")?.turnEnded).toBe(true); +}); + +test("an earlier turn without a summary or other work ends its plan", () => { + const rows = [row("u1", "user"), plan("p"), row("u2", "user"), row("a2", "assistant", { text: "ok" })]; + expect(planRow(rows, "p")?.turnEnded).toBe(true); +}); + +test("a plan sent after the answer ends with the turn", () => { + const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), plan("p"), summary("s")]; + expect(planRow(rows, "p")?.turnEnded).toBe(true); +}); + +test("a plan arriving after the summary ends with the turn", () => { + const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), summary("s"), plan("p")]; + expect(planRow(rows, "p")?.turnEnded).toBe(true); +}); From 0cf4c2ef8fe26997c1c9289db304b87059c62d3f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 06:44:07 -0700 Subject: [PATCH 04/11] fix(agent-pane): every ended turn's plan stops spinning; wrap long steps A plan row in the last turn without a summary, in an earlier one with no other work, or sent after the answer or the summary now ends with its turn. An ended in-progress step is a plain ring, not a frozen arc. Long step text wraps under its first line, with the mark beside that line. The row height estimate matches the card (40 + 24 per step). Co-Authored-By: Claude Opus 5.5 --- .../agent-session/acpmux/conversation/PlanList.tsx | 2 +- .../acpmux/conversation/conversation.css | 14 +++++++++++++- .../src/agent-session/acpmux/conversation/turns.ts | 8 ++++---- webviews/src/agent-session/acpmux/model.ts | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/PlanList.tsx b/webviews/src/agent-session/acpmux/conversation/PlanList.tsx index 6c893d009fa6..ffd470684ba6 100644 --- a/webviews/src/agent-session/acpmux/conversation/PlanList.tsx +++ b/webviews/src/agent-session/acpmux/conversation/PlanList.tsx @@ -31,7 +31,7 @@ export function PlanList({ entries, ended = false }: { entries: readonly AcpmuxP {entry.status === "completed" && } {entry.content} - {/* The marks and strike-through are drawn only; a screen reader hears the state. */} + {/* The marks are drawn only; a screen reader hears the state. */} {t(STATUS[entry.status])} ))} diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 1e57689aaaf8..6e51d073afb3 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -2333,9 +2333,16 @@ pre.cv-math-source { } .cv-plan__item { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; min-height: 24px; + padding-block: 4px; + box-sizing: border-box; + line-height: 16px; +} +.cv-plan__text { + min-width: 0; + overflow-wrap: anywhere; } .cv-plan__mark { flex: 0 0 auto; @@ -2345,6 +2352,7 @@ pre.cv-math-source { width: 12px; height: 12px; border: 1.5px solid var(--cv-text-muted); + margin-top: 2px; border-radius: 50%; box-sizing: border-box; } @@ -2374,6 +2382,10 @@ pre.cv-math-source { border-color: var(--cv-card-ring); border-top-color: var(--agent-text); } +/* Left in progress when its turn ended: a plain ring, not an arc frozen mid-turn. */ +.cv-plan[data-ended] .cv-plan__item[data-status="in_progress"] .cv-plan__mark { + border-color: var(--cv-text-muted); +} @media (prefers-reduced-motion: no-preference) { .cv-plan:not([data-ended]) .cv-plan__item[data-status="in_progress"] .cv-plan__mark { animation: cv-plan-spin 0.9s linear infinite; diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index e76f89057815..9d2d89a84281 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -287,7 +287,7 @@ function shapeTurn( return live ? liveTurn(user, turn, clockOffset, liveRuns) : last - ? turn + ? turn.map(endedPlan) : settledWithoutSummary(user, turn, expanded); const summary = turn[end]!; const body = turn.slice(0, end); @@ -324,7 +324,7 @@ function shapeTurn( } shaped.push(...groups.map(endedPlan), ...renderRows(body, version)); if (answer) shaped.push(answer); - shaped.push(...rest, ...editsCard(edits)); + shaped.push(...rest.map(endedPlan), ...editsCard(edits)); const preview = turnPreviewUrl(user, turn); if (preview) shaped.push({ id: `${PREVIEW}-${user.id}`, version, at: summary.at, kind: PREVIEW, text: preview }); // The footer copies the answer, so it carries the answer's text; the last turn's also retries @@ -338,7 +338,7 @@ function shapeTurn( }); // Anything after the summary (late tool updates, or a turn the agent started on its own) // draws as it came. - shaped.push(...turn.slice(end + 1)); + shaped.push(...turn.slice(end + 1).map(endedPlan)); return shaped; } @@ -405,7 +405,7 @@ function settledWithoutSummary(user: AcpmuxRow, turn: AcpmuxRow[], expanded: Rea const final = answerAt(rows); const before = final >= 0 ? rows.slice(0, final) : rows; const work = before.filter((row) => !staysOut(row)); - if (work.length === 0) return rows; + if (work.length === 0) return rows.map(endedPlan); const id = `${WORKED}-${user.id}`; const open = expanded.has(id); const edits = work.filter(isEdit); diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index a36b4870ff6b..031e3a0e8ee7 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -465,7 +465,7 @@ function fallbackRowHeight(row: AcpmuxRow, width: number): number { return 8 + 8 + 28 + open * 84 + started * 30; } // The plan card's 24px items inside 8px of padding and 8px margins (conversation/PlanList.tsx). - if (row.kind === "plan" && row.plan) return 16 + 16 + row.plan.length * 24; + if (row.kind === "plan" && row.plan) return 40 + row.plan.length * 24; if ( row.kind === "turnSummary" || row.kind === "notice" || From 7bf5729e2e0e0071ab05fc1675c3f287eeae5d5f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 05:18:09 +0000 Subject: [PATCH 05/11] cmux NEXT: test that Static stops the todo list spinner Co-Authored-By: Claude Opus 5.5 --- webviews/test/static-status-loops.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webviews/test/static-status-loops.test.ts b/webviews/test/static-status-loops.test.ts index 6c25be61c529..b9866fa105ad 100644 --- a/webviews/test/static-status-loops.test.ts +++ b/webviews/test/static-status-loops.test.ts @@ -3,7 +3,7 @@ // `data-status-indicators="static"` on the root: the app's WebTheme bootstrap sets it from the theme // payload (`statusIndicators`), which this test runs as the app does, and under it, or under Reduce // Motion, no web loop may run: the mic's wait pulse, the model picker and harness spinners, the -// floating chat's breathing dot, the Thinking sweep, the reply caret's wait pulse, the Jump to +// floating chat's breathing dot, the Thinking sweep, the todo list's spinner, the reply caret's wait pulse, the Jump to // Latest dots and Home's "writing" pulse. The diff viewer does not get the pane's theme, so its // spinners stop under Reduce Motion alone. Static also gives the color-only marks a shape each. // This loads the real stylesheets in Chromium and lists the animations that never end. @@ -48,6 +48,7 @@ await requireBrowserLane("static-status-loops.test.ts", async () => { `
    Thinking
    ` + `
    ` + `` + + `
    ` + `writing`; const diffLoops = `
    Loading
    `; From cf0bdd5f9ee4b9cffa613c087f34ecb30f14b94b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 05:18:09 +0000 Subject: [PATCH 06/11] cmux NEXT: the todo list spinner holds still under Static status indicators Co-Authored-By: Claude Opus 5.5 --- .../src/agent-session/acpmux/conversation/conversation.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index cc00e3d4a0f9..383a8b575d43 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -2475,6 +2475,10 @@ pre.cv-math-source { animation: cv-plan-spin 0.9s linear infinite; } } +/* Static status indicators (or Reduce Motion, above): the arc holds still. */ +:root[data-status-indicators="static"] .cv-plan__item[data-status="in_progress"] .cv-plan__mark { + animation: none; +} @keyframes cv-plan-spin { to { transform: rotate(1turn); From 7384349d23924cc1dc52223141131c5f49933678 Mon Sep 17 00:00:00 2001 From: "cmux-autofix[bot]" Date: Mon, 19 Oct 2026 06:41:44 +0000 Subject: [PATCH 07/11] chore(next): PR autofix at 2e1857aeaa26 - vp fmt webviews/src/agent-session/acpmux/replyHref.ts - vp fmt webviews/src/pages/markdown/README.md - vp fmt webviews/src/pages/markdown/links.ts - vp fmt webviews/test/markdown-cmux-app-links.test.ts Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/39210635038 --- webviews/src/agent-session/acpmux/replyHref.ts | 5 ++++- webviews/src/pages/markdown/README.md | 2 +- webviews/src/pages/markdown/links.ts | 3 ++- webviews/test/markdown-cmux-app-links.test.ts | 6 +++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/webviews/src/agent-session/acpmux/replyHref.ts b/webviews/src/agent-session/acpmux/replyHref.ts index 2f822170bc68..afa44e277099 100644 --- a/webviews/src/agent-session/acpmux/replyHref.ts +++ b/webviews/src/agent-session/acpmux/replyHref.ts @@ -23,7 +23,10 @@ export function safeHref(href: string): string | undefined { /// (`javascript:`, `file:`, `data:`, `ssh:`...) as text. export function appHref(href: string): string | undefined { const text = href.trim(); - if (!/^cmux(-[a-z0-9-]+)?:\/\/(workspace|pane|tab|session|chief)\/[A-Za-z0-9._~\-/]+(#[A-Za-z0-9._~\-]+)?$/.test(text)) return undefined; + if ( + !/^cmux(-[a-z0-9-]+)?:\/\/(workspace|pane|tab|session|chief)\/[A-Za-z0-9._~\-/]+(#[A-Za-z0-9._~\-]+)?$/.test(text) + ) + return undefined; let url: URL; try { url = new URL(text); diff --git a/webviews/src/pages/markdown/README.md b/webviews/src/pages/markdown/README.md index 07f48930937d..7b58868aaa5c 100644 --- a/webviews/src/pages/markdown/README.md +++ b/webviews/src/pages/markdown/README.md @@ -54,7 +54,7 @@ Lengths given as numbers are pixels (`em` where noted); strings are CSS values. | `markdown.defaultMode` | `"rich"` | The mode a file opens in: `"rich"` or `"source"`. | | `markdown.toolbar` | `true` | Show the toolbar (file, save status, mode switch). | | `markdown.font.family` | system UI font | `--cmux-md-font-family` | -| `markdown.font.size` | `16` | `--cmux-md-font-size`; the default text size (Settings row; Cmd-0 returns to it) | +| `markdown.font.size` | `16` | `--cmux-md-font-size`; the default text size (Settings row; Cmd-0 returns to it) | | `markdown.font.weight` | `400` | `--cmux-md-font-weight` | | `markdown.font.lineHeight` | `1.6` | `--cmux-md-line-height` | | `markdown.headings.family` | the body font | `--cmux-md-heading-font-family` | diff --git a/webviews/src/pages/markdown/links.ts b/webviews/src/pages/markdown/links.ts index 50533601fc94..67d5f3990243 100644 --- a/webviews/src/pages/markdown/links.ts +++ b/webviews/src/pages/markdown/links.ts @@ -16,7 +16,8 @@ export interface ParsedLink { /** `cmux://workspace/ws_…`, `pane/…`, `tab/…`, `session/…`, `chief/…` in the `cmux` scheme or a build's * `cmux-…` one; the host checks the ids again (DeepLink.parse) and opens nothing it does not know. */ -const APP_LINK = /^cmux(-[a-z0-9-]+)?:\/\/(workspace|pane|tab|session|chief)\/[A-Za-z0-9._~\-/]+(#[A-Za-z0-9._~\-]+)?$/i; +const APP_LINK = + /^cmux(-[a-z0-9-]+)?:\/\/(workspace|pane|tab|session|chief)\/[A-Za-z0-9._~\-/]+(#[A-Za-z0-9._~\-]+)?$/i; const MARKDOWN_PATH = /\.(md|markdown|mdx|mdown|mkd)$/i; diff --git a/webviews/test/markdown-cmux-app-links.test.ts b/webviews/test/markdown-cmux-app-links.test.ts index bc3bc8b95204..77954962fcc7 100644 --- a/webviews/test/markdown-cmux-app-links.test.ts +++ b/webviews/test/markdown-cmux-app-links.test.ts @@ -38,7 +38,11 @@ test("following a cmux app link on the Markdown page asks the host to open it as const calls: { op: string; params: unknown }[] = []; const client = { call: async (op: string, params: unknown) => (calls.push({ op, params }), {}) }; const router = new LinkRouter({ - store: { getState: () => ({ config: { path: "/Users/me/notes.md" } }), navigate: async () => null, go: async () => null } as never, + store: { + getState: () => ({ config: { path: "/Users/me/notes.md" } }), + navigate: async () => null, + go: async () => null, + } as never, client: client as never, resolver: { get: () => undefined }, scrollToAnchor: () => false, From e6ccef3f23b8dd9cc471d1ec4ea1332bec546232 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 23:52:39 -0700 Subject: [PATCH 08/11] test(agent-pane): an earlier turn without a summary ends a plan sent after its answer Co-Authored-By: Claude Opus 5.5 --- webviews/test/transcript-plan-ended.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/webviews/test/transcript-plan-ended.test.ts b/webviews/test/transcript-plan-ended.test.ts index 4583b0ddc68d..3e9873df4a6e 100644 --- a/webviews/test/transcript-plan-ended.test.ts +++ b/webviews/test/transcript-plan-ended.test.ts @@ -40,3 +40,15 @@ test("a plan arriving after the summary ends with the turn", () => { const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), summary("s"), plan("p")]; expect(planRow(rows, "p")?.turnEnded).toBe(true); }); + +test("an earlier turn without a summary ends a plan sent after its answer", () => { + const rows = [ + row("u1", "user"), + row("t1", "thought", { text: "Reading the branch" }), + row("a1", "assistant", { text: "done" }), + plan("p"), + row("u2", "user"), + row("a2", "assistant", { text: "ok" }), + ]; + expect(planRow(rows, "p")?.turnEnded).toBe(true); +}); From e22275d75b7d04298cb33f217e5d78f3b0c6f5a8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 23:54:36 -0700 Subject: [PATCH 09/11] agent pane: an earlier turn's late plan stops spinning; Static stops the arc by selector, not source order settledWithoutSummary returned the rows from the answer on without endedPlan, so a plan sent after the answer of a summary-less turn with folded work kept spinning. The spinner rule now carries the Static condition itself, and the default-mode loops check asserts the plan spinner is among the running loops. Co-Authored-By: Claude Opus 5.5 --- .../agent-session/acpmux/conversation/conversation.css | 10 +++++----- .../src/agent-session/acpmux/conversation/turns.ts | 2 +- webviews/test/static-status-loops.test.ts | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 383a8b575d43..fa9dc7945fb4 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -2470,15 +2470,15 @@ pre.cv-math-source { .cv-plan[data-ended] .cv-plan__item[data-status="in_progress"] .cv-plan__mark { border-color: var(--cv-text-muted); } +/* Static status indicators and Reduce Motion: the arc holds still. */ @media (prefers-reduced-motion: no-preference) { - .cv-plan:not([data-ended]) .cv-plan__item[data-status="in_progress"] .cv-plan__mark { + :root:not([data-status-indicators="static"]) + .cv-plan:not([data-ended]) + .cv-plan__item[data-status="in_progress"] + .cv-plan__mark { animation: cv-plan-spin 0.9s linear infinite; } } -/* Static status indicators (or Reduce Motion, above): the arc holds still. */ -:root[data-status-indicators="static"] .cv-plan__item[data-status="in_progress"] .cv-plan__mark { - animation: none; -} @keyframes cv-plan-spin { to { transform: rotate(1turn); diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index 9d2d89a84281..5f9ea6a27965 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -415,7 +415,7 @@ function settledWithoutSummary(user: AcpmuxRow, turn: AcpmuxRow[], expanded: Rea { id, version: version * 2 + (open ? 1 : 0), at: user.at, kind: WORKED, previous }, ...(open ? work.map((row) => ({ ...row, id: isEdit(row) ? `${row.id}${FOLDED}` : row.id, settled: true })) : []), ...before.filter(staysOut).map(endedPlan), - ...(final >= 0 ? rows.slice(final) : []), + ...(final >= 0 ? rows.slice(final).map(endedPlan) : []), ...editsCard(edits), ]; } diff --git a/webviews/test/static-status-loops.test.ts b/webviews/test/static-status-loops.test.ts index b9866fa105ad..d0b090c0f344 100644 --- a/webviews/test/static-status-loops.test.ts +++ b/webviews/test/static-status-loops.test.ts @@ -109,6 +109,8 @@ await requireBrowserLane("static-status-loops.test.ts", async () => { const running = await loops(p); await p.close(); expect(running.length).toBeGreaterThanOrEqual(10); + // The todo list's spinner among them, so the Static and Reduce Motion checks cover it. + expect(running.some((loop) => loop.endsWith("cv-plan-spin"))).toBe(true); }); test("Static status indicators stop every pane loop", async () => { From df45559ce126ff0ae04689bb67cd2a97dafca636 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 12:37:11 -0700 Subject: [PATCH 10/11] test: a live turn's plan after the summary keeps spinning (fails) Co-Authored-By: Claude Opus 5.5 --- webviews/test/transcript-plan-ended.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webviews/test/transcript-plan-ended.test.ts b/webviews/test/transcript-plan-ended.test.ts index 3e9873df4a6e..88f2dd732b52 100644 --- a/webviews/test/transcript-plan-ended.test.ts +++ b/webviews/test/transcript-plan-ended.test.ts @@ -52,3 +52,8 @@ test("an earlier turn without a summary ends a plan sent after its answer", () = ]; expect(planRow(rows, "p")?.turnEnded).toBe(true); }); + +test("a turn the agent started on its own after the summary keeps its plan live", () => { + const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), summary("s"), plan("p")]; + expect(planRow(rows, "p", true)?.turnEnded).toBeUndefined(); +}); From 4bae12972f1e814e65d451511c5fbcd8fb7d5c1c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 12:37:12 -0700 Subject: [PATCH 11/11] fix(agent-pane): a live turn's plan after the summary keeps spinning Review finding: rows after a turn summary were marked turnEnded even while the turn was live, so a plan the agent started on its own stopped spinning. Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/conversation/turns.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index 5f9ea6a27965..7b73562bc0c6 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -338,7 +338,8 @@ function shapeTurn( }); // Anything after the summary (late tool updates, or a turn the agent started on its own) // draws as it came. - shaped.push(...turn.slice(end + 1).map(endedPlan)); + // A turn the agent started on its own while still working keeps its plan live. + shaped.push(...turn.slice(end + 1).map(live ? (row) => row : endedPlan)); return shaped; }