import { useId, useState, type ReactNode } from "react"; import { completedActivitySummary } from "./completed-activity-summary"; import { Brain, ChevronDown, ChevronRight, BookOpen, CirclePause, Gauge, ShieldX, } from "lucide-react"; import { useReducedMotion } from "motion/react"; import { MarkdownBody } from "@/components/MarkdownBody"; import { cn } from "@/lib/utils"; import { useTaskChatExpansion } from "./expansion-state"; import type { TaskChatActivityPhaseItem, TaskChatAutoReview } from "./task-chat-model"; import { AutoApprovedBadge, AutoReviewDetail } from "./TaskChatAutoReview"; import { protocolActivityIsRunning, protocolActivityLabel, protocolActivityPresentation, } from "./task-chat-activity-presentation"; import { ResearchSourceStrip, TaskChatProtocolActivityDetails, WaitCountdown, hasTaskChatProtocolActivityDetails, verificationOutcome, verificationTone, } from "./TaskChatProtocolActivityRow"; import { TaskChatUsageReadout } from "./TaskChatUsageReadout"; import { TaskChatReasoningSummary } from "./TaskChatReasoningSummary"; import { latestReasoningHeadline } from "./reasoning-summary"; import { toolActivityPresentation, type ToolIcon } from "./tool-taxonomy"; import { useTaskChatShimmer } from "./TaskChatShimmer"; import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView"; type Activity = TaskChatActivityPhaseItem["items"][number]; type ToolActivity = Extract; /** Families whose rows read as " ", for example "Ran $ node --test". */ const SUBJECT_FAMILIES = new Set(["terminal", "read", "edit", "grep", "search", "web"]); const READ_RUN_MIN = 3; /** "120ms", "1.2s", "14s", "2m 03s". */ export function formatToolDuration(ms: number): string { if (ms < 1_000) return `${Math.max(0, Math.round(ms))}ms`; if (ms < 10_000) return `${(ms / 1_000).toFixed(1)}s`; const seconds = Math.round(ms / 1_000); if (seconds < 60) return `${seconds}s`; return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; } function toolPresentation(item: ToolActivity) { // Classify by the native name only: a canonical operation is a weak guess // for MCP tools (Claude reports most of them as "execute"). return toolActivityPresentation({ name: item.rawName ?? item.name, target: item.target, }); } /** Only file reads fold; Skill, LSP and task lookups are also "read" family. */ const FILE_READ_TOOLS = new Set(["read", "notebookread", "notebook_read"]); function isReadTool(item: Activity): item is ToolActivity { return item.kind === "tool" && item.status === "completed" && !item.blockedReason && Boolean(item.target?.trim()) && FILE_READ_TOOLS.has((item.rawName ?? item.name).trim().toLowerCase()); } interface ActivityRowPresentation { icon: ToolIcon; label: string; target?: string; mono: boolean; running: boolean; /** Colors the leading icon, e.g. green or red for a verification check. */ iconClassName?: string; /** Short plain-text outcome after the target, e.g. "6 tests passed". */ trailing?: ReactNode; /** A governance row's reason, shown under the row when an action was stopped. */ note?: string; /** Process exit code shown as a chip; null hides it. */ exitCode?: number | null; /** Tool duration shown after the chip; null hides it. */ durationMs?: number | null; /** Why a policy or rule refused the tool. */ blocked?: string; } function providerTrailing(item: Extract): Pick { if (item.surface !== "provider_activity") return {}; if (item.family === "verification") { return { iconClassName: verificationTone(item), trailing: verificationOutcome(item), }; } if (item.family === "wait" && item.status === "running") { return { trailing: }; } return {}; } function presentation(item: Activity, active: boolean): ActivityRowPresentation | null { if (item.kind === "tool") { const tool = toolPresentation(item); const running = active && (item.status === "pending" || item.status === "in_progress"); const label = item.status === "failed" ? tool.failedLabel : item.status === "interrupted" ? tool.interruptedLabel : running ? tool.runningLabel : tool.completedLabel; const terminal = tool.family === "terminal"; const subject = item.target?.trim(); // With a concrete subject, the verb alone reads better: "Read README.md". // A finished command needs no verb at all: the icon and "$" say it ran. // A failure or stop keeps its marker ("Read · failed") unless an exit // chip already says how a command ended. const marker = item.status === "failed" || item.status === "interrupted" ? label.split(" · ").at(1) : undefined; const verb = subject && SUBJECT_FAMILIES.has(tool.family) ? terminal && !running && (item.status === "completed" || item.exitCode != null) ? "" : `${label.split(" ")[0]!}${marker ? ` · ${marker}` : ""}` : label; // Exit 0 is only news for commands; for reads and searches it is noise. const exitCode = item.exitCode ?? null; const shownExit = exitCode === 0 && !terminal ? null : exitCode; if (item.blockedReason) { return { icon: ShieldX, label: "Blocked", target: subject ? (terminal ? `$ ${subject}` : subject) : undefined, mono: true, running: false, blocked: item.blockedReason, }; } return { icon: tool.icon, label: verb, target: subject ? (terminal ? `$ ${subject}` : subject) : undefined, mono: true, running, exitCode: running ? null : shownExit, // Codex reports 0 for calls it did not time; 0ms is never informative. durationMs: running || !item.durationMs ? null : item.durationMs, }; } if (item.kind === "thinking") { const running = active && Boolean(item.streaming); return { icon: Brain, label: running ? "Thinking" : "Thought", target: latestReasoningHeadline(item.lines.join("\n")) ?? item.lines .filter((line) => line.trim()) .at(-1) ?.trim(), mono: false, running, }; } if (item.kind === "protocol") { const protocol = protocolActivityPresentation(item); if (!protocol) return null; return { icon: protocol.icon, label: protocolActivityLabel(item, protocol), target: protocol.detail, mono: true, running: active && protocolActivityIsRunning(item), ...providerTrailing(item), ...(protocol.tone === "blocked" || protocol.tone === "failed" ? { iconClassName: "text-destructive" } : {}), note: protocol.note, }; } if (item.kind === "marker") return { icon: CirclePause, label: item.label, target: item.detail, mono: false, running: false, }; const { used, size, inputTokens, outputTokens, costUsd } = item.usage; const usage = [ size > 0 ? `${used.toLocaleString()}/${size.toLocaleString()} ctx` : undefined, inputTokens != null || outputTokens != null ? `↑${(inputTokens ?? 0).toLocaleString()} ↓${(outputTokens ?? 0).toLocaleString()}` : undefined, costUsd != null ? `$${costUsd.toFixed(4)}` : undefined, ] .filter(Boolean) .join(" · "); return { icon: Gauge, label: item.label ?? "Token usage", target: usage || item.detail, mono: false, running: false, }; } function ActivityContent({ item, active, }: { item: Activity; active: boolean; }) { const row = presentation(item, active); const shimmer = useTaskChatShimmer(item.id); if (!row) return null; const Icon = row.icon; const autoReview = itemAutoReview(item); const exitCode = "exitCode" in row ? row.exitCode : null; const durationMs = "durationMs" in row ? row.durationMs : null; const blocked = "blocked" in row ? row.blocked : undefined; return ( {row.label ? {row.label} : null} {row.target ? ( {row.target} ) : null} {blocked ? ( {blocked} ) : null} {row.trailing ? ( {row.trailing} ) : null} {exitCode != null || durationMs != null ? ( {exitCode != null ? ( exit {exitCode} ) : null} {durationMs != null ? ( {formatToolDuration(durationMs)} ) : null} ) : null} {item.kind === "tool" && item.diff && (item.diff.added > 0 || item.diff.removed > 0) ? ( ) : null} {autoReview ? : null} ); } /** Only a new logical activity moves; token and status updates stay mounted. */ function RollingActivity({ item, active, }: { item: Activity; active: boolean; }) { const reducedMotion = useReducedMotion(); const [frame, setFrame] = useState({ current: item, previous: null as Activity | null, }); if (frame.current.id !== item.id) setFrame({ current: item, previous: reducedMotion ? null : frame.current }); else if (frame.current !== item) setFrame({ ...frame, current: item }); return ( {frame.previous && !reducedMotion ? ( ) : null} ); } function itemAutoReview(item: Activity): TaskChatAutoReview | undefined { if (item.kind === "tool") return item.autoReview; if (item.kind === "protocol" && item.surface === "provider_activity") return item.autoReview; return undefined; } function ActivityDetails({ item }: { item: Activity }) { // Protocol rows render their review inside TaskChatProtocolActivityDetails. const autoReview = item.kind === "tool" ? item.autoReview : undefined; return ( <> {autoReview ? : null} ); } function ActivityDetailsBody({ item }: { item: Activity }) { if (item.kind === "thinking") return ; if (item.kind === "usage") return ; if (item.kind === "protocol") return ; if (item.kind === "marker") return (

