This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/web/src/components/Board.tsx

271 lines
8.3 KiB
TypeScript
Raw Normal View History

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<string, TaskView[]>();
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,
};
});
}
/** Check if a stage is a human gate (stage.gated OR topology node kind === 'gate'). */
function isGate(stage: Stage, topology: Topology | null): boolean {
if (stage.gated) 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 (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
Loading…
</div>
);
}
return (
<div className="flex flex-col gap-2 h-full min-h-0">
{/* Toolbar */}
<div
className="flex flex-wrap items-center gap-2 px-1"
role="toolbar"
aria-label="board filters"
>
<Input
type="search"
placeholder="Search tasks…"
aria-label="Search tasks"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 w-48 text-sm"
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Filter by status">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tree} onValueChange={setTree}>
<SelectTrigger className="h-8 w-36 text-sm" aria-label="Filter by tree">
<SelectValue placeholder="All trees" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-sm">All trees</SelectItem>
{(topology?.trees ?? []).map((t) => (
<SelectItem key={t.id} value={t.id} className="text-sm">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Node filter chip */}
{nodeFilter && (
<Button
variant="secondary"
size="sm"
className="h-8 gap-1 text-xs"
onClick={onClearNodeFilter}
aria-label={`Clear node filter: ${nodeFilter}`}
>
node: {nodeFilter}
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Kanban columns — horizontal scroll */}
<div
className="flex gap-3 overflow-x-auto pb-2 flex-1 min-h-0 px-1"
role="listbox"
aria-label="pipeline stages"
aria-multiselectable="false"
>
{filteredStages.map((stage) => (
<KanbanColumn
key={stage.key}
stage={stage}
topology={topology}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
{filteredStages.length === 0 && (
<div className="flex items-center justify-center flex-1 text-sm text-muted-foreground">
No stages available.
</div>
)}
</div>
</div>
);
}
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);
return (
<div
className={cn(
"flex flex-col gap-1.5 w-52 shrink-0 rounded-lg bg-muted/40 border border-border p-2",
)}
aria-label={`Stage: ${stage.label}`}
>
{/* Column header */}
<div className="flex items-center justify-between gap-1 mb-0.5">
<span className="text-xs font-semibold text-foreground truncate">{stage.label}</span>
<div className="flex items-center gap-1 shrink-0">
<Badge variant="outline" className="px-1.5 py-0 text-xs">
{stage.count}
</Badge>
{gate && (
<Tooltip>
<TooltipTrigger asChild>
<Lock
className="h-3 w-3 text-muted-foreground"
aria-label="human gate"
/>
</TooltipTrigger>
<TooltipContent>human gate</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Task cards — scrollable vertically */}
<ScrollArea className="flex-1" style={{ maxHeight: "calc(100vh - 220px)" }}>
<div className="flex flex-col gap-1.5 pr-1">
{stage.tasks.length === 0 ? (
<p className="text-xs text-muted-foreground py-3 text-center">—</p>
) : (
stage.tasks.map((t) => (
<TaskCard
key={t.thread_id}
task={t}
selected={t.thread_id === selectedId}
onSelect={onSelect}
/>
))
)}
</div>
</ScrollArea>
</div>
);
}