- 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.
299 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|