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
Adam Moussa 8530074551 ci(agent-team-web): wire Prettier + ESLint + Playwright and enable the CI steps (#65)
- Prettier 3.6.2: .prettierrc.json + .prettierignore + format/format:check
  scripts; formatted the web/ tree.
- ESLint 9 flat config (@eslint/js + typescript-eslint + react-hooks +
  react-refresh, eslint-config-prettier last so Prettier owns formatting);
  lint script; src/components/ui/** opts out of react-refresh (shadcn primitives
  co-export their cva variants). 0 problems.
- Playwright 1.61.1: playwright.config.ts (build+preview webServer) + an e2e
  Board-renders smoke against a mocked /api; test:e2e script. Reusable workflow
  installs chromium itself.
- ci-web.yaml: required-scripts -> format:check,lint,build,test,test:e2e;
  run-format-check/lint/e2e -> true; e2e-browser chromium.

All five scripts verified green locally.
2026-06-25 10:56:48 -04:00

299 lines
9.1 KiB
TypeScript

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,
};
});
}
/**
* 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 (
<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. Each column is its own listbox of
task cards (valid ARIA nesting), so this row is just a labelled list. */}
<div
className="flex gap-3 overflow-x-auto pb-2 flex-1 min-h-0 px-1"
role="list"
aria-label="pipeline stages"
>
{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);
// 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 (
<div
className={cn(
"flex h-full min-h-0 w-52 shrink-0 flex-col gap-1.5 rounded-lg border border-border bg-muted/40 p-2",
)}
role="listitem"
aria-label={`Stage: ${stage.label}`}
>
{/* Column header */}
<div className="flex items-center justify-between gap-1 mb-0.5">
<span className="flex items-center gap-1 truncate text-xs font-semibold text-foreground">
{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. flex-1 + min-h-0 lets the list cap
at the real column height (no magic viewport math). */}
<ScrollArea className="min-h-0 flex-1">
<div
className="flex flex-col gap-1.5 pr-1"
role="listbox"
aria-label={`${stage.label} tasks`}
>
{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>
);
}