{item.detail ?? item.label}

); if (item.diff?.lines?.length) return ( ); return ( <> {item.description ?

{item.description}

: null} {item.target ? (

{item.target}

) : null} {item.blockedReason ?

Blocked: {item.blockedReason}

: null} {item.detail ? (
          {item.detail}
        
) : null} {item.decision ?

Permission {item.decision}

: null} {item.diff ? (

{item.diff.path} · +{item.diff.added} −{item.diff.removed}

) : null} ); } function hasActivityDetails(item: Activity): boolean { if (itemAutoReview(item)) return true; if (item.kind === "protocol") return hasTaskChatProtocolActivityDetails(item); if (item.kind === "thinking") return item.lines.some((line) => line.trim()); if (item.kind === "tool") return Boolean( item.target?.trim() || item.detail?.trim() || item.diff || item.decision || item.blockedReason || item.description, ); if (item.kind === "marker") return Boolean(item.detail?.trim()); return Boolean( item.detail || item.usage.size > 0 || item.usage.inputTokens != null || item.usage.outputTokens != null || item.usage.costUsd != null, ); } function ExpandedActivity({ item, active, }: { item: Activity; active: boolean; }) { const [open, setOpen] = useTaskChatExpansion( `runner-detail:${item.id}`, false, ); const detailId = useId(); const expandable = hasActivityDetails(item); const content = ; const row = presentation(item, active); const note = row?.note; return (
  • {expandable ? ( ) : (
    {content}
    )} {note ? (

    {note}

    ) : null} {!open && item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? ( ) : null} {expandable && open ? (
    ) : null}
  • ); } type HistoryRow = { kind: "single"; item: Activity; index: number } | { kind: "reads"; items: ToolActivity[]; index: number }; /** Fold runs of completed file reads into one row; everything else stays one row per activity. */ export function foldReadRuns(activities: Activity[]): HistoryRow[] { const rows: HistoryRow[] = []; for (let index = 0; index < activities.length; ) { let end = index; while (end < activities.length && isReadTool(activities[end]!)) end += 1; if (end - index >= READ_RUN_MIN) { rows.push({ kind: "reads", items: activities.slice(index, end) as ToolActivity[], index: end - 1 }); index = end; } else { rows.push({ kind: "single", item: activities[index]!, index }); index += 1; } } return rows; } function readRunPreview(items: ToolActivity[]): string { const names = items.flatMap((item) => item.target?.trim() ? [item.target.trim().split("/").at(-1)!] : []); return names.length > 3 ? `${names.slice(0, 3).join(", ")} +${names.length - 3}` : names.join(", "); } function ReadRunActivity({ items }: { items: ToolActivity[] }) { const id = `runner-reads:${items[0]!.id}`; const [open, setOpen] = useTaskChatExpansion(id, false); const listId = useId(); return (
  • {open ? (
      {items.map((item) => ( ))}
    ) : null}
  • ); } /** One rolling activity between commentary messages, with persistent optional history. */ export function TaskChatRunnerActivityGroup({ item, defaultExpanded = false, }: { item: TaskChatActivityPhaseItem; defaultExpanded?: boolean; }) { const [expanded, setExpanded] = useTaskChatExpansion( item.id, defaultExpanded, ); const historyId = useId(); const activities = item.items.filter( (activity) => presentation(activity, false) !== null, ); const latest = activities.at(-1); const summary = completedActivitySummary(activities); const SummaryIcon = summary.icon; const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`; const editTotals = activities.reduce( (sum, activity) => activity.kind === "tool" && activity.diff ? { added: sum.added + activity.diff.added, removed: sum.removed + activity.diff.removed } : sum, { added: 0, removed: 0 }, ); return (
    {item.interstitial ? (
    {item.interstitial.text}
    ) : null} {latest ? (
    {expanded ? (
      {foldReadRuns(activities).map((row) => row.kind === "reads" ? ( ) : ( ), )}
    ) : null}
    ) : null}
    ); }