import { useRef, useState } from "react"; import { Brain } from "lucide-react"; import { MarkdownBody } from "@/components/MarkdownBody"; import { cn } from "@/lib/utils"; import type { TaskChatItem, TaskChatMessageItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model"; import { TaskChatBubble } from "./TaskChatBubble"; import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill"; import { TaskChatLiveTail } from "./TaskChatLiveTail"; import { paperclipRunnerHistoryItems } from "./transcript-adapter"; import { toolTaxonomy } from "./tool-taxonomy"; function lastOf(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; if (predicate(item)) return item; } return undefined; } function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) { const activity = lastOf( items, (item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool", ); if (!activity || activity.kind === "thinking") { return (
Thinking
); } const taxonomy = toolTaxonomy(activity.rawName ?? activity.name); const Icon = taxonomy.icon; const active = activity.status === "pending" || activity.status === "in_progress"; return (
{taxonomy.verbLabel} {activity.target ? {activity.target} : null}
); } export function TaskChatRunnerTurn({ runId, items, status, startedAtMs, finishedAtMs, toolSummary, }: { /** Stable identity used to clear replay-latched final text for the next turn. */ runId?: string | null; items: readonly TaskChatItem[]; status: string; startedAtMs: number | null; finishedAtMs?: number | null; toolSummary: string | null; }) { const [open, setOpen] = useState(false); const historyItems = paperclipRunnerHistoryItems(items); const progress = lastOf(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial)); const observedFinal = lastOf(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final"); // A reconnect/replay can briefly rebuild the transcript without the final // item (or with an earlier, shorter prefix). Once durable answer text is on // screen it must be monotonic for the lifetime of this mounted turn. const finalRef = useRef<{ runId?: string | null; item?: TaskChatMessageItem }>({ runId }); if (finalRef.current.runId !== runId) { finalRef.current = { runId }; } if (observedFinal && (!finalRef.current.item || observedFinal.text.length >= finalRef.current.item.text.length)) { finalRef.current.item = observedFinal; } const final = finalRef.current.item; if (status === "queued") { return (
); } return (
setOpen((value) => !value)} />
{!open ? (
{progress ? (
{progress.text}
) : null} {!final ? : null}
) : null} {final ? (
) : null}
); }