import { useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { Check, ChevronRight, X } from "lucide-react"; import { MarkdownBody } from "@/components/MarkdownBody"; import type { TaskChatTurnItem, TaskChatTurnChildItem, } from "./task-chat-model"; import { TaskChatStatusPill } from "./TaskChatStatusPill"; import { TaskChatAgentIdentity } from "./TaskChatBubble"; interface TaskChatTurnProps { item: TaskChatTurnItem; renderChild: (child: TaskChatTurnChildItem) => ReactNode; /** * When the settled turn is attached to its reply bubble (round 9), the * bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" — * always visible, in the slot the hover-only timestamp used to occupy. */ timestampPrefix?: string; /** * Content rendered on the header row (PAP-413: the copy/👍/👎 action * cluster). The inspection caret and timestamp stay left; actions sit at the * stable right edge, outside the expandable fold. */ leading?: ReactNode; } /** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */ export function turnSummaryMetrics( summary: TaskChatTurnItem["summary"], ): string { const parts: string[] = []; if (summary.durationLabel) parts.push(summary.durationLabel); if (summary.toolCount > 0) parts.push( `${summary.toolCount} tool${summary.toolCount === 1 ? "" : "s"}`, ); if (summary.added > 0 || summary.removed > 0) parts.push(`+${summary.added} −${summary.removed}`); if (summary.tokensLabel) parts.push(summary.tokensLabel); return parts.join(" · "); } /** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string { const metrics = turnSummaryMetrics(summary); const label = summary.failed ? "Stopped" : "Worked"; return metrics ? `${label} · ${metrics}` : label; } /** * One agent turn's activity behind a single expandable header row. Paperclip * Runner turns opt into the chronological-timeline branch below instead. * * 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, timestampPrefix, leading, }: TaskChatTurnProps) { const streamlined = useStreamlinedTaskChatPresentation(); const parentRow = !item.settled && item.liveStatus != null; // The new Paperclip Runner task surface owns one durable chronological // timeline. The Worked/Stopped row is its stable header, so it stays directly // below the preceding human bubble and above commentary, activity phases, // request receipts, and plan artifacts. The classic task interface continues // to use the run-wide fold below. if (item.standaloneHeader) { return (
{item.agentName || !item.historical ?
{item.agentName ? ( ) : null} {!item.historical ? {item.continuedAfterSteering ? "Continued after steering · " : ""} {item.summary.durationLabel ? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}` : item.summary.failed ? "Stopped" : "Worked"} : null}
: null} {item.items.length > 0 ? (
{item.items.map((child) => (
{renderChild(child)}
))}
) : null} {item.finalResponse ? (
{item.finalResponse.text}
) : null}
); } const persistentItems = item.settled ? item.items.filter( (child) => child.kind === "protocol" && child.surface === "runtime_request", ) : []; const persistentItemIds = new Set(persistentItems.map((child) => child.id)); const foldedItems = persistentItems.length > 0 ? item.items.filter((child) => !persistentItemIds.has(child.id)) : item.items; // 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, ); // Historical folds can contain thousands of tool/reasoning rows. Mount them // on first inspection, then retain them for closing motion and child state. const [historyMounted, setHistoryMounted] = useState(open); if (open && !historyMounted) setHistoryMounted(true); const [prevSettled, setPrevSettled] = useState(item.settled); const [wasParentRow, setWasParentRow] = useState(parentRow); // 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 && !wasParentRow) setOpen(false); } const expandable = foldedItems.length > 0; const folded = (item.settled || parentRow) && !open; const SummaryIcon = item.summary.failed ? X : Check; const showSettledHeader = item.settled && (!item.historical || expandable); const header = showSettledHeader ? ( ) : parentRow ? ( // The pill renders the expand button itself, wrapped around only the // gerund status line — the interstitial row above stays outside the // hover/click target (PAP-376). Without activity there is no // chevron/button. setOpen((o) => !o) : undefined} /> ) : null; return (
{leading ? ( streamlined ? (
{header}
{leading}
) : (
{leading} {header}
) ) : ( header )}
{historyMounted && foldedItems.map((child) => (
{renderChild(child)}
))}
{persistentItems.length > 0 ? (
{persistentItems.map((child) => (
{renderChild(child)}
))}
) : null}
); }