import { useId, useState } from "react"; import { CheckCircle2, ChevronDown, Circle, CircleAlert, LoaderCircle, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { TaskChatItem, TaskChatProtocolStep, TaskChatProviderActivityItem, TaskChatWorkspaceChangeItem, } from "./task-chat-model"; /** * Active-turn status docked above the composer. * * The plan is the provider-neutral `plan.updated` checklist (Claude task * tools, Codex `turn/plan/updated`, Cursor todos, any ACP `plan` update). * Visibility rules live here so every harness gets the same behavior: * * - Hidden when no plan steps and no workspace changes exist, and hidden once * the run is terminal (the caller passes `null`; the turn transcript keeps * the plan record). * - Single-step plans render as a header only: a one-item list adds no * information beyond the header. * - Expanded by default while work remains; auto-collapses once every step is * done unless the viewer explicitly reopened it. * - Forced to a header (`compact`) while a question or approval needs the * viewer, so the decision stays the focus. * - Long plans show a window around the current step with "earlier" and * "more" rows that reveal every step. The list always scrolls within a * bounded height so it never covers the conversation. */ export type TaskChatTurnPlanSegment = { kind: "plan"; steps: TaskChatProtocolStep[]; currentStepIndex: number; complete: boolean; }; export type TaskChatTurnStatusSegment = | TaskChatTurnPlanSegment | { kind: "workspace_diff"; files: number; additions: number | null; deletions: number | null; }; export interface TaskChatTurnStatusModel { segments: TaskChatTurnStatusSegment[]; } type WorkspaceSegment = Extract; 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 taskChatTurnPlanSegment(steps: TaskChatProtocolStep[]): TaskChatTurnPlanSegment { return { kind: "plan", steps, currentStepIndex: currentStepIndex(steps), complete: steps.length > 0 && steps.every((step) => step.status === "completed"), }; } export function taskChatTurnStatusModel(items: readonly TaskChatItem[]): TaskChatTurnStatusModel | null { const segments: TaskChatTurnStatusSegment[] = []; const plan = latestPlan(items); if (plan && plan.steps.length > 0) segments.push(taskChatTurnPlanSegment(plan.steps)); 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; } /** Plans up to this length always list every step. */ export const PLAN_FULL_LIST_LIMIT = 7; const PLAN_WINDOW_SIZE = 5; export type TaskChatTurnPlanRow = | { kind: "step"; step: TaskChatProtocolStep; index: number; current: boolean } | { kind: "earlier"; count: number; allCompleted: boolean } | { kind: "more"; count: number }; /** Rows to render: every step for short plans, else a window around the current step. */ export function taskChatTurnPlanRows(plan: TaskChatTurnPlanSegment, showAll: boolean): TaskChatTurnPlanRow[] { const toRow = (step: TaskChatProtocolStep, index: number): TaskChatTurnPlanRow => ({ kind: "step", step, index, current: !plan.complete && index === plan.currentStepIndex, }); if (showAll || plan.steps.length <= PLAN_FULL_LIST_LIMIT) return plan.steps.map(toRow); const start = Math.max(0, Math.min(plan.currentStepIndex - 1, plan.steps.length - PLAN_WINDOW_SIZE)); const end = Math.min(plan.steps.length, start + PLAN_WINDOW_SIZE); const rows: TaskChatTurnPlanRow[] = []; if (start > 0) { rows.push({ kind: "earlier", count: start, allCompleted: plan.steps.slice(0, start).every((step) => step.status === "completed"), }); } for (let index = start; index < end; index += 1) rows.push(toRow(plan.steps[index]!, index)); if (end < plan.steps.length) rows.push({ kind: "more", count: plan.steps.length - end }); return rows; } function isProblem(step: TaskChatProtocolStep | undefined): boolean { return step?.status === "blocked" || step?.status === "failed"; } export function taskChatTurnPlanHeadline(plan: TaskChatTurnPlanSegment): string { const step = plan.steps[plan.currentStepIndex]; if (plan.complete) return plan.steps.length === 1 ? "Step done" : `All ${plan.steps.length} steps done`; if (!step) return "Plan"; if (isProblem(step)) return `Blocked: ${step.label}`; if (step.status === "pending") return `Up next: ${step.label}`; return step.label; } function planHeaderIcon(plan: TaskChatTurnPlanSegment) { const status = plan.steps[plan.currentStepIndex]?.status; if (plan.complete) return ; if (status === "blocked" || status === "failed") { return ; } if (status === "pending") return ; return ; } function checklistIcon(step: TaskChatProtocolStep) { if (step.status === "completed") { return ; } if (step.status === "in_progress") { return ; } if (isProblem(step)) { return ; } return ; } const STEP_STATUS_LABEL: Record = { completed: "done", in_progress: "in progress", pending: "not started", blocked: "blocked", failed: "failed", }; function fileCountLabel(files: number): string { return `${files} ${files === 1 ? "file" : "files"} changed`; } function WorkspaceStats({ workspace, compact }: { workspace: WorkspaceSegment; compact?: boolean }) { return ( {fileCountLabel(workspace.files)} {workspace.additions != null ? ( +{workspace.additions} ) : null} {workspace.deletions != null ? ( −{workspace.deletions} ) : null} ); } 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 ROW_BUTTON_CLASS_NAME = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; function PlanRow({ row, onShowAll }: { row: TaskChatTurnPlanRow; onShowAll: () => void }) { if (row.kind === "earlier") { return (
  • ); } if (row.kind === "more") { return (
  • ); } const { step, current } = row; return (
  • {checklistIcon(step)} {step.label} ({STEP_STATUS_LABEL[step.status]})
  • ); } export interface TaskChatTurnPlanProps { plan: TaskChatTurnPlanSegment; workspace?: WorkspaceSegment | null; /** Start collapsed (used on mobile, where dock height is scarce). */ defaultCollapsed?: boolean; /** Force a header-only presentation while the viewer must answer something. */ compact?: boolean; } export function TaskChatTurnPlan({ plan, workspace = null, defaultCollapsed = false, compact = false }: TaskChatTurnPlanProps) { const listId = useId(); const [expandedOverride, setExpandedOverride] = useState(null); const [showAll, setShowAll] = useState(false); const completed = plan.steps.filter((step) => step.status === "completed").length; const total = plan.steps.length; const listable = total > 1; const automaticExpanded = !defaultCollapsed && !plan.complete; const expanded = listable && !compact && (expandedOverride ?? automaticExpanded); const headline = taskChatTurnPlanHeadline(plan); const rows = taskChatTurnPlanRows(plan, showAll); const problem = isProblem(plan.steps[plan.currentStepIndex]); const headerContent = ( <> {planHeaderIcon(plan)} {headline} {workspace ? : null} {completed}/{total} {completed} of {total} steps done ); return (
    {listable && !compact ? ( ) : (
    {headerContent}
    )}
    0 ? (completed / total) * 100 : 0}%` }} />
    {expanded ? (
      {rows.map((row) => ( setShowAll(true)} /> ))} {showAll && total > PLAN_FULL_LIST_LIMIT ? (
    1. ) : null}
    ) : null}
    ); } export interface TaskChatTurnStatusIslandProps { model: TaskChatTurnStatusModel; defaultCollapsed?: boolean; compact?: boolean; } export function TaskChatTurnStatusIsland({ model, defaultCollapsed, compact }: TaskChatTurnStatusIslandProps) { const plan = model.segments.find((segment): segment is TaskChatTurnPlanSegment => segment.kind === "plan"); const workspace = model.segments.find((segment): segment is WorkspaceSegment => segment.kind === "workspace_diff") ?? null; if (plan) { return ; } if (!workspace) return null; return (
    ); }