diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 62b79b6cef2..d4ae983e400 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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; * 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; diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index eecadaf1b96..5f7baaeb30a 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -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 ) : null} + {chevronOpen !== undefined ? ( + + ) : null} ); } diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 87c27f1f228..99cb23fe08b 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -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; }; diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index 10ad5b2155e..cc7bd899e29 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -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('[data-testid="task-chat-turn-summary"]'); + const liveHeaderBtn = () => + container.querySelector('[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('[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"); }); }); diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 277b936224e..05be0179c10 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -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 { - 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[] = []; @@ -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 ( -
- {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. + * 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} + ) : parentRow ? ( + expandable ? ( + + ) : ( + // Nothing to expand yet: the status line alone, no chevron/button. + + ) ) : null}
- {item.settled ? ( -
- {item.items.map((child) => ( -
{renderChild(child)}
- ))} -
- ) : ( - - )} +
+ {item.items.map((child) => ( +
{renderChild(child)}
+ ))} +
diff --git a/ui/src/components/task-chat/task-chat-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index 9d95d638d43..86e247ca5d8 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -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": diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index ad3e4c105e2..1428f5465bc 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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: { diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index a582b066de6..985653ce415 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -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); + } + }); +}); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index b75e00e4fa5..ad0d7ee7afd 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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";