feat(agent-team): dashboard UI redesign (Board + DAG tabs, Tailwind/shadcn) [read-only] #64
14 changed files with 1339 additions and 600 deletions
275
agent-team/web/src/components/Board.test.tsx
Normal file
275
agent-team/web/src/components/Board.test.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { Board } from "./Board";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { StateResponse, Topology } from "../api";
|
||||
|
||||
/** Wrap in TooltipProvider — the real app mounts this at the root. */
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
const TOPO: Topology = {
|
||||
trees: [
|
||||
{ id: "core", label: "Core" },
|
||||
{ id: "sdlc", label: "SDLC" },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
|
||||
{ id: "clarify", label: "Clarify", agent: "coordinator", tree: "core", kind: "gate", gated: true },
|
||||
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
function makeState(): StateResponse {
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
generated_at: "now",
|
||||
db_path: "x",
|
||||
stages: [
|
||||
{
|
||||
key: "intake",
|
||||
label: "Intake",
|
||||
agent: "coordinator",
|
||||
phases: ["intake"],
|
||||
gated: false,
|
||||
role_node: false,
|
||||
state: "idle",
|
||||
count: 1,
|
||||
tasks: [
|
||||
{
|
||||
thread_id: "aaaa1111",
|
||||
short_id: "aaaa1111",
|
||||
task: "remediate CVE",
|
||||
current_phase: "intake",
|
||||
status: "active",
|
||||
waiting: false,
|
||||
waiting_since: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "clarify",
|
||||
label: "Clarify",
|
||||
agent: "coordinator",
|
||||
phases: ["clarify"],
|
||||
gated: true,
|
||||
role_node: false,
|
||||
state: "awaiting_human",
|
||||
count: 1,
|
||||
tasks: [
|
||||
{
|
||||
thread_id: "bbbb2222",
|
||||
short_id: "bbbb2222",
|
||||
task: "fix login bug",
|
||||
current_phase: "clarify",
|
||||
status: "waiting_human",
|
||||
waiting: true,
|
||||
waiting_since: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
thread_id: "aaaa1111",
|
||||
short_id: "aaaa1111",
|
||||
task: "remediate CVE",
|
||||
current_phase: "intake",
|
||||
status: "active",
|
||||
waiting: false,
|
||||
waiting_since: null,
|
||||
},
|
||||
{
|
||||
thread_id: "bbbb2222",
|
||||
short_id: "bbbb2222",
|
||||
task: "fix login bug",
|
||||
current_phase: "clarify",
|
||||
status: "waiting_human",
|
||||
waiting: true,
|
||||
waiting_since: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
waiting: [],
|
||||
question_counts: {},
|
||||
summary: { active: 1, parked: 0, waiting: 1, open_questions: 0, total: 2 },
|
||||
budget: { available: false },
|
||||
nodes: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Board", () => {
|
||||
it("renders a column per stage with correct labels", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Stage: Intake")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Stage: Clarify")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows task count badge in each column header", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const intakeCol = screen.getByLabelText("Stage: Intake");
|
||||
// Both columns have 1 task each
|
||||
expect(within(intakeCol).getByText("1")).toBeInTheDocument();
|
||||
|
||||
const clarifyCol = screen.getByLabelText("Stage: Clarify");
|
||||
expect(within(clarifyCol).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders task descriptions inside columns", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
||||
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a waiting affordance (clock icon) for a waiting task", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The Clock icon has aria-label="awaiting human"
|
||||
expect(screen.getByLabelText("awaiting human")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows lock icon on a gated stage column", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("human gate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a card fires onSelect with the thread_id", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={onSelect}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("remediate CVE"));
|
||||
expect(onSelect).toHaveBeenCalledWith("aaaa1111");
|
||||
});
|
||||
|
||||
it("selected card gets aria-selected=true", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId="aaaa1111"
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cards = screen.getAllByRole("option", { selected: true });
|
||||
expect(cards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows node filter chip when nodeFilter is set", () => {
|
||||
const onClearNodeFilter = vi.fn();
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={makeState()}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter="intake"
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={onClearNodeFilter}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByLabelText("Clear node filter: intake");
|
||||
expect(chip).toBeInTheDocument();
|
||||
fireEvent.click(chip);
|
||||
expect(onClearNodeFilter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to deriving stages from tasks when stages is undefined", () => {
|
||||
const stateNoStages: StateResponse = {
|
||||
...makeState(),
|
||||
stages: undefined,
|
||||
};
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={stateNoStages}
|
||||
topology={TOPO}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should still show the tasks grouped by phase
|
||||
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
||||
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders null state gracefully with loading message", () => {
|
||||
renderWithProviders(
|
||||
<Board
|
||||
state={null}
|
||||
topology={null}
|
||||
selectedId={null}
|
||||
nodeFilter={null}
|
||||
onSelect={() => {}}
|
||||
onClearNodeFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,22 @@
|
|||
import type { StateResponse, Topology } from "../api";
|
||||
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";
|
||||
|
||||
// STUB — replaced in the component fan-out. Keeps the foundation compiling.
|
||||
interface Props {
|
||||
state: StateResponse | null;
|
||||
topology: Topology | null;
|
||||
|
|
@ -10,6 +26,245 @@ interface Props {
|
|||
onClearNodeFilter: () => void;
|
||||
}
|
||||
|
||||
export function Board(_props: Props) {
|
||||
return <div className="p-4 text-sm text-muted-foreground">Board…</div>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "reactflow";
|
||||
import { Lock, RotateCcw } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RFData } from "../layout";
|
||||
|
||||
// One pipeline stage on the map. Color encodes live state; a badge shows the
|
||||
|
|
@ -7,49 +10,80 @@ import type { RFData } from "../layout";
|
|||
// label keep the map reachable for screen readers / keyboard users.
|
||||
function MapNodeImpl({ data }: NodeProps<RFData>) {
|
||||
const { meta, state, count, dimmed, onPath, loopBackTo } = data;
|
||||
const cls = [
|
||||
"node",
|
||||
`state-${state}`,
|
||||
meta.gated ? "gated" : "",
|
||||
dimmed ? "dimmed" : "",
|
||||
onPath ? "on-path" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const canLoop = loopBackTo.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls}
|
||||
className={cn(
|
||||
// Base
|
||||
"relative flex w-[184px] h-[72px] flex-col items-center justify-center gap-0.5 rounded-lg border bg-card px-3 py-2 text-center",
|
||||
// State-driven border + tint
|
||||
state === "active" && "border-status-active bg-status-active-bg",
|
||||
state === "awaiting_human" && "border-status-waiting bg-status-waiting-bg",
|
||||
state === "parked" && "border-status-parked bg-status-parked-bg",
|
||||
state === "idle" && "border-border bg-card",
|
||||
// Path highlight
|
||||
onPath && "ring-2 ring-primary shadow-md shadow-primary/20",
|
||||
// Dimmed when a task is selected and this node is not on its path
|
||||
dimmed && "opacity-30",
|
||||
// Gated / inert nodes
|
||||
meta.gated && "opacity-60",
|
||||
)}
|
||||
role="group"
|
||||
aria-label={`${meta.label} stage, ${state}, ${count} task${count === 1 ? "" : "s"}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="node-label">
|
||||
{meta.label}
|
||||
{meta.kind === "gate" && (
|
||||
<span className="gate-pip" title="human gate" aria-hidden>
|
||||
⛋
|
||||
</span>
|
||||
)}
|
||||
{canLoop && (
|
||||
<span
|
||||
className="loop-chip"
|
||||
title={`can send work back to ${loopBackTo.join(", ")}`}
|
||||
aria-label={`can send work back to ${loopBackTo.join(", ")}`}
|
||||
>
|
||||
↺
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="node-agent">{meta.agent}</div>
|
||||
|
||||
{/* Count badge — top-right corner */}
|
||||
{count > 0 && (
|
||||
<span className="node-count" aria-hidden>
|
||||
<span
|
||||
className="absolute -top-2 -right-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{meta.gated && <span className="node-gated-tag">inert</span>}
|
||||
|
||||
{/* Label row */}
|
||||
<div className="flex items-center gap-1 font-semibold text-sm leading-tight text-foreground">
|
||||
{meta.label}
|
||||
|
||||
{meta.kind === "gate" && (
|
||||
<span className="inline-flex" title="human gate">
|
||||
<Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canLoop && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center">
|
||||
<RotateCcw
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||||
aria-label={`can send work back to ${loopBackTo.join(", ")}`}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
can send work back to {loopBackTo.join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent name */}
|
||||
<div className="text-xs text-muted-foreground leading-tight truncate max-w-full px-1">
|
||||
{meta.agent}
|
||||
</div>
|
||||
|
||||
{/* Inert tag — bottom-right corner */}
|
||||
{meta.gated && (
|
||||
<span className="absolute bottom-1 right-1.5 text-[9px] uppercase tracking-wide font-semibold text-muted-foreground">
|
||||
inert
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function PipelineMap({
|
|||
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
|
||||
|
||||
return (
|
||||
<div className="map-pane" aria-label="pipeline map">
|
||||
<div className="h-full w-full" aria-label="pipeline map">
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
|
|
|
|||
82
agent-team/web/src/components/TaskCard.tsx
Normal file
82
agent-team/web/src/components/TaskCard.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge, statusVariant } from "@/components/ui/badge";
|
||||
import type { TaskView } from "../api";
|
||||
|
||||
// Map a task status to a left-border accent class.
|
||||
function statusBorderClass(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "border-l-status-active";
|
||||
case "waiting_human":
|
||||
return "border-l-status-waiting";
|
||||
case "parked":
|
||||
return "border-l-status-parked";
|
||||
case "done":
|
||||
return "border-l-status-done";
|
||||
case "failed":
|
||||
return "border-l-status-failed";
|
||||
default:
|
||||
return "border-l-border";
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskView;
|
||||
selected: boolean;
|
||||
onSelect: (threadId: string) => void;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, selected, onSelect }: TaskCardProps) {
|
||||
function handleClick() {
|
||||
onSelect(task.thread_id);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(task.thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
// Base card
|
||||
"relative cursor-pointer rounded-md bg-card border border-border border-l-4 p-2 text-sm",
|
||||
// Left accent tinted by status
|
||||
statusBorderClass(task.status),
|
||||
// Selected ring
|
||||
selected && "ring-2 ring-ring",
|
||||
// Hover elevation
|
||||
"hover:border-primary/50 hover:shadow-sm transition-shadow",
|
||||
)}
|
||||
>
|
||||
{/* Top row: status badge + waiting clock */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Badge variant={statusVariant(task.status)}>
|
||||
{task.status.replace("_", " ")}
|
||||
</Badge>
|
||||
{task.waiting && (
|
||||
<Clock
|
||||
className="h-3 w-3 text-status-waiting shrink-0"
|
||||
aria-label="awaiting human"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task description — 2-line clamp */}
|
||||
<p className="text-xs text-foreground leading-snug line-clamp-2 mb-1">
|
||||
{task.task}
|
||||
</p>
|
||||
|
||||
{/* Short ID in mono muted text */}
|
||||
<p className="text-xs font-mono text-muted-foreground">{task.short_id}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,17 @@
|
|||
import { MessageSquare, X, ChevronDown } from "lucide-react";
|
||||
import type { TaskDetail } from "../api";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { Badge, statusVariant } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
detail: TaskDetail | null;
|
||||
|
|
@ -24,91 +36,246 @@ function fmtCost(usd: number): string {
|
|||
export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
||||
if (!detail && !loading && !error) return null;
|
||||
|
||||
const isWaiting = detail?.ok && detail.status === "waiting_human";
|
||||
const isClarifyGate =
|
||||
isWaiting && detail?.current_phase === "clarify";
|
||||
|
||||
return (
|
||||
<section className="drawer" aria-label="task detail" role="complementary">
|
||||
<div className="drawer-head">
|
||||
<h2>Task history</h2>
|
||||
<button className="drawer-close" aria-label="Close detail" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
<section
|
||||
className="flex flex-col w-[380px] h-full bg-card border-l border-border"
|
||||
aria-label="task detail"
|
||||
role="complementary"
|
||||
>
|
||||
{/* Sticky header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-card border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">Task history</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close detail"
|
||||
onClick={onClose}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="drawer-loading">Loading…</div>}
|
||||
{error && <div className="drawer-error">{error}</div>}
|
||||
{loading && (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="m-3 rounded-md bg-status-failed-bg text-status-failed text-sm px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && detail.ok && (
|
||||
<>
|
||||
<div className="drawer-summary">
|
||||
<div className="drawer-task">{detail.task}</div>
|
||||
<div className="drawer-meta">
|
||||
<span className={`badge ${detail.status}`}>{detail.status}</span>
|
||||
<span className="mono">{detail.short_id}</span>
|
||||
<span className="spend">spend {fmtCost(detail.total_usd)}</span>
|
||||
</div>
|
||||
{detail.partial && (
|
||||
<div className="partial-banner">
|
||||
Partial history — recorded since the v3 ledger; earlier steps are
|
||||
reconstructed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Summary card */}
|
||||
<Card className="bg-background">
|
||||
<CardContent className="pt-3 pb-3 space-y-1.5">
|
||||
<p className="text-sm font-medium text-foreground leading-snug">
|
||||
{detail.task}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={statusVariant(detail.status)}>
|
||||
{detail.status}
|
||||
</Badge>
|
||||
<code className="text-xs font-mono text-muted-foreground">
|
||||
{detail.short_id}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
spend {fmtCost(detail.total_usd)}
|
||||
</span>
|
||||
</div>
|
||||
{detail.partial && (
|
||||
<p className="text-xs text-muted-foreground bg-muted rounded px-2 py-1">
|
||||
Partial history — recorded since the v3 ledger; earlier
|
||||
steps are reconstructed.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ol className="timeline">
|
||||
{detail.timeline.map((step, i) => {
|
||||
const open = step.exited_at == null;
|
||||
return (
|
||||
<li className={`tl-step ${open ? "open" : ""}`} key={i}>
|
||||
<span className="tl-dot" aria-hidden />
|
||||
<div className="tl-body">
|
||||
<div className="tl-node">{step.to_phase}</div>
|
||||
<div className="tl-times">
|
||||
<span>{step.entered_at ?? "—"}</span>
|
||||
<span className="tl-dur">
|
||||
{open ? "in progress" : fmtDuration(step.duration_s)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tl-foot">
|
||||
{step.status && <span className={`badge ${step.status}`}>{step.status}</span>}
|
||||
<span className="tl-cost">{fmtCost(step.cost_usd)}</span>
|
||||
{step.note && <span className="tl-note">{step.note}</span>}
|
||||
{/* Gate panel — waiting_human informational only */}
|
||||
{isWaiting && (
|
||||
<Card className="bg-status-waiting-bg border border-status-waiting/30">
|
||||
<CardContent className="pt-3 pb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare className="h-4 w-4 mt-0.5 shrink-0 text-status-waiting" />
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-status-waiting">
|
||||
Input requested
|
||||
</p>
|
||||
<p className="text-xs text-status-waiting/80 leading-snug">
|
||||
{isClarifyGate
|
||||
? "The pipeline needs clarification before planning."
|
||||
: "The pipeline is awaiting a human decision to proceed."}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-status-waiting mt-1">
|
||||
Answer in Slack{" "}
|
||||
<span className="font-mono">#agent-team</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{detail.timeline.length === 0 && (
|
||||
<li className="empty">No transitions recorded.</li>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
{detail.qa_history.length > 0 && (
|
||||
<details className="drawer-section" open>
|
||||
<summary>Q&A ({detail.qa_history.length})</summary>
|
||||
<QASection entries={detail.qa_history} />
|
||||
</details>
|
||||
)}
|
||||
{detail.review_verdicts.length > 0 && (
|
||||
<details className="drawer-section">
|
||||
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
|
||||
<VerdictsSection entries={detail.review_verdicts} />
|
||||
</details>
|
||||
)}
|
||||
{detail.plan && (
|
||||
<details className="drawer-section">
|
||||
<summary>Plan</summary>
|
||||
<PlanSection plan={detail.plan} />
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Timeline
|
||||
</p>
|
||||
<ol className="relative space-y-0">
|
||||
{detail.timeline.map((step, i) => {
|
||||
const open = step.exited_at == null;
|
||||
const isLast = i === detail.timeline.length - 1;
|
||||
return (
|
||||
<li key={i} className="flex gap-2">
|
||||
{/* Dot + connector */}
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 h-2 w-2 rounded-full shrink-0 border-2",
|
||||
open
|
||||
? "bg-status-active border-status-active"
|
||||
: "bg-muted border-border",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{!isLast && (
|
||||
<span className="w-px flex-1 bg-border mt-0.5 mb-0.5 min-h-[12px]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step body */}
|
||||
<div className="pb-3 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{step.to_phase}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs shrink-0",
|
||||
open
|
||||
? "text-status-active font-medium"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{open ? "in progress" : fmtDuration(step.duration_s)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{step.entered_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step.entered_at}
|
||||
</span>
|
||||
)}
|
||||
{step.status && (
|
||||
<Badge variant={statusVariant(step.status)} className="text-[10px] py-0 px-1.5">
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
{step.cost_usd > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtCost(step.cost_usd)}
|
||||
</span>
|
||||
)}
|
||||
{step.note && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
{step.note}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{detail.timeline.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground py-2">
|
||||
No transitions recorded.
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Q&A section */}
|
||||
{detail.qa_history.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<CollapsibleSection
|
||||
title={`Q&A (${detail.qa_history.length})`}
|
||||
defaultOpen
|
||||
>
|
||||
<QASection entries={detail.qa_history} />
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Review verdicts */}
|
||||
{detail.review_verdicts.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<CollapsibleSection
|
||||
title={`Review verdicts (${detail.review_verdicts.length})`}
|
||||
defaultOpen={true}
|
||||
>
|
||||
<VerdictsSection entries={detail.review_verdicts} />
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Plan */}
|
||||
{detail.plan && (
|
||||
<>
|
||||
<Separator />
|
||||
<CollapsibleSection title="Plan" defaultOpen={true}>
|
||||
<PlanSection plan={detail.plan} />
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{detail && !detail.ok && (
|
||||
<div className="drawer-error">{detail.error ?? "Could not load task."}</div>
|
||||
<div className="m-3 rounded-md bg-status-failed-bg text-status-failed text-sm px-3 py-2">
|
||||
{detail.error ?? "Could not load task."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsible section wrapper used for Q&A / Verdicts / Plan
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
defaultOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Collapsible defaultOpen={defaultOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full group">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function asText(v: unknown): string {
|
||||
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
|
||||
}
|
||||
|
|
@ -118,17 +285,23 @@ function asText(v: unknown): string {
|
|||
// as markdown so numbered sub-answers / bold render readably.
|
||||
function QASection({ entries }: { entries: unknown[] }) {
|
||||
return (
|
||||
<div className="qa">
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, i) => {
|
||||
const obj =
|
||||
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
|
||||
const turn = obj && "turn" in obj ? Number(obj.turn) : i;
|
||||
const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry);
|
||||
return (
|
||||
<div className="qa-card" key={i}>
|
||||
<div className="qa-turn">Turn {turn + 1}</div>
|
||||
<Markdown>{answer}</Markdown>
|
||||
</div>
|
||||
<Card key={i} className="bg-background">
|
||||
<CardHeader className="pb-1 pt-2 px-3">
|
||||
<CardTitle className="text-xs font-semibold text-muted-foreground">
|
||||
Turn {turn + 1}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-2 px-3 text-sm prose-sm">
|
||||
<Markdown>{answer}</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -139,7 +312,7 @@ function QASection({ entries }: { entries: unknown[] }) {
|
|||
// verdict badge + round + outcome header and the findings rendered as markdown.
|
||||
function VerdictsSection({ entries }: { entries: unknown[] }) {
|
||||
return (
|
||||
<div className="verdicts">
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, i) => {
|
||||
const v =
|
||||
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
||||
|
|
@ -147,16 +320,29 @@ function VerdictsSection({ entries }: { entries: unknown[] }) {
|
|||
const round = "round_index" in v ? Number(v.round_index) : i + 1;
|
||||
const outcome = asText(v.outcome);
|
||||
const findings = asText(v.findings);
|
||||
const cls = verdict.replace(/[^a-z_]/gi, "_").toLowerCase();
|
||||
return (
|
||||
<div className="verdict-card" key={i}>
|
||||
<div className="verdict-head">
|
||||
<span className="verdict-round">Round {round}</span>
|
||||
<span className={`badge ${cls}`}>{verdict.replace(/_/g, " ")}</span>
|
||||
{outcome && <span className="verdict-outcome">{outcome.replace(/_/g, " ")}</span>}
|
||||
</div>
|
||||
{findings && <Markdown>{findings}</Markdown>}
|
||||
</div>
|
||||
<Card key={i} className="bg-background">
|
||||
<CardHeader className="pb-1 pt-2 px-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Round {round}
|
||||
</span>
|
||||
<Badge variant={statusVariant(verdict)}>
|
||||
{verdict.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{outcome && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{outcome.replace(/_/g, " ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{findings && (
|
||||
<CardContent className="pb-2 px-3 text-sm prose-sm">
|
||||
<Markdown>{findings}</Markdown>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -176,27 +362,40 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
|
|||
|
||||
if (!summary && !phases) {
|
||||
return (
|
||||
<pre className="json">{JSON.stringify(plan, null, 2)}</pre>
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-auto font-mono">
|
||||
{JSON.stringify(plan, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plan">
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
<div className="space-y-2">
|
||||
{summary && (
|
||||
<div className="text-sm prose-sm">
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{phases && (
|
||||
<ol className="plan-phases">
|
||||
<ol className="space-y-2">
|
||||
{phases.map((p, i) => {
|
||||
const ph =
|
||||
p && typeof p === "object" ? (p as Record<string, unknown>) : {};
|
||||
const name = asText(ph.name) || `Phase ${i + 1}`;
|
||||
const steps = Array.isArray(ph.steps) ? ph.steps : [];
|
||||
return (
|
||||
<li className="plan-phase" key={i}>
|
||||
<div className="plan-phase-name">{name}</div>
|
||||
<li key={i}>
|
||||
<p className="text-xs font-semibold text-foreground mb-1">
|
||||
{name}
|
||||
</p>
|
||||
{steps.length > 0 && (
|
||||
<ul className="plan-steps">
|
||||
<ul className="space-y-0.5 pl-3">
|
||||
{steps.map((s, j) => (
|
||||
<li key={j}>{asText(s)}</li>
|
||||
<li
|
||||
key={j}
|
||||
className="text-xs text-muted-foreground list-disc"
|
||||
>
|
||||
{asText(s)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
|
@ -206,11 +405,11 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
|
|||
</ol>
|
||||
)}
|
||||
{extra.length > 0 && (
|
||||
<dl className="plan-extra">
|
||||
<dl className="space-y-1">
|
||||
{extra.map(([k, val]) => (
|
||||
<div className="plan-extra-row" key={k}>
|
||||
<dt>{k}</dt>
|
||||
<dd>{asText(val)}</dd>
|
||||
<div key={k} className="flex gap-2 text-xs">
|
||||
<dt className="text-muted-foreground font-medium shrink-0">{k}</dt>
|
||||
<dd className="text-foreground truncate">{asText(val)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TaskList } from "./TaskList";
|
||||
import type { StateResponse, Topology } from "../api";
|
||||
|
||||
const TOPO: Topology = {
|
||||
trees: [
|
||||
{ id: "core", label: "Core" },
|
||||
{ id: "sdlc", label: "SDLC" },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
|
||||
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
function makeState(): StateResponse {
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
generated_at: "now",
|
||||
db_path: "x",
|
||||
tasks: [
|
||||
{ thread_id: "aaaa1111", short_id: "aaaa1111", task: "remediate CVE", current_phase: "plan", status: "active", waiting: false, waiting_since: null },
|
||||
{ thread_id: "bbbb2222", short_id: "bbbb2222", task: "fix login bug", current_phase: "intake", status: "parked", waiting: false, waiting_since: null },
|
||||
],
|
||||
waiting: [],
|
||||
question_counts: {},
|
||||
summary: { active: 1, parked: 1, waiting: 0, open_questions: 0, total: 2 },
|
||||
budget: { available: false },
|
||||
nodes: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskList", () => {
|
||||
it("renders all tasks", () => {
|
||||
render(
|
||||
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter={null}
|
||||
onSelect={() => {}} onClearNodeFilter={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
||||
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters by status", () => {
|
||||
render(
|
||||
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter={null}
|
||||
onSelect={() => {}} onClearNodeFilter={() => {}} />,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Filter by status"), { target: { value: "parked" } });
|
||||
expect(screen.queryByText("remediate CVE")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("honors a node filter (map click)", () => {
|
||||
render(
|
||||
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter="plan"
|
||||
onSelect={() => {}} onClearNodeFilter={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
||||
expect(screen.queryByText("fix login bug")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onSelect when a row is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter={null}
|
||||
onSelect={onSelect} onClearNodeFilter={() => {}} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText("remediate CVE"));
|
||||
expect(onSelect).toHaveBeenCalledWith("aaaa1111");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import type { StateResponse, TaskView, Topology } from "../api";
|
||||
|
||||
// Mirror of the backend phase->node mapping (topology.PHASE_TO_NODE) so the list
|
||||
// can resolve a task's tree and honor a map-node filter. Identity except the P3
|
||||
// build/verify vertices.
|
||||
const PHASE_TO_NODE: Record<string, string> = {
|
||||
build: "build_node",
|
||||
verify: "verify_node",
|
||||
};
|
||||
const phaseToNode = (phase: string): string => PHASE_TO_NODE[phase] ?? phase;
|
||||
|
||||
interface Props {
|
||||
state: StateResponse | null;
|
||||
topology: Topology | null;
|
||||
selectedId: string | null;
|
||||
nodeFilter: string | null;
|
||||
onSelect: (threadId: string) => void;
|
||||
onClearNodeFilter: () => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["all", "active", "waiting_human", "parked", "done", "failed"];
|
||||
|
||||
export function TaskList({
|
||||
state,
|
||||
topology,
|
||||
selectedId,
|
||||
nodeFilter,
|
||||
onSelect,
|
||||
onClearNodeFilter,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [tree, setTree] = useState("all");
|
||||
|
||||
const treeByNode = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const n of topology?.nodes ?? []) m[n.id] = n.tree;
|
||||
return m;
|
||||
}, [topology]);
|
||||
|
||||
const tasks = state?.tasks ?? [];
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return tasks.filter((t) => {
|
||||
if (nodeFilter && phaseToNode(t.current_phase) !== nodeFilter) return false;
|
||||
if (status !== "all" && t.status !== status) return false;
|
||||
if (tree !== "all" && treeByNode[phaseToNode(t.current_phase)] !== tree)
|
||||
return false;
|
||||
if (q && !(`${t.task} ${t.short_id}`.toLowerCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [tasks, query, status, tree, nodeFilter, treeByNode]);
|
||||
|
||||
return (
|
||||
<aside className="tasklist" aria-label="task list">
|
||||
<div className="tasklist-controls">
|
||||
<input
|
||||
className="search"
|
||||
type="search"
|
||||
placeholder="Search tasks…"
|
||||
aria-label="Search tasks"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="filters">
|
||||
<select
|
||||
aria-label="Filter by status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s === "all" ? "All statuses" : s.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filter by tree"
|
||||
value={tree}
|
||||
onChange={(e) => setTree(e.target.value)}
|
||||
>
|
||||
<option value="all">All trees</option>
|
||||
{(topology?.trees ?? []).map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{nodeFilter && (
|
||||
<button className="filter-chip" onClick={onClearNodeFilter}>
|
||||
node: {nodeFilter} ✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="tasks" role="listbox" aria-label="tasks">
|
||||
{filtered.length === 0 && <li className="empty">No tasks match.</li>}
|
||||
{filtered.map((t) => (
|
||||
<TaskRow
|
||||
key={t.thread_id}
|
||||
task={t}
|
||||
selected={t.thread_id === selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
task: TaskView;
|
||||
selected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={`task-row ${selected ? "selected" : ""}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(task.thread_id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(task.thread_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="task-row-top">
|
||||
<span className={`badge ${task.status}`}>{task.status.replace("_", " ")}</span>
|
||||
<span className="task-phase">{task.current_phase}</span>
|
||||
{task.waiting && <span className="task-wait" title="awaiting human">⏳</span>}
|
||||
</div>
|
||||
<div className="task-desc">{task.task}</div>
|
||||
<div className="task-id mono">{task.short_id}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { StateResponse } from "../api";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -6,36 +7,118 @@ interface Props {
|
|||
generatedAt: string | null;
|
||||
}
|
||||
|
||||
const CHIPS: Array<{ key: keyof StateResponse["summary"]; label: string; cls: string }> =
|
||||
[
|
||||
{ key: "active", label: "Active", cls: "active" },
|
||||
{ key: "waiting", label: "Awaiting human", cls: "waiting" },
|
||||
{ key: "parked", label: "Parked", cls: "parked" },
|
||||
{ key: "open_questions", label: "Open questions", cls: "open" },
|
||||
{ key: "total", label: "Total", cls: "total" },
|
||||
];
|
||||
interface ChipDef {
|
||||
key: keyof StateResponse["summary"];
|
||||
label: string;
|
||||
numCls: string;
|
||||
bgCls: string;
|
||||
}
|
||||
|
||||
// Top status bar: summary chips, a connection/health dot, and the snapshot clock.
|
||||
const CHIPS: ChipDef[] = [
|
||||
{
|
||||
key: "active",
|
||||
label: "ACTIVE",
|
||||
numCls: "text-status-active",
|
||||
bgCls: "bg-status-active-bg",
|
||||
},
|
||||
{
|
||||
key: "waiting",
|
||||
label: "AWAITING HUMAN",
|
||||
numCls: "text-status-waiting",
|
||||
bgCls: "bg-status-waiting-bg",
|
||||
},
|
||||
{
|
||||
key: "parked",
|
||||
label: "PARKED",
|
||||
numCls: "text-status-parked",
|
||||
bgCls: "bg-status-parked-bg",
|
||||
},
|
||||
{
|
||||
key: "open_questions",
|
||||
label: "OPEN QUESTIONS",
|
||||
numCls: "text-status-waiting",
|
||||
bgCls: "bg-status-waiting-bg",
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
label: "TOTAL",
|
||||
numCls: "text-muted-foreground",
|
||||
bgCls: "bg-muted",
|
||||
},
|
||||
];
|
||||
|
||||
// Top status bar: brand, summary chips, connection health, and snapshot clock.
|
||||
export function TopBar({ state, connected, generatedAt }: Props) {
|
||||
const summary = state?.summary;
|
||||
const title = connected
|
||||
? `Live — snapshot at ${generatedAt ?? "—"}`
|
||||
: `Disconnected — last snapshot at ${generatedAt ?? "—"}`;
|
||||
|
||||
return (
|
||||
<header className="topbar" role="banner">
|
||||
<div className="brand">
|
||||
<span className="brand-dot" aria-hidden />
|
||||
<span className="brand-name">agent-team</span>
|
||||
<span className="brand-sub">pipeline</span>
|
||||
<header
|
||||
className="flex items-center gap-3 py-2 px-3 bg-card border-b border-border"
|
||||
role="banner"
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full bg-gradient-to-br from-primary to-primary/50 shadow-sm"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-bold text-foreground tracking-tight leading-none">
|
||||
agent-team
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-none">
|
||||
pipeline
|
||||
</span>
|
||||
</div>
|
||||
<div className="chips" role="list">
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-5 bg-border shrink-0" aria-hidden />
|
||||
|
||||
{/* Summary chips */}
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 min-w-0 overflow-x-auto"
|
||||
role="list"
|
||||
aria-label="Pipeline summary"
|
||||
>
|
||||
{CHIPS.map((c) => (
|
||||
<div className={`chip chip-${c.cls}`} role="listitem" key={c.key}>
|
||||
<span className="chip-num">{summary ? summary[c.key] : "–"}</span>
|
||||
<span className="chip-lbl">{c.label}</span>
|
||||
<div
|
||||
key={c.key}
|
||||
role="listitem"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-md px-2.5 py-1 shrink-0",
|
||||
c.bgCls,
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm font-bold leading-none tabular-nums", c.numCls)}>
|
||||
{summary ? summary[c.key] : "–"}
|
||||
</span>
|
||||
<span className="text-[9px] font-semibold tracking-widest text-muted-foreground leading-none mt-0.5 whitespace-nowrap">
|
||||
{c.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="health" title={connected ? "live" : "disconnected"}>
|
||||
<span className={`health-dot ${connected ? "ok" : "down"}`} aria-hidden />
|
||||
<span className="health-clock">{generatedAt ?? "—"}</span>
|
||||
|
||||
{/* Health indicator */}
|
||||
<div
|
||||
className="flex items-center gap-2 shrink-0 ml-auto"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"w-2.5 h-2.5 rounded-full",
|
||||
connected
|
||||
? "bg-green-500 shadow-[0_0_6px_2px_rgba(34,197,94,0.45)]"
|
||||
: "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{generatedAt ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,3 +92,71 @@
|
|||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Markdown content (Q&A answers / review findings / plan summaries), rendered by
|
||||
* the XSS-safe <Markdown> component. Ported from theme.css onto the new tokens.
|
||||
*/
|
||||
.md {
|
||||
font-size: 0.82rem;
|
||||
color: hsl(var(--foreground));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.md p {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.md ul,
|
||||
.md ol {
|
||||
margin: 0.4rem 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.md li {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
.md strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 700;
|
||||
}
|
||||
.md h1,
|
||||
.md h2,
|
||||
.md h3,
|
||||
.md h4 {
|
||||
font-size: 0.9rem;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
color: hsl(var(--status-done));
|
||||
}
|
||||
.md code {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.92em;
|
||||
font-family: theme("fontFamily.mono");
|
||||
}
|
||||
.md pre {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md pre code {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.md a {
|
||||
color: hsl(var(--status-done));
|
||||
}
|
||||
.md blockquote {
|
||||
margin: 0.4rem 0;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid hsl(var(--input));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@ import { StrictMode } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
// theme.css is being retired as components migrate to Tailwind; kept until the
|
||||
// last component is converted so intermediate builds still render (removed in
|
||||
// the integration phase).
|
||||
import "./theme.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
146
agent-team/web/src/taskFilters.test.ts
Normal file
146
agent-team/web/src/taskFilters.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { filterTasks, phaseToNode } from "./taskFilters";
|
||||
import type { TaskView } from "./api";
|
||||
|
||||
const TREE_BY_NODE: Record<string, string> = {
|
||||
intake: "core",
|
||||
clarify: "core",
|
||||
plan: "sdlc",
|
||||
review: "sdlc",
|
||||
build_node: "sdlc",
|
||||
verify_node: "sdlc",
|
||||
};
|
||||
|
||||
function task(overrides: Partial<TaskView> = {}): TaskView {
|
||||
return {
|
||||
thread_id: "aaaa1111",
|
||||
short_id: "aaaa1111",
|
||||
task: "remediate CVE-2024-1234",
|
||||
current_phase: "plan",
|
||||
status: "active",
|
||||
waiting: false,
|
||||
waiting_since: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_PARAMS = {
|
||||
query: "",
|
||||
status: "all",
|
||||
tree: "all",
|
||||
nodeFilter: null,
|
||||
treeByNode: TREE_BY_NODE,
|
||||
};
|
||||
|
||||
describe("phaseToNode", () => {
|
||||
it("returns the phase itself for standard phases", () => {
|
||||
expect(phaseToNode("intake")).toBe("intake");
|
||||
expect(phaseToNode("plan")).toBe("plan");
|
||||
expect(phaseToNode("clarify")).toBe("clarify");
|
||||
});
|
||||
|
||||
it("maps build -> build_node and verify -> verify_node", () => {
|
||||
expect(phaseToNode("build")).toBe("build_node");
|
||||
expect(phaseToNode("verify")).toBe("verify_node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTasks", () => {
|
||||
it("returns all tasks when no filters applied", () => {
|
||||
const tasks = [task(), task({ thread_id: "bbbb2222", short_id: "bbbb2222" })];
|
||||
expect(filterTasks(tasks, BASE_PARAMS)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by query matching task description", () => {
|
||||
const tasks = [
|
||||
task({ task: "remediate CVE" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", task: "fix login bug" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "CVE" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].task).toBe("remediate CVE");
|
||||
});
|
||||
|
||||
it("filters by query matching short_id", () => {
|
||||
const tasks = [
|
||||
task({ short_id: "abc123" }),
|
||||
task({ thread_id: "bbbb", short_id: "xyz789", task: "other task" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "abc" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].short_id).toBe("abc123");
|
||||
});
|
||||
|
||||
it("query matching is case-insensitive", () => {
|
||||
const tasks = [task({ task: "Remediate CVE" })];
|
||||
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by status", () => {
|
||||
const tasks = [
|
||||
task({ status: "active" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", status: "parked" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, status: "parked" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].status).toBe("parked");
|
||||
});
|
||||
|
||||
it("status='all' does not filter by status", () => {
|
||||
const tasks = [
|
||||
task({ status: "active" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", status: "done" }),
|
||||
];
|
||||
expect(filterTasks(tasks, { ...BASE_PARAMS, status: "all" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by tree", () => {
|
||||
const tasks = [
|
||||
task({ current_phase: "intake" }), // core tree
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }), // sdlc tree
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, tree: "sdlc" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].current_phase).toBe("plan");
|
||||
});
|
||||
|
||||
it("filters by nodeFilter (exact node match)", () => {
|
||||
const tasks = [
|
||||
task({ current_phase: "plan" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "intake" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "plan" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].current_phase).toBe("plan");
|
||||
});
|
||||
|
||||
it("nodeFilter resolves build -> build_node", () => {
|
||||
const tasks = [
|
||||
task({ current_phase: "build" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "build_node" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].current_phase).toBe("build");
|
||||
});
|
||||
|
||||
it("combines query + status filters", () => {
|
||||
const tasks = [
|
||||
task({ task: "fix login", status: "active" }),
|
||||
task({ thread_id: "b", short_id: "b", task: "fix login", status: "parked" }),
|
||||
task({ thread_id: "c", short_id: "c", task: "other task", status: "active" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "fix login", status: "active" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].thread_id).toBe("aaaa1111");
|
||||
});
|
||||
|
||||
it("returns empty array when nothing matches", () => {
|
||||
const tasks = [task()];
|
||||
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "nonexistent" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty task list", () => {
|
||||
expect(filterTasks([], BASE_PARAMS)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
47
agent-team/web/src/taskFilters.ts
Normal file
47
agent-team/web/src/taskFilters.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Pure filter helpers — shared by Board and any future list views.
|
||||
// Lifted from TaskList.tsx so logic is testable in isolation.
|
||||
|
||||
import type { TaskView, Topology } from "./api";
|
||||
|
||||
// Mirror of the backend phase->node mapping. Identity for most phases;
|
||||
// build/verify are the only overrides (P3 build-pipeline vertices).
|
||||
const PHASE_TO_NODE_MAP: Record<string, string> = {
|
||||
build: "build_node",
|
||||
verify: "verify_node",
|
||||
};
|
||||
|
||||
export function phaseToNode(phase: string): string {
|
||||
return PHASE_TO_NODE_MAP[phase] ?? phase;
|
||||
}
|
||||
|
||||
/** Build a node-id -> tree-id lookup from topology. */
|
||||
export function buildTreeByNode(topology: Topology | null): Record<string, string> {
|
||||
const m: Record<string, string> = {};
|
||||
for (const n of topology?.nodes ?? []) m[n.id] = n.tree;
|
||||
return m;
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
query: string;
|
||||
status: string; // "all" | task status value
|
||||
tree: string; // "all" | tree id
|
||||
nodeFilter: string | null;
|
||||
treeByNode: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Filter an array of TaskView objects by the given params. */
|
||||
export function filterTasks(tasks: TaskView[], params: FilterParams): TaskView[] {
|
||||
const { query, status, tree, nodeFilter, treeByNode } = params;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
return tasks.filter((t) => {
|
||||
const node = phaseToNode(t.current_phase);
|
||||
|
||||
if (nodeFilter && node !== nodeFilter) return false;
|
||||
if (status !== "all" && t.status !== status) return false;
|
||||
if (tree !== "all" && treeByNode[node] !== tree) return false;
|
||||
if (q && !`${t.task} ${t.short_id}`.toLowerCase().includes(q)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
:root {
|
||||
--bg: #0c0e13;
|
||||
--bg-1: #12151c;
|
||||
--bg-2: #1a1e26;
|
||||
--border: #232a36;
|
||||
--border-2: #2f3645;
|
||||
--text: #e6e8ee;
|
||||
--text-dim: #8a93a6;
|
||||
--accent: #7a5cff;
|
||||
--accent-2: #3f7fd8;
|
||||
--ok: #2f8f5e;
|
||||
--ok-bg: #163a2b;
|
||||
--ok-fg: #6ee7a8;
|
||||
--warn-bg: #3a3216;
|
||||
--warn-fg: #f5d76e;
|
||||
--park-bg: #3a1f16;
|
||||
--park-fg: #f3a36e;
|
||||
--done-bg: #1c2b3a;
|
||||
--done-fg: #7fb3f5;
|
||||
--fail-bg: #3a1620;
|
||||
--fail-fg: #f57f9c;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
|
||||
.app { display: flex; flex-direction: column; height: 100%; }
|
||||
.body { display: grid; grid-template-columns: 320px 1fr 360px; flex: 1; min-height: 0; }
|
||||
|
||||
/* --- Top bar -------------------------------------------------------------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 1.5rem;
|
||||
padding: .6rem 1rem; background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { display: flex; align-items: baseline; gap: .5rem; }
|
||||
.brand-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
align-self: center;
|
||||
}
|
||||
.brand-name { font-weight: 700; letter-spacing: .01em; }
|
||||
.brand-sub { color: var(--text-dim); font-size: .85rem; }
|
||||
.chips { display: flex; gap: .6rem; flex: 1; }
|
||||
.chip {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: .3rem .7rem; min-width: 4.5rem;
|
||||
}
|
||||
.chip-num { font-size: 1.25rem; font-weight: 700; }
|
||||
.chip-lbl { font-size: .66rem; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--text-dim); }
|
||||
.chip-active .chip-num { color: var(--ok-fg); }
|
||||
.chip-waiting .chip-num { color: var(--warn-fg); }
|
||||
.chip-parked .chip-num { color: var(--park-fg); }
|
||||
.health { display: flex; align-items: center; gap: .5rem; color: var(--text-dim);
|
||||
font-size: .8rem; }
|
||||
.health-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.health-dot.ok { background: var(--ok-fg); box-shadow: 0 0 6px var(--ok); }
|
||||
.health-dot.down { background: var(--fail-fg); }
|
||||
|
||||
/* --- Task list ------------------------------------------------------------ */
|
||||
.tasklist { background: var(--bg-1); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; min-height: 0; }
|
||||
.tasklist-controls { padding: .75rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: .5rem; }
|
||||
.search, select {
|
||||
background: var(--bg-2); color: var(--text); border: 1px solid var(--border-2);
|
||||
border-radius: 6px; padding: .4rem .55rem; font-size: .85rem; width: 100%;
|
||||
}
|
||||
.filters { display: flex; gap: .5rem; }
|
||||
.filter-chip { background: var(--bg-2); color: var(--accent); cursor: pointer;
|
||||
border: 1px solid var(--accent); border-radius: 999px; padding: .2rem .6rem;
|
||||
font-size: .75rem; align-self: flex-start; }
|
||||
.tasks { list-style: none; margin: 0; padding: .5rem; overflow-y: auto; flex: 1; }
|
||||
.task-row { background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: .55rem .65rem; margin-bottom: .5rem; cursor: pointer;
|
||||
transition: border-color .15s, transform .05s; }
|
||||
.task-row:hover { border-color: var(--border-2); }
|
||||
.task-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.task-row.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.task-row-top { display: flex; align-items: center; gap: .5rem; margin-bottom: .25rem; }
|
||||
.task-phase { color: var(--text-dim); font-size: .75rem; }
|
||||
.task-wait { margin-left: auto; }
|
||||
.task-desc { font-size: .85rem; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.task-id { color: var(--text-dim); font-size: .72rem; margin-top: .25rem; }
|
||||
.empty { color: var(--text-dim); font-style: italic; padding: .5rem; }
|
||||
|
||||
/* --- Badges -------------------------------------------------------------- */
|
||||
.badge { display: inline-block; padding: .08rem .45rem; border-radius: 999px;
|
||||
font-size: .72rem; font-weight: 600; }
|
||||
.badge.active { background: var(--ok-bg); color: var(--ok-fg); }
|
||||
.badge.waiting_human, .badge.waiting { background: var(--warn-bg); color: var(--warn-fg); }
|
||||
.badge.parked { background: var(--park-bg); color: var(--park-fg); }
|
||||
.badge.done { background: var(--done-bg); color: var(--done-fg); }
|
||||
.badge.failed { background: var(--fail-bg); color: var(--fail-fg); }
|
||||
.badge.unknown { background: var(--bg-2); color: var(--text-dim); }
|
||||
|
||||
/* --- Map ----------------------------------------------------------------- */
|
||||
.center { min-height: 0; min-width: 0; position: relative; }
|
||||
.map-pane { width: 100%; height: 100%; }
|
||||
.loading { padding: 2rem; color: var(--text-dim); }
|
||||
.react-flow__attribution { display: none; }
|
||||
|
||||
.node {
|
||||
width: 184px; height: 72px; background: var(--bg-2);
|
||||
border: 1.5px solid var(--border-2); border-radius: 10px;
|
||||
padding: .5rem .6rem; display: flex; flex-direction: column; justify-content: center;
|
||||
position: relative; transition: border-color .25s, background .25s, opacity .2s;
|
||||
}
|
||||
.node:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.node-label { font-weight: 700; font-size: .92rem; display: flex; gap: .3rem;
|
||||
align-items: center; }
|
||||
.gate-pip { color: var(--warn-fg); font-size: .8rem; }
|
||||
.node-agent { color: var(--text-dim); font-size: .72rem; margin-top: .15rem; }
|
||||
.node-count { position: absolute; top: -8px; right: -8px; min-width: 20px; height: 20px;
|
||||
border-radius: 999px; background: var(--accent); color: #0c0e13; font-weight: 800;
|
||||
font-size: .72rem; display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 5px; }
|
||||
.node-gated-tag { position: absolute; bottom: 4px; right: 8px; font-size: .58rem;
|
||||
letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); }
|
||||
.node.state-active { border-color: var(--ok); background: var(--ok-bg); }
|
||||
.node.state-awaiting_human { border-color: var(--warn-fg); background: var(--warn-bg); }
|
||||
.node.state-parked { border-color: var(--park-fg); background: var(--park-bg); }
|
||||
.node.gated { opacity: .62; }
|
||||
.node.dimmed { opacity: .28; }
|
||||
.node.on-path { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(122, 92, 255, .4); }
|
||||
|
||||
/* --- Drawer -------------------------------------------------------------- */
|
||||
.drawer { background: var(--bg-1); border-left: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
|
||||
.drawer-head { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .75rem 1rem; border-bottom: 1px solid var(--border); position: sticky;
|
||||
top: 0; background: var(--bg-1); }
|
||||
.drawer-head h2 { font-size: 1rem; margin: 0; color: #9fb4d6; }
|
||||
.drawer-close { background: none; border: none; color: var(--text-dim);
|
||||
font-size: 1rem; cursor: pointer; }
|
||||
.drawer-close:focus-visible { outline: 2px solid var(--accent); }
|
||||
.drawer-loading, .drawer-error, .drawer-section { padding: 1rem; }
|
||||
.drawer-error { color: var(--fail-fg); }
|
||||
.drawer-summary { padding: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.drawer-task { font-size: .95rem; margin-bottom: .5rem; }
|
||||
.drawer-meta { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap;
|
||||
font-size: .8rem; color: var(--text-dim); }
|
||||
.spend { margin-left: auto; color: var(--text); }
|
||||
.partial-banner { margin-top: .6rem; background: var(--warn-bg); color: var(--warn-fg);
|
||||
border-radius: 6px; padding: .5rem .6rem; font-size: .76rem; }
|
||||
|
||||
.timeline { list-style: none; margin: 0; padding: 1rem 1rem 0; }
|
||||
.tl-step { display: flex; gap: .7rem; padding-bottom: 1rem; position: relative; }
|
||||
.tl-step:not(:last-child)::before { content: ""; position: absolute; left: 5px;
|
||||
top: 14px; bottom: 0; width: 2px; background: var(--border-2); }
|
||||
.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent);
|
||||
margin-top: 2px; flex-shrink: 0; z-index: 1; }
|
||||
.tl-step.open .tl-dot { background: var(--warn-fg); box-shadow: 0 0 6px var(--warn-fg); }
|
||||
.tl-body { flex: 1; }
|
||||
.tl-node { font-weight: 700; font-size: .88rem; }
|
||||
.tl-times { display: flex; justify-content: space-between; color: var(--text-dim);
|
||||
font-size: .72rem; margin: .15rem 0; }
|
||||
.tl-dur { color: var(--text); }
|
||||
.tl-foot { display: flex; gap: .5rem; align-items: center; font-size: .72rem; }
|
||||
.tl-cost { color: var(--ok-fg); }
|
||||
.tl-note { color: var(--text-dim); font-style: italic; }
|
||||
.drawer-section summary { cursor: pointer; color: #9fb4d6; font-weight: 600;
|
||||
font-size: .85rem; }
|
||||
.json { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: .6rem; font-size: .72rem; overflow-x: auto; max-height: 16rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
|
||||
/* --- Loop-back affordance on a node ------------------------------------- */
|
||||
.loop-chip { margin-left: .35rem; color: var(--accent); font-weight: 700;
|
||||
font-size: .9rem; cursor: help; }
|
||||
.badge.request_changes { background: var(--park-bg); color: var(--park-fg); }
|
||||
.badge.approve, .badge.approved, .badge.pass { background: var(--ok-bg); color: var(--ok-fg); }
|
||||
|
||||
/* --- Markdown content (Q&A / verdicts / plan) --------------------------- */
|
||||
.md { font-size: .82rem; color: var(--text); overflow-wrap: anywhere; }
|
||||
.md > :first-child { margin-top: 0; }
|
||||
.md > :last-child { margin-bottom: 0; }
|
||||
.md p { margin: .4rem 0; }
|
||||
.md ul, .md ol { margin: .4rem 0; padding-left: 1.2rem; }
|
||||
.md li { margin: .15rem 0; }
|
||||
.md strong { color: #fff; font-weight: 700; }
|
||||
.md h1, .md h2, .md h3, .md h4 { font-size: .9rem; margin: .6rem 0 .3rem;
|
||||
color: #9fb4d6; }
|
||||
.md code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 0 .25rem; font-size: .92em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.md pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: .5rem; overflow-x: auto; }
|
||||
.md pre code { border: 0; padding: 0; background: none; }
|
||||
.md a { color: var(--done-fg); }
|
||||
.md blockquote { margin: .4rem 0; padding-left: .7rem; border-left: 2px solid var(--border-2);
|
||||
color: var(--text-dim); }
|
||||
|
||||
/* --- Q&A cards --------------------------------------------------------- */
|
||||
.qa-card, .verdict-card { background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: .55rem .65rem; margin-bottom: .6rem; }
|
||||
.qa-turn { font-size: .72rem; font-weight: 700; color: var(--text-dim);
|
||||
text-transform: uppercase; letter-spacing: .04em; margin-bottom: .3rem; }
|
||||
|
||||
/* --- Verdict cards ----------------------------------------------------- */
|
||||
.verdict-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
|
||||
margin-bottom: .35rem; }
|
||||
.verdict-round { font-size: .72rem; font-weight: 700; color: var(--text-dim); }
|
||||
.verdict-outcome { font-size: .72rem; color: var(--text-dim); }
|
||||
|
||||
/* --- Plan -------------------------------------------------------------- */
|
||||
.plan-phases { margin: .5rem 0 0; padding-left: 1.1rem; }
|
||||
.plan-phase { margin-bottom: .5rem; }
|
||||
.plan-phase-name { font-weight: 700; font-size: .84rem; }
|
||||
.plan-steps { margin: .2rem 0 0; padding-left: 1.1rem; }
|
||||
.plan-steps li { font-size: .8rem; margin: .15rem 0; }
|
||||
.plan-extra { margin: .6rem 0 0; font-size: .78rem; }
|
||||
.plan-extra-row { display: flex; gap: .5rem; }
|
||||
.plan-extra dt { color: var(--text-dim); min-width: 5rem; }
|
||||
.plan-extra dd { margin: 0; }
|
||||
Reference in a new issue