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:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:37 -07:00
1 parent 61387b20e6
commit 76770ace54
9 files changed
+268 -221

No files matched your search

+35 -24
View File
@@ -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");
});
});
+50 -112
View File
@@ -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":
+11 -3
View File
@@ -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";