Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
38e54ca
test(agent-pane): the todo list draws as a card with Claude's marks
teamleaderleo Oct 18, 2026
c15a5af
fix(agent-pane): the todo list draws as Claude's quiet card, unclipped
teamleaderleo Oct 18, 2026
54c194f
Test that every ended turn's plan stops spinning
teamleaderleo Oct 18, 2026
0cf4c2e
fix(agent-pane): every ended turn's plan stops spinning; wrap long steps
teamleaderleo Oct 18, 2026
8b66525
Merge remote-tracking branch 'upstream/feat-cmux-next' into claude-po…
teamleaderleo Oct 18, 2026
5ce1fbc
Merge remote-tracking branch 'upstream/feat-cmux-next' into claude-po…
teamleaderleo Oct 19, 2026
28e1a5c
Merge feat-cmux-next into claude-polish/plan-card
teamleaderleo Oct 19, 2026
7bf5729
cmux NEXT: test that Static stops the todo list spinner
teamleaderleo Oct 19, 2026
cf0bdd5
cmux NEXT: the todo list spinner holds still under Static status indi…
teamleaderleo Oct 19, 2026
2e1857a
Merge remote-tracking branch 'origin/feat-cmux-next' into claude-poli…
teamleaderleo Oct 19, 2026
7384349
chore(next): PR autofix at 2e1857aeaa26
Oct 19, 2026
e6ccef3
test(agent-pane): an earlier turn without a summary ends a plan sent …
teamleaderleo Oct 19, 2026
e22275d
agent pane: an earlier turn's late plan stops spinning; Static stops …
teamleaderleo Oct 19, 2026
4cdd92e
Merge remote-tracking branch 'origin/feat-cmux-next' into claude-poli…
teamleaderleo Oct 19, 2026
df45559
test: a live turn's plan after the summary keeps spinning (fails)
teamleaderleo Oct 19, 2026
4bae129
fix(agent-pane): a live turn's plan after the summary keeps spinning
teamleaderleo Oct 19, 2026
e7c84b8
Merge feat-cmux-next into HEAD (picks up #22127: webview tests run pa…
teamleaderleo Oct 19, 2026
17925e0
Merge origin/feat-cmux-next into HEAD
teamleaderleo Oct 20, 2026
39cad31
Merge origin/feat-cmux-next into claude-polish/plan-card
teamleaderleo Oct 20, 2026
536a39c
Merge branch 'feat-cmux-next' into claude-polish/plan-card
teamleaderleo Oct 20, 2026
938215d
Merge origin/feat-cmux-next into claude-polish/plan-card
teamleaderleo Oct 20, 2026
2cd4b1a
Merge branch 'feat-cmux-next' into claude-polish/plan-card
teamleaderleo Oct 20, 2026
29fb864
Merge branch 'feat-cmux-next' into claude-polish/plan-card
teamleaderleo Oct 20, 2026
d28d8f8
Merge branch 'feat-cmux-next' into claude-polish/plan-card
teamleaderleo Oct 20, 2026
ec3e2ff
Merge feat-cmux-next (de3ed0ef6e07) into claude-polish/plan-card
teamleaderleo Oct 20, 2026
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
6 changes: 5 additions & 1 deletion webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,11 @@ const SummaryRow = memo(
);
const PlanRow = memo(
function PlanRow({ row }: RowProps) {
return row.plan ? <PlanList entries={row.plan} /> : <div className="acpmux-muted">{row.text}</div>;
return row.plan ? (
<PlanList entries={row.plan} ended={row.turnEnded} />
) : (
<div className="acpmux-muted">{row.text}</div>
);
},
(a, b) => a.row.id === b.row.id && a.row.version === b.row.version,
);
Expand Down
11 changes: 7 additions & 4 deletions webviews/src/agent-session/acpmux/composerStates.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,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 {
Expand All @@ -102,7 +105,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;
}
Expand Down Expand Up @@ -207,7 +210,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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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, {
Expand Down
16 changes: 9 additions & 7 deletions webviews/src/agent-session/acpmux/conversation/PlanList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,12 +13,13 @@ const STATUS: Record<AcpmuxPlanEntry["status"], StringKey> = {
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 (
<div className="cv-plan">
<div className="cv-plan__heading">
<div className="cv-plan" data-ended={ended ? "" : undefined}>
{/* Heard, not drawn: the marks show the progress. */}
<div className="cv-plan__progress cv-plan__state">
{entries.length === 1 ? t("plan.progress.one", { done }) : t("plan.progress", { done, total: entries.length })}
</div>
<ul className="cv-plan__items">
Expand All @@ -29,7 +31,7 @@ export function PlanList({ entries }: { entries: readonly AcpmuxPlanEntry[] }) {
{entry.status === "completed" && <Check size={10} />}
</span>
<span className="cv-plan__text">{entry.content}</span>
{/* 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. */}
<span className="cv-plan__state">{t(STATUS[entry.status])}</span>
</li>
))}
Expand Down
55 changes: 42 additions & 13 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -2409,26 +2409,34 @@ 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;
list-style: none;
}
.cv-plan__item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
min-height: 22px;
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;
Expand All @@ -2438,10 +2446,12 @@ 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;
}
/* 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;
Expand All @@ -2452,18 +2462,37 @@ 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);
}
/* 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);
}
/* Static status indicators and Reduce Motion: the arc holds still. */
@media (prefers-reduced-motion: no-preference) {
: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;
}
}
@keyframes cv-plan-spin {
to {
transform: rotate(1turn);
}
}

/* ---------- A background shell's end (BackgroundTaskNote.tsx, cx-ncc.84) ---------- */
Expand Down
20 changes: 13 additions & 7 deletions webviews/src/agent-session/acpmux/conversation/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -322,9 +322,9 @@ 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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Active plan looks stopped

When the agent starts another turn on its own, direct.ts keeps planRow across turn_result and turn_started. The next plan update reuses the row before the previous summary. groups.map(endedPlan) then sets turnEnded even while the agent is working, so the active step shows a still ring instead of spinning.

Start a new plan row at that turn boundary, and cover this sequence with an event-based test.

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
Expand All @@ -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));
// 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;
}

Expand Down Expand Up @@ -397,6 +398,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;
Comment on lines +412 to +413

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Finished plans keep spinning

Opening a long, finished chat can load a plan update and summary without their earlier prompt. attach loads only the newest 400 events, capped at 128 KiB. turnViewFrom copies rows before the first prompt unchanged, so that plan never gets turnEnded and its unfinished step keeps spinning. Apply the ended-plan logic to these leading rows too, while preserving any genuinely running plan.


/// 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.
Expand All @@ -405,7 +411,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);
Expand All @@ -414,8 +420,8 @@ 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),
...(final >= 0 ? rows.slice(final) : []),
...before.filter(staysOut).map(endedPlan),
...(final >= 0 ? rows.slice(final).map(endedPlan) : []),
...editsCard(edits),
];
}
Expand Down
6 changes: 4 additions & 2 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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.
Expand Down Expand Up @@ -494,8 +496,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 40 + row.plan.length * 24;
if (
row.kind === "turnSummary" ||
row.kind === "notice" ||
Expand Down
6 changes: 3 additions & 3 deletions webviews/src/agent-session/acpmux/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 47 additions & 2 deletions webviews/test/agent-pane-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"],
Expand All @@ -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 () => {
Expand Down
Loading