import { useMemo, useState } from "react"; import { Lock, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { TaskCard } from "./TaskCard"; import { buildTreeByNode, filterTasks, phaseToNode } from "../taskFilters"; import type { Stage, StateResponse, TaskView, Topology } from "../api"; interface Props { state: StateResponse | null; topology: Topology | null; selectedId: string | null; nodeFilter: string | null; onSelect: (threadId: string) => void; onClearNodeFilter: () => void; } const STATUS_OPTIONS = [ { value: "all", label: "All statuses" }, { value: "active", label: "Active" }, { value: "waiting_human", label: "Waiting human" }, { value: "parked", label: "Parked" }, { value: "done", label: "Done" }, { value: "failed", label: "Failed" }, ]; /** Derive ordered stages from topology node order when state.stages is absent. */ function deriveStages(tasks: TaskView[], topology: Topology | null): Stage[] { // Build node order from topology; fall back to natural insertion order. const nodeOrder: string[] = topology?.nodes.map((n) => n.id) ?? []; const grouped = new Map(); for (const t of tasks) { const node = phaseToNode(t.current_phase); if (!grouped.has(node)) grouped.set(node, []); grouped.get(node)!.push(t); } // Sort by topology order; unknown nodes go at end. const nodeIds = [...grouped.keys()].sort((a, b) => { const ai = nodeOrder.indexOf(a); const bi = nodeOrder.indexOf(b); if (ai === -1 && bi === -1) return 0; if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }); return nodeIds.map((nodeId) => { const topoNode = topology?.nodes.find((n) => n.id === nodeId); const stageTasks = grouped.get(nodeId) ?? []; return { key: nodeId, label: topoNode?.label ?? nodeId, agent: topoNode?.agent ?? "", phases: [nodeId], gated: topoNode?.gated ?? false, role_node: false, state: "idle" as const, count: stageTasks.length, tasks: stageTasks, }; }); } /** * A human-decision gate — distinct from `stage.gated`, which means the stage is * INERT (a disabled/not-yet-wired P3 node like build/verify/dispatch). A gate is * the "gate" stage or a topology node of kind "gate" (e.g. clarify). */ function isGate(stage: Stage, topology: Topology | null): boolean { if (stage.key === "gate") return true; const topoNode = topology?.nodes.find((n) => n.id === stage.key); return topoNode?.kind === "gate"; } export function Board({ state, topology, selectedId, nodeFilter, onSelect, onClearNodeFilter, }: Props) { const [query, setQuery] = useState(""); const [status, setStatus] = useState("all"); const [tree, setTree] = useState("all"); const treeByNode = useMemo(() => buildTreeByNode(topology), [topology]); // Use backend-provided stages or fall back to derived grouping. const allStages: Stage[] = useMemo(() => { if (!state) return []; if (state.stages && state.stages.length > 0) return state.stages; return deriveStages(state.tasks ?? [], topology); }, [state, topology]); // Filter each stage's tasks by the toolbar + nodeFilter params. const filteredStages = useMemo(() => { return allStages.map((stage) => { const filtered = filterTasks(stage.tasks, { query, status, tree, nodeFilter, treeByNode, }); return { ...stage, tasks: filtered, count: filtered.length }; }); }, [allStages, query, status, tree, nodeFilter, treeByNode]); if (!state) { return (
Loading…
); } return (
{/* Toolbar */}
setQuery(e.target.value)} className="h-8 w-48 text-sm" /> {/* Node filter chip */} {nodeFilter && ( )}
{/* Kanban columns — horizontal scroll. Each column is its own listbox of task cards (valid ARIA nesting), so this row is just a labelled list. */}
{filteredStages.map((stage) => ( ))} {filteredStages.length === 0 && (
No stages available.
)}
); } interface ColumnProps { stage: Stage; topology: Topology | null; selectedId: string | null; onSelect: (threadId: string) => void; } function KanbanColumn({ stage, topology, selectedId, onSelect }: ColumnProps) { const gate = isGate(stage, topology); // NOTE: we intentionally do NOT dim stages from the backend `stage.gated` // flag. In the live deployment build/verify/dispatch are wired and active even // though they're still flagged gated, so that flag is a stale "P3 opt-in" hint, // not a real "inactive" signal. Activity is conveyed by live task counts/status. return (
{/* Column header */}
{stage.label}
{stage.count} {gate && ( human gate )}
{/* Task cards — scrollable vertically. flex-1 + min-h-0 lets the list cap at the real column height (no magic viewport math). */}
{stage.tasks.length === 0 ? (

—

) : ( stage.tasks.map((t) => ( )) )}
); }