Skip to content
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8907,6 +8907,7 @@ function AcpmuxPane() {
if (target === undefined) return undefined;
if (target.fold !== undefined) openFold(target.fold);
if (target.run !== undefined) openFold(target.run);
for (const fold of target.also ?? []) openFold(fold);
const key = Date.now();
setRevealRow({ rowId: target.rowId, key, toolId: "" });
setTimeout(() => setRevealRow((current) => (current?.key === key ? undefined : current)), REVEAL_HOLD_MS);
Expand Down
2 changes: 2 additions & 0 deletions webviews/src/agent-session/acpmux/Localizable.xcstrings

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// l10n-allow-file: gallery fixtures (sample prompts, commands and replies), not shipped UI.
// A run of background-task notifications folds into one line (backgroundRuns.ts), and a reply of only
// punctuation draws no row (visibleMessage.ts). Leo, 2026-11-02, on a chat that drew "Background task
// finished" four times, each with a lone "·" reply under it: "why do you still do these".
import { agentPaneEntry } from "../../../gallery/format";
import { activity, assistant, chat, row, summary, tool, user } from "../../../gallery/fixtures/acpmux";
import type { AcpmuxRow } from "../model";
import { backgroundRunRowId } from "./backgroundRuns";
import { BACKGROUND_TURN, backgroundTurnRowId } from "./backgroundTurns";

const quiet = (id: string, minutes: number): AcpmuxRow =>
row(BACKGROUND_TURN, minutes, {
id: backgroundTurnRowId(id),
background: { id, trigger: "task_notification", running: false, quiet: true, hasRows: true },
});
/// What the agent did in a quiet background turn: read the task's log, then the lone "·" an agent
/// sends to end a turn it had nothing to say in, which draws nothing.
const dot = (id: string, minutes: number) => [
activity(
[tool("tail -n 3 suite.log", "execute", "completed", { command: "tail -n 3 suite.log", output: "14 passed" })],
minutes - 0.03,
{ id: `${id}-work`, inBackground: id },
),
assistant("·", minutes - 0.05, { id: `${id}-dot`, inBackground: id }),
];
const opening = [
user("Run the four suites in the background and tell me if anything breaks", 12),
assistant("Started all four suites in the background.", 11.9),
summary(11.9, { status: "completed", durationMs: 4_000 }),
];

/// Leo's chat: four quiet task turns in a row, each answered with "·".
const fourFinished = [
...opening,
quiet("gallery-run-a", 10),
...dot("gallery-run-a", 10),
quiet("gallery-run-b", 9),
...dot("gallery-run-b", 9),
quiet("gallery-run-c", 8),
...dot("gallery-run-c", 8),
quiet("gallery-run-d", 7),
...dot("gallery-run-d", 7),
];
const RUN = backgroundRunRowId(backgroundTurnRowId("gallery-run-a"));

/// A stopped shell among the quiet lines, as in Leo's screenshot.
const mixed = [
...opening,
quiet("gallery-mix-a", 10),
...dot("gallery-mix-a", 10),
row("backgroundTask", 9, {
id: "gallery-mix-stopped",
backgroundTask: { toolCallId: "gallery-mix-stopped", command: "Wait for rust tests", state: "stopped" },
}),
quiet("gallery-mix-b", 8),
...dot("gallery-mix-b", 8),
quiet("gallery-mix-c", 7),
...dot("gallery-mix-c", 7),
];

/// Two quiet lines, a task the agent answered with something to say, then two more.
const withReply = [
...opening,
quiet("gallery-reply-a", 10),
...dot("gallery-reply-a", 10),
quiet("gallery-reply-b", 9),
...dot("gallery-reply-b", 9),
row(BACKGROUND_TURN, 8, {
id: backgroundTurnRowId("gallery-reply-loud"),
background: { id: "gallery-reply-loud", trigger: "task_notification", running: false, quiet: false },
}),
activity(
[tool("tail -n 3 orders.log", "execute", "completed", { command: "tail -n 3 orders.log", output: "2 failed" })],
7.9,
{ inBackground: "gallery-reply-loud" },
),
assistant("The orders suite failed: 2 of 14 tests in `orders_backfill`. Want me to look?", 7.8, {
inBackground: "gallery-reply-loud",
}),
quiet("gallery-reply-c", 6),
...dot("gallery-reply-c", 6),
quiet("gallery-reply-d", 5),
...dot("gallery-reply-d", 5),
];

export default agentPaneEntry({
id: "agent-pane.background-runs",
title: "Background task runs",
area: "Agent pane",
height: 420,
covers: [
"agent-session/acpmux/conversation/backgroundRuns.ts",
"agent-session/acpmux/conversation/BackgroundTurnLine.tsx",
],
variants: {
closed: {
note: "Four background tasks finished in a row; the agent read each one's log and answered with a lone dot. One muted line, 4 background tasks finished, closed; no dot draws. (A turn whose only reply is the dot has nothing to open and draws no line at all.)",
snapshot: chat(fourFinished),
},
open: {
note: "The same run opened: each Background task finished line again, in order, and still no dot. Play: open the run.",
snapshot: chat(fourFinished),
checks: {
layoutShiftMax: {
value: 0.5,
reason: "Opening the run intentionally reveals its lines below the clicked line.",
},
},
play: async (ctx) => {
const line = { selector: `[data-row-id="${RUN}"] .cv-background-turn` };
await ctx.click(line);
await ctx.waitFor(() => ctx.find(line).getAttribute("aria-expanded") === "true");
},
},
mixed: {
note: "A stopped shell among the quiet lines, as in Leo's screenshot: one line, 4 background updates. A failed shell would stay on its own row.",
snapshot: chat(mixed),
},
"reply-kept": {
note: "A task the agent answered with something to say splits the run: 2 background tasks finished, its rows and reply as they came, then 2 more.",
snapshot: chat(withReply),
},
"single-line": {
note: "One quiet line alone stays as it was (no run of one); opened, its lone dot draws no row of its own.",
snapshot: chat([...opening, quiet("gallery-one", 10), ...dot("gallery-one", 10)]),
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
// spinner; once it ended quiet it leads with the clock. A click opens the turn's rows under it, which
// the transcript draws as its own rows (turns.ts), so the open state is the transcript's fold set,
// kept per chat like a "Worked for" fold, and survives virtualization. One with no row of its own
// yet has nothing to open, so it has no chevron and is no button.
// yet has nothing to open, so it has no chevron and is no button. A run of
// background-task notifications folded under one line (backgroundRuns.ts) draws here too, the same
// line in the same look: "4 background tasks finished".
import { useT } from "../i18n";
import type { AcpmuxRow } from "../model";
import { backgroundRunLabel } from "./backgroundRuns";
import { backgroundTurnLabel } from "./backgroundTurns";
import { Clock, Spinner } from "./icons";
import { TranscriptActivityRow } from "./TranscriptActivityRow";
Expand All @@ -20,6 +23,25 @@ export function BackgroundTurnLine({
onToggle: () => void;
}) {
const t = useT();
const run = row.backgroundRun;
if (run) {
const label = backgroundRunLabel(t, run);
return (
<TranscriptActivityRow
icon={<Clock />}
before={label}
className="cv-background-turn"
ariaLabel={label}
expanded={expanded}
attributes={{
"aria-expanded": String(expanded),
"data-find-skip": "",
"data-background-turn": "run",
}}
onClick={onToggle}
/>
);
}
const turn = row.background;
if (!turn) return null;
const label = backgroundTurnLabel(t, turn);
Expand Down
170 changes: 170 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/backgroundRuns.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// l10n-allow-file: test fixture replies, not shipped UI.
// A run of background-task notifications folds into one line (backgroundRuns.ts), and a reply of
// only punctuation draws no row (visibleMessage.ts). Leo, 2026-11-02, on a column of "Background
// task finished" rows with a lone "·" reply under each: "why do you still do these".
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { AcpmuxRow } from "../model";
import { BackgroundTurnLine } from "./BackgroundTurnLine";
import { backgroundRunRowId, foldBackgroundRuns } from "./backgroundRuns";
import { BACKGROUND_TURN, backgroundTurnRowId } from "./backgroundTurns";
import { messageRunRowId } from "./messageRuns";
import { hiddenRowFolds, messageRevealTarget, turnView } from "./turns";
import { isBareAgentText, isVisiblyEmptyMessage } from "./visibleMessage";

let at = 0;
const row = (id: string, kind: string, extra: Partial<AcpmuxRow> = {}): AcpmuxRow => ({
id,
kind,
version: 1,
at: (at += 1_000),
...extra,
});
const line = (id: string, background: Partial<NonNullable<AcpmuxRow["background"]>> = {}) =>
row(backgroundTurnRowId(id), BACKGROUND_TURN, {
background: { id, trigger: "task_notification", running: false, quiet: true, hasRows: true, ...background },
});
const note = (id: string, state: "completed" | "failed" | "stopped") =>
row(id, "backgroundTask", { backgroundTask: { toolCallId: id, command: "cargo test", state } });
const dot = (id: string, extra: Partial<AcpmuxRow> = {}) => row(id, "assistant", { text: "·", ...extra });
const ids = (rows: AcpmuxRow[]) => rows.filter((entry) => entry.kind !== "date").map((entry) => entry.id);
const prompt = () => {
at = 0;
return [
row("prompt", "user", { text: "Run the suites in the background" }),
row("reply", "assistant", { text: "Started them." }),
row("summary", "turnSummary", { status: "completed", durationMs: 2_000 }),
];
};

test("a reply of only punctuation draws no row; a person's does", () => {
for (const text of [".", "·", "…", " . ", "—", "...", ""]) expect(isBareAgentText(text)).toBe(true);
for (const text of ["ok", "1", ". done", "👍"]) expect(isBareAgentText(text)).toBe(false);
expect(isVisiblyEmptyMessage(dot("a"))).toBe(true);
expect(isVisiblyEmptyMessage(row("q", "user", { text: "?" }))).toBe(false);
expect(isVisiblyEmptyMessage(row("r", "assistant", { text: "Done." }))).toBe(false);
});

test("four quiet background task lines with lone replies fold into one closed line", () => {
const rows = [
...prompt(),
line("a"),
dot("a-dot"),
line("b"),
dot("b-dot"),
line("c"),
dot("c-dot"),
line("d"),
dot("d-dot"),
];
const view = turnView(rows, new Set());
const run = backgroundRunRowId(backgroundTurnRowId("a"));
expect(ids(view)).toEqual(["prompt", "reply", "summary", run]);
const drawnRun = view.at(-1)!;
expect(drawnRun.kind).toBe(BACKGROUND_TURN);
expect(drawnRun.backgroundRun).toMatchObject({ finished: 4, stopped: 0 });
const html = renderToStaticMarkup(<BackgroundTurnLine row={drawnRun} expanded={false} onToggle={() => {}} />);
expect(html).toContain("4 background tasks finished");
expect(html).toContain('aria-expanded="false"');
// Opened: each line again, in order; the lone replies still draw nothing.
const open = turnView(rows, new Set([run]));
expect(ids(open).slice(3)).toEqual([
run,
backgroundTurnRowId("a"),
backgroundTurnRowId("b"),
backgroundTurnRowId("c"),
backgroundTurnRowId("d"),
]);
});

test("a finished and a stopped shell fold with the lines, as background updates", () => {
const rows = [...prompt(), line("a"), note("stop", "stopped"), line("b")];
const view = turnView(rows, new Set());
const run = view.at(-1)!;
expect(run.backgroundRun).toMatchObject({ finished: 2, stopped: 1 });
const html = renderToStaticMarkup(<BackgroundTurnLine row={run} expanded={false} onToggle={() => {}} />);
expect(html).toContain("3 background updates");
});

test("a notification followed by a real reply keeps that reply in sight", () => {
const rows = [
...prompt(),
line("a"),
line("loud", { quiet: false }),
row("said", "assistant", { text: "The suite failed: 2 tests.", inBackground: "loud" }),
line("b"),
];
const view = turnView(rows, new Set());
expect(ids(view).slice(3)).toEqual([backgroundTurnRowId("a"), "said", backgroundTurnRowId("b")]);
});

test("a failed shell and a running line end a run; one line alone stays as it was", () => {
const rows = [...prompt(), line("a"), note("fail", "failed"), line("b"), line("live", { running: true })];
const view = turnView(rows, new Set());
expect(view.some((entry) => entry.backgroundRun)).toBe(false);
expect(ids(view).slice(3)).toEqual([
backgroundTurnRowId("a"),
"fail",
backgroundTurnRowId("b"),
backgroundTurnRowId("live"),
]);
});

test("a member line opened on its own keeps its rows inside the run", () => {
const rows = [
...prompt(),
line("a"),
row("a-work", "activity", { inBackground: "a", items: [] }),
dot("a-dot", { inBackground: "a" }),
line("b"),
];
const run = backgroundRunRowId(backgroundTurnRowId("a"));
// The member opened while its run is closed: still one line.
expect(ids(turnView(rows, new Set([backgroundTurnRowId("a")]))).slice(3)).toEqual([run]);
// Both open: the member's rows under it, then the next member.
expect(ids(turnView(rows, new Set([run, backgroundTurnRowId("a")]))).slice(3)).toEqual([
run,
backgroundTurnRowId("a"),
"a-work",
backgroundTurnRowId("b"),
]);
});

test("Find opens the run, then the member line, for a row behind both", () => {
const rows = [...prompt(), line("a"), row("a-work", "activity", { inBackground: "a", items: [] }), line("b")];
const drawn = turnView(rows, new Set());
const run = backgroundRunRowId(backgroundTurnRowId("a"));
// Then a message run that may fold the row inside the member's rows (messageRuns.ts).
expect(hiddenRowFolds(rows, drawn, 4)).toEqual([run, backgroundTurnRowId("a"), messageRunRowId("a-work")]);
expect(messageRevealTarget(rows, drawn, ["a-work"])).toEqual({
rowId: "a-work",
fold: run,
run: backgroundTurnRowId("a"),
also: [messageRunRowId("a-work")],
});
expect(hiddenRowFolds(rows, drawn, 5)).toEqual([run]);
});

test("rows without a run come back as they were, and the run line keeps its object", () => {
const plain = [...prompt(), line("a")];
expect(foldBackgroundRuns(plain, new Set())).toBe(plain);
const rows = [...prompt(), line("a"), line("b")];
const first = foldBackgroundRuns(rows, new Set()).at(-1)!;
const again = foldBackgroundRuns(rows, new Set()).at(-1)!;
expect(again.version).toBe(first.version);
expect(foldBackgroundRuns(rows, new Set([first.id])).find((entry) => entry.id === first.id)!.version).not.toBe(
first.version,
);
});

test("closing a run hides its last member's opened rows with it", () => {
const rows = [...prompt(), line("a"), line("b"), row("b-work", "activity", { inBackground: "b", items: [] })];
const run = backgroundRunRowId(backgroundTurnRowId("a"));
expect(ids(turnView(rows, new Set([backgroundTurnRowId("b")]))).slice(3)).toEqual([run]);
expect(ids(turnView(rows, new Set([run, backgroundTurnRowId("b")]))).slice(3)).toEqual([
run,
backgroundTurnRowId("a"),
backgroundTurnRowId("b"),
"b-work",
]);
});
Loading
Loading