From edb2812235b4e20e9320d1acd632280f1285dc99 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sat, 1 Aug 2026 22:06:23 -0700 Subject: [PATCH] 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 --- .../task-chat/TaskChatTurn.test.tsx | 94 +++++++++++++++- ui/src/components/task-chat/TaskChatTurn.tsx | 104 +++++++++++++++++- 2 files changed, 190 insertions(+), 8 deletions(-) diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index 2377d298452..10ad5b2155e 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -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('[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(); + }); }); diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index f871c0b1694..277b936224e 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -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 { + const eligible = items.filter(isActivityRow).length - LIVE_TURN_VISIBLE_ACTIVITY; + const folded = new Set(); + 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 ( +
+ {foldedCount > 0 ? ( + + ) : null} + {items.map((child, index) => { + const rowFolded = !stepsOpen && foldedIds.has(child.id); + return ( +
+
+
{renderChild(child)}
+
+
+ ); + })} +
+ ); +} + /** * 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}
-
- {item.items.map((child) => ( -
{renderChild(child)}
- ))} -
+ {item.settled ? ( +
+ {item.items.map((child) => ( +
{renderChild(child)}
+ ))} +
+ ) : ( + + )}