feat(ui): task-chat live turns fold older completed tool calls behind '> N earlier steps'

Live (unsettled) turns keep the most recent 3 activity rows visible; older
completed/failed tool rows and finished thinking blocks fold into an
expandable group. Each live row gets its own .tc-turn-fold wrapper so rows
entering the fold animate with the same --motion-turn-fold grid-rows motion
(zeroed under prefers-reduced-motion). In-flight tools, messages, and other
non-activity rows never fold and end the foldable prefix. Settled-turn
summary behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:14 -07:00
1 parent 182e5ab03d
commit edb2812235
2 files changed
+190 -8

No files matched your search

@@ -3,10 +3,19 @@
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskChatTurn, turnSummaryText } from "./TaskChatTurn";
import { TaskChatTurn, liveTurnFoldedIds, turnSummaryText } from "./TaskChatTurn";
import { buildTurnSummary } from "./transcript-adapter";
import type { TranscriptEntry } from "@/adapters";
import type { TaskChatTurnItem } from "./task-chat-model";
import type { TaskChatToolStatus, TaskChatTurnChildItem, TaskChatTurnItem } from "./task-chat-model";
const tool = (id: string, status: TaskChatToolStatus = "completed"): TaskChatTurnChildItem => ({
id,
kind: "tool",
name: "Read",
status,
});
const thinking = (id: string): TaskChatTurnChildItem => ({ id, kind: "thinking", lines: ["hm"] });
const SETTLED: TaskChatTurnItem = {
id: "t1",
@@ -56,6 +65,45 @@ describe("buildTurnSummary", () => {
});
});
describe("liveTurnFoldedIds", () => {
it("keeps everything visible while the window fits", () => {
// 4 activity rows → only 1 would fold, below the 2-row minimum.
expect(liveTurnFoldedIds(["a", "b", "c", "d"].map((id) => tool(id))).size).toBe(0);
});
it("folds finished rows past the 3-row tail window", () => {
const ids = liveTurnFoldedIds(["a", "b", "c", "d", "e", "f"].map((id) => tool(id)));
expect([...ids]).toEqual(["a", "b", "c"]);
});
it("counts thinking blocks as foldable activity", () => {
const ids = liveTurnFoldedIds([thinking("t1"), tool("a"), tool("b"), tool("c"), tool("d"), tool("e")]);
expect([...ids]).toEqual(["t1", "a", "b"]);
});
it("keeps the in-flight tool visible even past the window", () => {
const items = [tool("a"), tool("b"), tool("c"), tool("d"), tool("e", "in_progress")];
expect([...liveTurnFoldedIds(items)]).toEqual(["a", "b"]);
});
it("an early in-flight tool ends the foldable prefix", () => {
const items = [tool("a"), tool("b", "in_progress"), tool("c"), tool("d"), tool("e"), tool("f"), tool("g")];
// Only "a" precedes the in-flight row — below the fold minimum, nothing folds.
expect(liveTurnFoldedIds(items).size).toBe(0);
});
it("non-activity rows end the foldable prefix", () => {
const message: TaskChatTurnChildItem = { id: "m", kind: "message", author: "agent", text: "hi" };
const items = [tool("a"), tool("b"), message, tool("c"), tool("d"), tool("e"), tool("f"), tool("g")];
expect([...liveTurnFoldedIds(items)]).toEqual(["a", "b"]);
});
it("folds failed rows too — terminal is what matters", () => {
const ids = liveTurnFoldedIds([tool("a", "failed"), tool("b"), tool("c"), tool("d"), tool("e"), tool("f")]);
expect([...ids]).toEqual(["a", "b", "c"]);
});
});
describe("TaskChatTurn", () => {
let container: HTMLDivElement;
let root: Root;
@@ -106,4 +154,46 @@ describe("TaskChatTurn", () => {
renderTurn({ ...SETTLED, animateFold: true });
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
const liveTurn = (children: TaskChatTurnChildItem[]): TaskChatTurnItem => ({
...SETTLED,
settled: false,
items: children,
});
const stepsBtn = () =>
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-earlier-steps"]');
const foldedRows = () =>
container.querySelectorAll('[data-testid="task-chat-live-row"][data-folded="true"]');
it("shows no earlier-steps line while the live window fits", () => {
renderTurn(liveTurn(["a", "b", "c", "d"].map((id) => tool(id))));
expect(stepsBtn()).toBeNull();
expect(foldedRows().length).toBe(0);
});
it("folds older completed rows behind an earlier-steps line", () => {
renderTurn(liveTurn(["a", "b", "c", "d", "e", "f"].map((id) => tool(id))));
expect(stepsBtn()?.textContent).toContain("3 earlier steps");
expect(foldedRows().length).toBe(3);
});
it("expands the folded group in place on click", () => {
renderTurn(liveTurn(["a", "b", "c", "d", "e", "f"].map((id) => tool(id))));
flushSync(() => stepsBtn()!.click());
expect(stepsBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(foldedRows().length).toBe(0);
});
it("a newly arriving row pushes the oldest visible row into the fold", () => {
const ids = ["a", "b", "c", "d", "e", "f"];
renderTurn(liveTurn(ids.map((id) => tool(id))));
expect(foldedRows().length).toBe(3);
renderTurn(liveTurn([...ids, "g"].map((id) => tool(id))));
expect(foldedRows().length).toBe(4);
});
it("never renders the earlier-steps line on a settled turn", () => {
renderTurn({ ...SETTLED, items: ["a", "b", "c", "d", "e", "f"].map((id) => tool(id)) });
expect(stepsBtn()).toBeNull();
});
});
+98 -6
View File
@@ -8,6 +8,40 @@ interface TaskChatTurnProps {
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
}
/** Live-turn window: how many recent activity rows stay visible unfolded. */
export const LIVE_TURN_VISIBLE_ACTIVITY = 3;
/** Don't bother folding fewer rows than this — the fold line wouldn't save space. */
const LIVE_TURN_FOLD_MIN = 2;
function isActivityRow(child: TaskChatTurnChildItem): boolean {
return child.kind === "tool" || child.kind === "thinking";
}
/**
* Which rows of a LIVE (unsettled) turn fold into the "> N earlier steps"
* group. Folds the contiguous leading run of finished activity rows —
* completed/failed tools and thinking blocks — while keeping the most recent
* LIVE_TURN_VISIBLE_ACTIVITY activity rows visible. Pending/in-progress tool
* rows (including a tool still streaming its diff) never fold and end the
* prefix, as does any non-activity row (message/status/marker/usage).
* Thinking blocks need no own "finished" check: the adapter coalesces
* consecutive thinking, so any block with ≥ LIVE_TURN_VISIBLE_ACTIVITY
* activity rows after it can no longer grow.
*/
export function liveTurnFoldedIds(items: readonly TaskChatTurnChildItem[]): Set<string> {
const eligible = items.filter(isActivityRow).length - LIVE_TURN_VISIBLE_ACTIVITY;
const folded = new Set<string>();
if (eligible < LIVE_TURN_FOLD_MIN) return folded;
for (const child of items) {
if (folded.size >= eligible) break;
if (!isActivityRow(child)) break;
if (child.kind === "tool" && child.status !== "completed" && child.status !== "failed") break;
folded.add(child.id);
}
if (folded.size < LIVE_TURN_FOLD_MIN) folded.clear();
return folded;
}
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
export function turnSummaryMetrics(summary: TaskChatTurnItem["summary"]): string {
const parts: string[] = [];
@@ -25,10 +59,64 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
return metrics ? `${label} · ${metrics}` : label;
}
/**
* A live turn's activity list with the older-steps window (PAP-352). Each row
* sits in its own .tc-turn-fold wrapper so a row entering the folded group
* plays the same grid-rows collapse (--motion-turn-fold, zeroed under
* prefers-reduced-motion) the settled turn uses — new rows arriving visibly
* push the oldest visible row into the fold. The "> N earlier steps" line
* expands the group in place. Row spacing comes from per-row inner padding
* (not flex gap) so zero-height folded rows contribute no gaps.
*/
function LiveTurnActivity({ items, renderChild }: {
items: TaskChatTurnChildItem[];
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
}) {
const [stepsOpen, setStepsOpen] = useState(false);
const foldedIds = liveTurnFoldedIds(items);
const foldedCount = foldedIds.size;
return (
<div className="flex flex-col pt-1">
{foldedCount > 0 ? (
<button
type="button"
onClick={() => setStepsOpen((o) => !o)}
aria-expanded={stepsOpen}
className="flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-earlier-steps"
>
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", stepsOpen ? "rotate-90" : null)} />
<span>
{foldedCount} earlier step{foldedCount === 1 ? "" : "s"}
</span>
</button>
) : null}
{items.map((child, index) => {
const rowFolded = !stepsOpen && foldedIds.has(child.id);
return (
<div
key={child.id}
className="tc-turn-fold"
data-folded={rowFolded ? "true" : "false"}
aria-hidden={rowFolded}
data-testid="task-chat-live-row"
>
<div>
<div className={index === 0 ? undefined : "pt-2"}>{renderChild(child)}</div>
</div>
</div>
);
})}
</div>
);
}
/**
* One agent turn's activity, foldable to a one-line summary once settled.
*
* While the turn is live it renders fully interleaved with no summary line.
* While the turn is live it renders interleaved with no summary line, but
* older finished activity rows fold behind "> N earlier steps" (see
* LiveTurnActivity / liveTurnFoldedIds) so a long-running turn stays compact.
* When it settles the activity folds behind a "✓ Worked · …" line — animated
* (grid-rows via .tc-turn-fold, --motion-turn-fold) when the settle happens
* on-screen, instant for turns that load already settled (the fold class only
@@ -72,11 +160,15 @@ export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
) : null}
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
<div className="flex flex-col gap-2 pt-1">
{item.items.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
{item.settled ? (
<div className="flex flex-col gap-2 pt-1">
{item.items.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
) : (
<LiveTurnActivity items={item.items} renderChild={renderChild} />
)}
</div>
</div>
</div>