mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user