import { useEffect, useRef, useState } from "react"; import { CheckCircle2, Circle, CircleAlert, LoaderCircle, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import type { TaskChatItem, TaskChatProtocolStep, TaskChatProviderActivityItem, TaskChatWorkspaceChangeItem, } from "./task-chat-model"; export type TaskChatTurnStatusSegment = | { kind: "plan"; steps: TaskChatProtocolStep[]; currentStepIndex: number; complete: boolean; } | { kind: "workspace_diff"; files: number; additions: number | null; deletions: number | null; }; export interface TaskChatTurnStatusModel { segments: TaskChatTurnStatusSegment[]; } function latestPlan(items: readonly TaskChatItem[]): TaskChatProviderActivityItem | null { let latest: TaskChatProviderActivityItem | null = null; let latestOrder = -1; for (const [index, item] of items.entries()) { if (item.kind !== "protocol" || item.surface !== "provider_activity" || item.family !== "plan") continue; const order = item.transcriptIndex ?? index; if (order >= latestOrder) { latest = item; latestOrder = order; } } return latest; } function latestWorkspaceDiff(items: readonly TaskChatItem[]): TaskChatWorkspaceChangeItem | null { let latest: TaskChatWorkspaceChangeItem | null = null; for (const item of items) { if (item.kind === "protocol" && item.surface === "workspace_change") latest = item; } return latest; } function currentStepIndex(steps: readonly TaskChatProtocolStep[]): number { const inProgress = steps.findIndex((step) => step.status === "in_progress"); if (inProgress >= 0) return inProgress; const blocked = steps.findIndex((step) => step.status === "blocked" || step.status === "failed"); if (blocked >= 0) return blocked; const pending = steps.findIndex((step) => step.status === "pending"); if (pending >= 0) return pending; return Math.max(0, steps.length - 1); } export function taskChatTurnStatusModel(items: readonly TaskChatItem[]): TaskChatTurnStatusModel | null { const segments: TaskChatTurnStatusSegment[] = []; const plan = latestPlan(items); if (plan && plan.steps.length > 0) { segments.push({ kind: "plan", steps: plan.steps, currentStepIndex: currentStepIndex(plan.steps), complete: plan.steps.every((step) => step.status === "completed"), }); } const workspace = latestWorkspaceDiff(items); const files = workspace ? workspace.totals.files || workspace.files.length : 0; if (workspace && files > 0) { segments.push({ kind: "workspace_diff", files, additions: workspace.totals.additions, deletions: workspace.totals.deletions, }); } return segments.length > 0 ? { segments } : null; } function planIcon(plan: Extract) { const status = plan.steps[plan.currentStepIndex]?.status; if (plan.complete) return ; if (status === "blocked" || status === "failed") { return ; } return ; } function checklistIcon(step: TaskChatProtocolStep) { if (step.status === "completed") { return ; } if (step.status === "in_progress") { return ; } if (step.status === "blocked" || step.status === "failed") { return ; } return ; } function fileCountLabel(files: number): string { return `${files} ${files === 1 ? "file" : "files"} changed`; } function IslandBody({ model }: { model: TaskChatTurnStatusModel }) { const plan = model.segments.find( (segment): segment is Extract => segment.kind === "plan", ); const workspace = model.segments.find( (segment): segment is Extract => segment.kind === "workspace_diff", ); return ( {plan ? ( {planIcon(plan)} Step {plan.currentStepIndex + 1} / {plan.steps.length} : {plan.steps[plan.currentStepIndex]?.label} ) : null} {plan && workspace ? · : null} {workspace ? ( {fileCountLabel(workspace.files)} {workspace.additions != null ? ( +{workspace.additions} ) : null} {workspace.deletions != null ? ( −{workspace.deletions} ) : null} ) : null} ); } function islandLabel(model: TaskChatTurnStatusModel): string { const parts: string[] = []; for (const segment of model.segments) { if (segment.kind === "plan") { const step = segment.steps[segment.currentStepIndex]; parts.push(`Step ${segment.currentStepIndex + 1} of ${segment.steps.length}${step ? `: ${step.label}` : ""}`); } else { parts.push(fileCountLabel(segment.files)); if (segment.additions != null) parts.push(`${segment.additions} additions`); if (segment.deletions != null) parts.push(`${segment.deletions} deletions`); } } return parts.join(", "); } const ISLAND_CLASS_NAME = "mx-auto flex min-h-10 max-w-(--sz-turn-status-island) items-center overflow-hidden rounded-full border border-border bg-popover/95 px-4 py-2 text-foreground shadow-sm backdrop-blur"; const HOVER_CLOSE_GRACE_MS = 100; export function TaskChatTurnStatusIsland({ model }: { model: TaskChatTurnStatusModel }) { const plan = model.segments.find( (segment): segment is Extract => segment.kind === "plan", ); const [open, setOpen] = useState(false); const [pinned, setPinned] = useState(false); const closeTimer = useRef | null>(null); useEffect(() => () => { if (closeTimer.current) clearTimeout(closeTimer.current); }, []); const cancelClose = () => { if (closeTimer.current) clearTimeout(closeTimer.current); closeTimer.current = null; }; const scheduleClose = () => { cancelClose(); if (pinned) return; closeTimer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_GRACE_MS); }; if (!plan) { return (
); } return ( { setOpen(nextOpen); if (!nextOpen) setPinned(false); }} > { if (event.pointerType === "mouse") cancelClose(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }} onEscapeKeyDown={() => setPinned(false)} onInteractOutside={() => setPinned(false)} >
    {plan.steps.map((step, index) => (
  1. {checklistIcon(step)} {step.label}
  2. ))}
); }