mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): task-chat live turn collapses to one expandable parent row owning all activity
While a run streams, the status line (whimsy/taxonomy gerund + elapsed + tokens) is now the live turn's single visible row: TaskChatTurnItem gains liveStatus, TaskChatTurn renders it as an expandable header (chevron, same grammar as tool rows / the settled Worked line), and expanding nests the chronological tool/thinking history via the existing .tc-turn-fold motion with live append while open. On settle the header morphs in place into the 'Worked · …' summary, preserving expand state. Only tool/thinking/usage rows nest (isNestableLiveChild); mid-run agent text, markers and interaction cards stay in the thread. Retires the liveTurnFoldedIds window + tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
61387b20e6
commit
76770ace54
9 files changed
+268
-221
No files matched your search
@@ -5,6 +5,7 @@ import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapte
|
||||
import {
|
||||
buildTurnSummary,
|
||||
deriveRunStatusLabel,
|
||||
isNestableLiveChild,
|
||||
isTerminalRunStatus,
|
||||
transcriptToTaskChatItems,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
@@ -45,13 +46,16 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
* pill.
|
||||
*
|
||||
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
|
||||
* an unsettled (expanded) turn; when it terminates the same turn id flips
|
||||
* settled, so TaskChatTurn plays the ~--motion-turn-fold collapse down to the
|
||||
* one-line "✓ Worked · …" summary (runs already terminal at mount collapse
|
||||
* instantly). Settled turns interleave after the run's last comment
|
||||
* (comment.runId linkage) — the agent's reply bubble above, the folded activity
|
||||
* summary below, so the live "Running…" pill reads as being replaced by the
|
||||
* summary in place. flag-OFF remains byte-for-byte IssueChatThread.
|
||||
* ONE expandable parent row — its status line (whimsy gerund or in-flight
|
||||
* tool state + elapsed + tokens) is the turn's single visible line, with the
|
||||
* chronological tool/thinking activity nested behind an expand (PAP-354).
|
||||
* Mid-run agent text stays outside as bubbles. When the run terminates the
|
||||
* same turn id flips settled, so the header morphs in place into the one-line
|
||||
* "✓ Worked · …" summary (expand state preserved; runs already terminal at
|
||||
* mount collapse instantly). Settled turns interleave after the run's last
|
||||
* comment (comment.runId linkage) — the agent's reply bubble above, the folded
|
||||
* activity summary below, in the slot the live parent row occupied. flag-OFF
|
||||
* remains byte-for-byte IssueChatThread.
|
||||
*/
|
||||
export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const {
|
||||
@@ -296,33 +300,40 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
|
||||
if (liveRun) {
|
||||
const entries = transcriptByRun.get(liveRun.id) ?? [];
|
||||
const children = transcriptToTaskChatItems(entries, {
|
||||
const parsed = transcriptToTaskChatItems(entries, {
|
||||
runId: liveRun.id,
|
||||
agentName: liveRun.agentName,
|
||||
running: true,
|
||||
}).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn");
|
||||
if (children.length > 0) {
|
||||
out.push({
|
||||
id: `${liveRun.id}:turn`,
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
items: children,
|
||||
summary: buildTurnSummary(entries),
|
||||
});
|
||||
});
|
||||
// Parent-row model (PAP-354): the run's status line IS the live turn —
|
||||
// one expandable row owning the activity. Only tool/thinking/usage rows
|
||||
// nest inside it; mid-run agent text stays in the thread as bubbles
|
||||
// above, so the parent row sits last — the slot its settled summary
|
||||
// takes over in place.
|
||||
for (const it of parsed) {
|
||||
if (it.kind === "message") out.push(it);
|
||||
}
|
||||
const children = parsed.filter(isNestableLiveChild);
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined }
|
||||
: deriveRunStatusLabel(entries);
|
||||
out.push({
|
||||
id: `${liveRun.id}:status`,
|
||||
kind: "status",
|
||||
status: "running",
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
id: `${liveRun.id}:turn`,
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
items: children,
|
||||
summary: buildTurnSummary(entries),
|
||||
liveStatus: {
|
||||
id: `${liveRun.id}:status`,
|
||||
kind: "status",
|
||||
status: "running",
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
import { statusLabelIcon, toolTaxonomy } from "./tool-taxonomy";
|
||||
import { isGenericStatusLabel, whimsyWord } from "./status-whimsy";
|
||||
@@ -43,6 +43,12 @@ const CONFIG = {
|
||||
interface TaskChatStatusPillProps {
|
||||
item: TaskChatStatusItem;
|
||||
onApprovalDecision?: (optionId: string) => void;
|
||||
/**
|
||||
* When set, the live line is the header of an expandable parent row
|
||||
* (TaskChatTurn): render a trailing chevron reflecting the open state —
|
||||
* the same expand grammar as tool rows and the settled "Worked ·" line.
|
||||
*/
|
||||
chevronOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +58,7 @@ interface TaskChatStatusPillProps {
|
||||
* card chrome — awaiting-approval elevates with an attention pulse and
|
||||
* exposes the ACP permission options as actions.
|
||||
*/
|
||||
export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusPillProps) {
|
||||
export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: TaskChatStatusPillProps) {
|
||||
const { Icon, spin, tone } = CONFIG[item.status];
|
||||
const awaiting = item.status === "awaiting_approval";
|
||||
const live = item.status === "running" || item.status === "working";
|
||||
@@ -95,6 +101,12 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{chevronOpen !== undefined ? (
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,13 @@ export function TaskChatThreadView({
|
||||
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
|
||||
if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
|
||||
if (it.kind === "turn") {
|
||||
return it.settled ? 1 : it.items.reduce((n, child) => n + signatureOf(child), it.items.length);
|
||||
if (it.settled) return 1;
|
||||
// The live parent row's header changes (gerund ↔ tool-state flashes)
|
||||
// count too, so the collapsed single-line turn still advances the key.
|
||||
const headerSig = it.liveStatus
|
||||
? it.liveStatus.label.length + (it.liveStatus.detail?.length ?? 0)
|
||||
: 0;
|
||||
return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig);
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TaskChatTurn, liveTurnFoldedIds, turnSummaryText } from "./TaskChatTurn";
|
||||
import { TaskChatTurn, turnSummaryText } from "./TaskChatTurn";
|
||||
import { buildTurnSummary } from "./transcript-adapter";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import type { TaskChatToolStatus, TaskChatTurnChildItem, TaskChatTurnItem } from "./task-chat-model";
|
||||
import type {
|
||||
TaskChatStatusItem,
|
||||
TaskChatToolStatus,
|
||||
TaskChatTurnChildItem,
|
||||
TaskChatTurnItem,
|
||||
} from "./task-chat-model";
|
||||
|
||||
const tool = (id: string, status: TaskChatToolStatus = "completed"): TaskChatTurnChildItem => ({
|
||||
id,
|
||||
@@ -15,8 +20,6 @@ const tool = (id: string, status: TaskChatToolStatus = "completed"): TaskChatTur
|
||||
status,
|
||||
});
|
||||
|
||||
const thinking = (id: string): TaskChatTurnChildItem => ({ id, kind: "thinking", lines: ["hm"] });
|
||||
|
||||
const SETTLED: TaskChatTurnItem = {
|
||||
id: "t1",
|
||||
kind: "turn",
|
||||
@@ -25,6 +28,26 @@ const SETTLED: TaskChatTurnItem = {
|
||||
items: [{ id: "c1", kind: "tool", name: "read auth.ts", status: "completed" }],
|
||||
};
|
||||
|
||||
// Non-generic label so the header shows it verbatim (no whimsy substitution).
|
||||
const LIVE_STATUS: TaskChatStatusItem = {
|
||||
id: "t1:status",
|
||||
kind: "status",
|
||||
status: "working",
|
||||
label: "Editing files",
|
||||
detail: "Edit · server/src/routes/auth.ts",
|
||||
toolName: "Edit",
|
||||
};
|
||||
|
||||
const parentRowTurn = (
|
||||
children: TaskChatTurnChildItem[],
|
||||
status: TaskChatStatusItem = LIVE_STATUS,
|
||||
): TaskChatTurnItem => ({
|
||||
...SETTLED,
|
||||
settled: false,
|
||||
liveStatus: status,
|
||||
items: children,
|
||||
});
|
||||
|
||||
describe("turnSummaryText", () => {
|
||||
it("joins the known parts with dots", () => {
|
||||
expect(turnSummaryText(SETTLED.summary)).toBe("Worked · 38s · 3 tools · +34 −3 · 12.3k tokens");
|
||||
@@ -65,45 +88,6 @@ 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;
|
||||
@@ -128,6 +112,8 @@ describe("TaskChatTurn", () => {
|
||||
const fold = () => container.querySelector(".tc-turn-fold");
|
||||
const summaryBtn = () =>
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]');
|
||||
const liveHeaderBtn = () =>
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-live-turn"]');
|
||||
|
||||
it("renders a settled turn folded with its summary line", () => {
|
||||
renderTurn(SETTLED);
|
||||
@@ -143,57 +129,78 @@ describe("TaskChatTurn", () => {
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders a live turn expanded with no summary line", () => {
|
||||
it("a headerless live turn renders expanded with no summary line", () => {
|
||||
renderTurn({ ...SETTLED, settled: false });
|
||||
expect(summaryBtn()).toBeNull();
|
||||
expect(liveHeaderBtn()).toBeNull();
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
|
||||
it("folds when the item settles while mounted", () => {
|
||||
it("a headerless live turn folds when it settles while mounted", () => {
|
||||
renderTurn({ ...SETTLED, settled: false });
|
||||
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("collapses a live parent-row turn to its single status line", () => {
|
||||
renderTurn(parentRowTurn([tool("a"), tool("b"), tool("c", "in_progress")]));
|
||||
const header = liveHeaderBtn();
|
||||
expect(header?.textContent).toContain("Editing files…");
|
||||
expect(header?.textContent).toContain("Edit · server/src/routes/auth.ts");
|
||||
expect(header?.getAttribute("aria-expanded")).toBe("false");
|
||||
// All activity is folded behind it — no rows visible, no summary line.
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
expect(summaryBtn()).toBeNull();
|
||||
});
|
||||
|
||||
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("flashes the in-flight tool state through the header", () => {
|
||||
renderTurn(parentRowTurn([tool("a")]));
|
||||
expect(liveHeaderBtn()?.textContent).toContain("Editing files…");
|
||||
renderTurn(
|
||||
parentRowTurn([tool("a")], { ...LIVE_STATUS, label: "Reading a file", detail: "Read · auth.ts", toolName: "Read" }),
|
||||
);
|
||||
expect(liveHeaderBtn()?.textContent).toContain("Reading a file…");
|
||||
expect(liveHeaderBtn()?.textContent).toContain("Read · auth.ts");
|
||||
});
|
||||
|
||||
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("expands to the nested chronological history on click", () => {
|
||||
renderTurn(parentRowTurn([tool("a"), tool("b")]));
|
||||
flushSync(() => liveHeaderBtn()!.click());
|
||||
expect(liveHeaderBtn()?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
expect(fold()?.textContent).toContain("a");
|
||||
expect(fold()?.textContent).toContain("b");
|
||||
});
|
||||
|
||||
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("appends new rows live while expanded and re-collapses on click", () => {
|
||||
renderTurn(parentRowTurn([tool("a")]));
|
||||
flushSync(() => liveHeaderBtn()!.click());
|
||||
renderTurn(parentRowTurn([tool("a"), tool("b", "in_progress")]));
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
expect(fold()?.textContent).toContain("b");
|
||||
flushSync(() => liveHeaderBtn()!.click());
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
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();
|
||||
it("renders the status line without an expand affordance while there is no activity", () => {
|
||||
renderTurn(parentRowTurn([]));
|
||||
expect(liveHeaderBtn()).toBeNull();
|
||||
expect(container.textContent).toContain("Editing files…");
|
||||
});
|
||||
|
||||
it("morphs a collapsed parent row into the settled summary in place", () => {
|
||||
renderTurn(parentRowTurn([tool("a")]));
|
||||
renderTurn({ ...SETTLED, animateFold: true, items: [tool("a")] });
|
||||
expect(liveHeaderBtn()).toBeNull();
|
||||
expect(summaryBtn()?.textContent).toContain("Worked");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps the expand state across the settle morph", () => {
|
||||
renderTurn(parentRowTurn([tool("a")]));
|
||||
flushSync(() => liveHeaderBtn()!.click());
|
||||
renderTurn({ ...SETTLED, animateFold: true, items: [tool("a")] });
|
||||
expect(summaryBtn()?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -2,46 +2,13 @@ import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronRight, X } from "lucide-react";
|
||||
import type { TaskChatTurnItem, TaskChatTurnChildItem } from "./task-chat-model";
|
||||
import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
|
||||
interface TaskChatTurnProps {
|
||||
item: TaskChatTurnItem;
|
||||
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[] = [];
|
||||
@@ -60,84 +27,44 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One agent turn's activity behind a single expandable header row.
|
||||
*
|
||||
* 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
|
||||
* transitions on state CHANGE, so first paint in the folded state never
|
||||
* animates). Clicking the summary folds/unfolds with the same motion.
|
||||
* prefers-reduced-motion zeroes the transition in CSS.
|
||||
* While the turn is live and carries `liveStatus`, that status line (whimsy or
|
||||
* taxonomy gerund + elapsed + tokens) IS the turn: one parent row, collapsed
|
||||
* by default, owning all activity (PAP-354). Expanding nests the chronological
|
||||
* tool/thinking history underneath — each row keeping its own expand — and new
|
||||
* rows append live while open. On settle the header morphs in place into the
|
||||
* "✓ Worked · …" summary, preserving the expand state, so the running line
|
||||
* reads as being replaced by the summary in the same slot.
|
||||
*
|
||||
* Both directions of the fold reuse the .tc-turn-fold grid-rows motion
|
||||
* (--motion-turn-fold, zeroed under prefers-reduced-motion). The fold class
|
||||
* only transitions on state CHANGE, so first paint in the folded state (e.g. a
|
||||
* turn that loads already settled) never animates. A live turn without
|
||||
* `liveStatus` (harness fixtures) renders its children expanded with no header
|
||||
* and folds when it settles.
|
||||
*/
|
||||
export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
|
||||
const [open, setOpen] = useState(!item.settled);
|
||||
const parentRow = !item.settled && item.liveStatus != null;
|
||||
// Parent-row live turns and settled turns start as their one-line header;
|
||||
// only the headerless legacy live turn starts expanded.
|
||||
const [open, setOpen] = useState(() => !item.settled && item.liveStatus == null);
|
||||
const [prevSettled, setPrevSettled] = useState(item.settled);
|
||||
const [wasParentRow, setWasParentRow] = useState(parentRow);
|
||||
|
||||
// Live → settled transition observed while mounted: fold (animated via CSS
|
||||
// unless reduced motion). Adjusted during render (not in an effect) so the
|
||||
// fold state lands in the same commit — no unfolded flash. Turns mounted
|
||||
// already-settled start folded via the useState initializers.
|
||||
// Render-adjusted state (not effects) so transitions land in the same
|
||||
// commit — no flash of the wrong fold state.
|
||||
if (parentRow && !wasParentRow) setWasParentRow(true);
|
||||
// Live → settled while mounted: a parent-row turn keeps its expand state
|
||||
// (the header morphs in place); a headerless expanded turn folds (animated
|
||||
// via CSS unless reduced motion).
|
||||
if (item.settled !== prevSettled) {
|
||||
setPrevSettled(item.settled);
|
||||
if (item.settled) setOpen(false);
|
||||
if (item.settled && !wasParentRow) setOpen(false);
|
||||
}
|
||||
|
||||
const folded = item.settled && !open;
|
||||
const expandable = item.items.length > 0;
|
||||
const folded = (item.settled || parentRow) && !open;
|
||||
const SummaryIcon = item.summary.failed ? X : Check;
|
||||
|
||||
return (
|
||||
@@ -157,18 +84,29 @@ export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
|
||||
) : null}
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
|
||||
</button>
|
||||
) : parentRow ? (
|
||||
expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="-mx-1.5 block w-full rounded-sm px-1.5 text-left transition-colors hover:bg-muted/60"
|
||||
data-testid="task-chat-live-turn"
|
||||
>
|
||||
<TaskChatStatusPill item={item.liveStatus!} chevronOpen={open} />
|
||||
</button>
|
||||
) : (
|
||||
// Nothing to expand yet: the status line alone, no chevron/button.
|
||||
<TaskChatStatusPill item={item.liveStatus!} />
|
||||
)
|
||||
) : null}
|
||||
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
|
||||
<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 className="flex flex-col gap-2 pt-1">
|
||||
{item.items.map((child) => (
|
||||
<div key={child.id}>{renderChild(child)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,17 +96,42 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
],
|
||||
};
|
||||
case "working":
|
||||
// Parent-row live turn (PAP-354): the tool-state line owns the activity;
|
||||
// expanding nests the chronological history underneath.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-working",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 2, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
|
||||
items: [
|
||||
{ id: "w-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ id: "w-edit", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "in_progress" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "running":
|
||||
// Generic label → the parent row header rotates whimsical gerunds.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } },
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-running",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } },
|
||||
items: [
|
||||
{ id: "r-think", kind: "thinking", lines: ["The login route is in server/src/routes/auth.ts.", "Check for an existing limiter before writing one."] },
|
||||
{ id: "r-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "completed":
|
||||
|
||||
@@ -178,15 +178,23 @@ export type TaskChatTurnChildItem =
|
||||
/**
|
||||
* One agent turn's activity (thinking/tools/diffs) grouped so a finished turn
|
||||
* can fold into a one-line expandable summary ("✓ Worked · 38s · 3 tools ·
|
||||
* +34 −3"). While `settled` is false the children render fully interleaved;
|
||||
* on the live → settled transition the fold animates (~ --motion-turn-fold),
|
||||
* while turns that load already-settled collapse instantly.
|
||||
* +34 −3"). A live turn carrying `liveStatus` renders as a single expandable
|
||||
* parent row headed by that status line (gerund/tool-state + elapsed +
|
||||
* tokens); expanding nests the chronological activity underneath, and on
|
||||
* settle the header morphs in place into the summary. The live → settled
|
||||
* fold animates (~ --motion-turn-fold), while turns that load already-settled
|
||||
* collapse instantly.
|
||||
*/
|
||||
export interface TaskChatTurnItem {
|
||||
id: string;
|
||||
kind: "turn";
|
||||
items: TaskChatTurnChildItem[];
|
||||
settled: boolean;
|
||||
/**
|
||||
* The in-flight run's status line, hoisted to be THE turn's single visible
|
||||
* row while collapsed (PAP-354 parent-row model). Absent once settled.
|
||||
*/
|
||||
liveStatus?: TaskChatStatusItem;
|
||||
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
|
||||
animateFold?: boolean;
|
||||
summary: {
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
buildTurnSummary,
|
||||
deriveRunStatusLabel,
|
||||
isNestableLiveChild,
|
||||
toolDisplayName,
|
||||
transcriptToTaskChatItems,
|
||||
} from "./transcript-adapter";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
const TS = "2026-07-31T12:00:00.000Z";
|
||||
|
||||
@@ -208,3 +210,30 @@ describe("deriveRunStatusLabel with generic tail updates", () => {
|
||||
expect(status.detail).toBe("Terminal · pnpm test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNestableLiveChild", () => {
|
||||
const items: TaskChatItem[] = [
|
||||
{ id: "t", kind: "tool", name: "Read", status: "completed" },
|
||||
{ id: "th", kind: "thinking", lines: ["hm"] },
|
||||
{ id: "u", kind: "usage", usage: { used: 1, size: 2 } },
|
||||
{ id: "m", kind: "message", author: "agent", text: "self-talk" },
|
||||
{ id: "mk", kind: "marker", variant: "session_start", label: "Session started" },
|
||||
{ id: "s", kind: "status", status: "running", label: "Running" },
|
||||
{ id: "i", kind: "interaction", interaction: {} as never },
|
||||
];
|
||||
|
||||
it("nests only tool, thinking and usage rows inside the live parent row", () => {
|
||||
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
|
||||
"tool",
|
||||
"thinking",
|
||||
"usage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps messages, markers, statuses and interactions in the thread", () => {
|
||||
for (const kind of ["message", "marker", "status", "interaction"]) {
|
||||
const item = items.find((it) => it.kind === kind)!;
|
||||
expect(isNestableLiveChild(item)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TaskChatDiff,
|
||||
TaskChatItem,
|
||||
TaskChatToolItem,
|
||||
TaskChatTurnChildItem,
|
||||
TaskChatTurnItem,
|
||||
} from "./task-chat-model";
|
||||
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
|
||||
@@ -26,6 +27,16 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
|
||||
return status != null && TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live parent row's nesting rule (PAP-354): only activity rows — tool
|
||||
* calls, thinking blocks, usage readouts — nest inside the expandable live
|
||||
* turn. Messages (mid-run self-talk, the streaming reply), markers, statuses
|
||||
* and interaction cards stay in the thread outside it.
|
||||
*/
|
||||
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
|
||||
return item.kind === "tool" || item.kind === "thinking" || item.kind === "usage";
|
||||
}
|
||||
|
||||
function diffKind(changeType: string): "add" | "remove" | "context" {
|
||||
if (changeType === "add") return "add";
|
||||
if (changeType === "remove") return "remove";
|
||||
|
||||
Reference in new issue
Block a user