feat(agent-team): dashboard UI redesign (Board + DAG tabs, Tailwind/shadcn) [read-only] #64
36 changed files with 4135 additions and 629 deletions
33
.github/workflows/ci-web.yaml
vendored
Normal file
33
.github/workflows/ci-web.yaml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: CI Web
|
||||
|
||||
# Thin caller over the org reusable front-end CI for the agent-team dashboard SPA
|
||||
# (agent-team/web — Vite + React + TypeScript + vitest). Distinct job id `ci-web`
|
||||
# (→ status context `ci-web / ci`) so it does NOT collide with the Python
|
||||
# `ci / ci` context emitted by ci.yaml.
|
||||
#
|
||||
# Our package.json currently exposes build + test (+ typecheck); `build` runs
|
||||
# `tsc -b && vite build`, so the type-check happens inside build. Prettier
|
||||
# (format:check), ESLint (lint), and Playwright (test:e2e) are not wired yet, so
|
||||
# those reusable steps are toggled off until those tools are added.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "agent-team/web/**"
|
||||
- ".github/workflows/ci-web.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci-web:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
|
||||
with:
|
||||
node-version: "24"
|
||||
working-directory: agent-team/web
|
||||
cache-dependency-path: agent-team/web/package-lock.json
|
||||
required-scripts: "build,test"
|
||||
run-format-check: false
|
||||
run-lint: false
|
||||
run-e2e: false
|
||||
1863
agent-team/web/package-lock.json
generated
1863
agent-team/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,14 +10,25 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-separator": "^1.1.10",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-markdown": "9.0.1",
|
||||
"reactflow": "11.11.4"
|
||||
"reactflow": "11.11.4",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "6.4.8",
|
||||
|
|
@ -26,7 +37,11 @@
|
|||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@vitejs/plugin-react": "4.3.1",
|
||||
"autoprefixer": "^10.5.1",
|
||||
"jsdom": "24.1.3",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.5.4",
|
||||
"vite": "5.4.8",
|
||||
"vitest": "1.6.0"
|
||||
|
|
|
|||
6
agent-team/web/postcss.config.js
Normal file
6
agent-team/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LayoutGrid, Workflow as WorkflowIcon } from "lucide-react";
|
||||
import {
|
||||
fetchState,
|
||||
fetchTask,
|
||||
|
|
@ -7,17 +8,22 @@ import {
|
|||
type TaskDetail,
|
||||
type Topology,
|
||||
} from "./api";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Board } from "./components/Board";
|
||||
import { PipelineMap } from "./components/PipelineMap";
|
||||
import { TaskDrawer } from "./components/TaskDrawer";
|
||||
import { TaskList } from "./components/TaskList";
|
||||
import { TopBar } from "./components/TopBar";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
type View = "board" | "pipeline";
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState<StateResponse | null>(null);
|
||||
const [topology, setTopology] = useState<Topology | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [view, setView] = useState<View>("board");
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<TaskDetail | null>(null);
|
||||
|
|
@ -81,6 +87,7 @@ export default function App() {
|
|||
|
||||
const toggleNodeFilter = useCallback((nodeId: string) => {
|
||||
setNodeFilter((cur) => (cur === nodeId ? null : nodeId));
|
||||
setView("board"); // node clicks filter the board, so surface it
|
||||
}, []);
|
||||
|
||||
// Highlight the nodes a selected task has visited.
|
||||
|
|
@ -105,42 +112,69 @@ export default function App() {
|
|||
return looped;
|
||||
}, [selectedId, detail, topology]);
|
||||
|
||||
const drawerOpen = selectedId != null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar
|
||||
state={state}
|
||||
connected={connected}
|
||||
generatedAt={state?.generated_at ?? null}
|
||||
/>
|
||||
<div className="body">
|
||||
<TaskList
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-full flex-col bg-background text-foreground">
|
||||
<TopBar
|
||||
state={state}
|
||||
topology={topology}
|
||||
selectedId={selectedId}
|
||||
nodeFilter={nodeFilter}
|
||||
onSelect={selectTask}
|
||||
onClearNodeFilter={() => setNodeFilter(null)}
|
||||
connected={connected}
|
||||
generatedAt={state?.generated_at ?? null}
|
||||
/>
|
||||
<main className="center">
|
||||
{topology ? (
|
||||
<PipelineMap
|
||||
topology={topology}
|
||||
state={state}
|
||||
pathNodeIds={pathNodeIds}
|
||||
loopedEdges={loopedEdges}
|
||||
onNodeClick={toggleNodeFilter}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => setView(v as View)}
|
||||
className="flex min-w-0 flex-1 flex-col"
|
||||
aria-label="Main view"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-3 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="board">
|
||||
<LayoutGrid /> Board
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pipeline">
|
||||
<WorkflowIcon /> Pipeline
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="board" className="m-0 min-h-0 flex-1">
|
||||
<Board
|
||||
state={state}
|
||||
topology={topology}
|
||||
selectedId={selectedId}
|
||||
nodeFilter={nodeFilter}
|
||||
onSelect={selectTask}
|
||||
onClearNodeFilter={() => setNodeFilter(null)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="pipeline" className="m-0 min-h-0 flex-1">
|
||||
{topology ? (
|
||||
<PipelineMap
|
||||
topology={topology}
|
||||
state={state}
|
||||
pathNodeIds={pathNodeIds}
|
||||
loopedEdges={loopedEdges}
|
||||
onNodeClick={toggleNodeFilter}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-8 text-sm text-muted-foreground">
|
||||
Loading pipeline…
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{drawerOpen && (
|
||||
<TaskDrawer
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
) : (
|
||||
<div className="loading">Loading pipeline…</div>
|
||||
)}
|
||||
</main>
|
||||
<TaskDrawer
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,28 @@ export interface TaskView {
|
|||
waiting_since: string | null;
|
||||
}
|
||||
|
||||
// One pipeline stage as grouped + ordered by the backend (status_page.snapshot).
|
||||
// Each stage maps to a Kanban column; `tasks` are the live tasks sitting in it.
|
||||
export interface Stage {
|
||||
key: string;
|
||||
label: string;
|
||||
agent: string;
|
||||
phases: string[];
|
||||
gated: boolean;
|
||||
role_node: boolean;
|
||||
state: NodeState;
|
||||
count: number;
|
||||
tasks: TaskView[];
|
||||
}
|
||||
|
||||
export interface StateResponse {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
generated_at: string;
|
||||
db_path: string;
|
||||
// Backend already groups tasks into ordered pipeline stages; the Board renders
|
||||
// one column per stage. Optional for resilience against older daemons.
|
||||
stages?: Stage[];
|
||||
tasks: TaskView[];
|
||||
waiting: TaskView[];
|
||||
question_counts: Record<string, number>;
|
||||
|
|
|
|||
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();
|
||||
});
|
||||
});
|
||||
287
agent-team/web/src/components/Board.tsx
Normal file
287
agent-team/web/src/components/Board.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,71 @@ 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",
|
||||
)}
|
||||
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>
|
||||
|
||||
<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,18 @@
|
|||
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";
|
||||
import { formatEastern } from "@/lib/time";
|
||||
|
||||
interface Props {
|
||||
detail: TaskDetail | null;
|
||||
|
|
@ -24,91 +37,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 tabular-nums">
|
||||
{formatEastern(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">
|
||||
<h3 className="m-0 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</h3>
|
||||
<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 +286,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 +313,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 +321,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 +363,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 +406,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,5 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { formatEastern } from "@/lib/time";
|
||||
import type { StateResponse } from "../api";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -6,36 +8,119 @@ 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 snapshot = formatEastern(generatedAt);
|
||||
const title = connected
|
||||
? `Live — snapshot at ${snapshot}`
|
||||
: `Disconnected — last snapshot at ${snapshot}`;
|
||||
|
||||
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-status-active shadow-[0_0_6px_2px_hsl(var(--status-active)/0.45)]"
|
||||
: "bg-status-failed",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{snapshot}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
64
agent-team/web/src/components/ui/badge.tsx
Normal file
64
agent-team/web/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold leading-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-secondary text-secondary-foreground",
|
||||
outline: "border border-border text-muted-foreground",
|
||||
// Pipeline status variants — tinted surface + bright foreground.
|
||||
active: "bg-status-active-bg text-status-active",
|
||||
waiting: "bg-status-waiting-bg text-status-waiting",
|
||||
parked: "bg-status-parked-bg text-status-parked",
|
||||
done: "bg-status-done-bg text-status-done",
|
||||
failed: "bg-status-failed-bg text-status-failed",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a backend task status / verdict string to a Badge variant. Centralized so
|
||||
* TaskCard, the Board, and TaskDrawer agree on color.
|
||||
*/
|
||||
export function statusVariant(
|
||||
status: string,
|
||||
): NonNullable<BadgeProps["variant"]> {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "active";
|
||||
case "waiting_human":
|
||||
case "waiting":
|
||||
return "waiting";
|
||||
case "parked":
|
||||
case "request_changes":
|
||||
return "parked";
|
||||
case "done":
|
||||
case "approve":
|
||||
case "approved":
|
||||
case "pass":
|
||||
return status === "done" ? "done" : "active";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
55
agent-team/web/src/components/ui/button.tsx
Normal file
55
agent-team/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-6",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
51
agent-team/web/src/components/ui/card.tsx
Normal file
51
agent-team/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-1 p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-3 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent };
|
||||
7
agent-team/web/src/components/ui/collapsible.tsx
Normal file
7
agent-team/web/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
20
agent-team/web/src/components/ui/input.tsx
Normal file
20
agent-team/web/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-secondary px-2.5 py-1 text-sm text-foreground transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
45
agent-team/web/src/components/ui/scroll-area.tsx
Normal file
45
agent-team/web/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" &&
|
||||
"h-2 flex-col border-t border-t-transparent p-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
85
agent-team/web/src/components/ui/select.tsx
Normal file
85
agent-team/web/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-1 rounded-md border border-input bg-secondary px-2.5 py-1 text-sm text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
position={position}
|
||||
className={cn(
|
||||
"relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
position === "popper" && "translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-7 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
};
|
||||
28
agent-team/web/src/components/ui/separator.tsx
Normal file
28
agent-team/web/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
52
agent-team/web/src/components/ui/tabs.tsx
Normal file
52
agent-team/web/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
27
agent-team/web/src/components/ui/tooltip.tsx
Normal file
27
agent-team/web/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
162
agent-team/web/src/index.css
Normal file
162
agent-team/web/src/index.css
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Design tokens for the agent-team dashboard.
|
||||
*
|
||||
* shadcn/ui HSL semantic tokens (background/foreground/card/primary/…) plus a
|
||||
* pipeline `status.*` palette (active/waiting/parked/done/failed), each with a
|
||||
* tinted surface and a bright foreground. Values are ported from the original
|
||||
* theme.css hex palette so the dark look stays recognizable. Density follows
|
||||
* vibe-kanban: tight spacing, 0.5rem radius.
|
||||
*/
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: 223 23% 6%; /* #0c0e13 */
|
||||
--foreground: 225 18% 92%; /* #e6e8ee */
|
||||
|
||||
--card: 220 19% 13%; /* #1a1e26 */
|
||||
--card-foreground: 225 18% 92%;
|
||||
|
||||
--popover: 222 22% 9%; /* #12151c */
|
||||
--popover-foreground: 225 18% 92%;
|
||||
|
||||
--primary: 251 100% 68%; /* #7a5cff accent purple */
|
||||
--primary-foreground: 223 23% 6%;
|
||||
|
||||
--secondary: 220 19% 13%;
|
||||
--secondary-foreground: 225 18% 92%;
|
||||
|
||||
--muted: 222 22% 9%;
|
||||
--muted-foreground: 221 14% 60%; /* #8a93a6 */
|
||||
|
||||
--accent: 220 19% 16%; /* subtle hover surface */
|
||||
--accent-foreground: 225 18% 92%;
|
||||
|
||||
--destructive: 345 86% 73%; /* #f57f9c */
|
||||
--destructive-foreground: 223 23% 6%;
|
||||
|
||||
--border: 218 21% 17%; /* #232a36 */
|
||||
--input: 221 19% 23%; /* #2f3645 */
|
||||
--ring: 251 100% 68%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Pipeline status palette (foreground + tinted surface). */
|
||||
--status-active: 149 72% 67%; /* #6ee7a8 */
|
||||
--status-active-bg: 155 45% 16%; /* #163a2b */
|
||||
--status-waiting: 47 87% 70%; /* #f5d76e */
|
||||
--status-waiting-bg: 47 45% 16%; /* #3a3216 */
|
||||
--status-parked: 24 85% 69%; /* #f3a36e */
|
||||
--status-parked-bg: 15 45% 16%; /* #3a1f16 */
|
||||
--status-done: 214 86% 73%; /* #7fb3f5 */
|
||||
--status-done-bg: 210 35% 17%; /* #1c2b3a */
|
||||
--status-failed: 345 86% 73%; /* #f57f9c */
|
||||
--status-failed-bg: 343 45% 16%; /* #3a1620 */
|
||||
|
||||
/* Brand gradient stops (logo dot, accents). */
|
||||
--brand-from: 251 100% 68%;
|
||||
--brand-to: 215 66% 55%; /* #3f7fd8 */
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: theme("fontFamily.sans");
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.mono {
|
||||
font-family: theme("fontFamily.mono");
|
||||
}
|
||||
}
|
||||
|
||||
/* React Flow: hide the attribution badge (LAN-only, offline). */
|
||||
.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));
|
||||
}
|
||||
55
agent-team/web/src/lib/time.test.ts
Normal file
55
agent-team/web/src/lib/time.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatEastern, parseServerDate } from "./time";
|
||||
|
||||
describe("formatEastern", () => {
|
||||
it("formats the snapshot 'YYYY-MM-DD HH:MM:SS UTC' shape as Eastern 24h (EDT in summer)", () => {
|
||||
// 22:54:48 UTC on 2026-06-24 is 18:54:48 EDT (UTC-4).
|
||||
expect(formatEastern("2026-06-24 22:54:48 UTC")).toBe(
|
||||
"2026-06-24 18:54:48 EDT",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats ISO offset timestamps as Eastern 24h", () => {
|
||||
expect(formatEastern("2026-06-24T22:48:10.437876+00:00")).toBe(
|
||||
"2026-06-24 18:48:10 EDT",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses EST in winter (DST-aware)", () => {
|
||||
// 12:00 UTC on 2026-01-15 is 07:00 EST (UTC-5).
|
||||
expect(formatEastern("2026-01-15 12:00:00 UTC")).toBe(
|
||||
"2026-01-15 07:00:00 EST",
|
||||
);
|
||||
});
|
||||
|
||||
it("crosses the day boundary correctly (late UTC -> earlier Eastern, prior day)", () => {
|
||||
// 02:30 UTC on 2026-06-25 is 22:30 EDT on 2026-06-24.
|
||||
expect(formatEastern("2026-06-25T02:30:00+00:00")).toBe(
|
||||
"2026-06-24 22:30:00 EDT",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the em-dash for null / undefined / unparseable input", () => {
|
||||
expect(formatEastern(null)).toBe("—");
|
||||
expect(formatEastern(undefined)).toBe("—");
|
||||
expect(formatEastern("not a date")).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseServerDate", () => {
|
||||
it("treats the bare 'UTC'-suffixed shape as UTC", () => {
|
||||
const d = parseServerDate("2026-06-24 22:54:48 UTC");
|
||||
expect(d?.toISOString()).toBe("2026-06-24T22:54:48.000Z");
|
||||
});
|
||||
|
||||
it("parses ISO offsets", () => {
|
||||
const d = parseServerDate("2026-06-24T22:48:10.000+00:00");
|
||||
expect(d?.toISOString()).toBe("2026-06-24T22:48:10.000Z");
|
||||
});
|
||||
|
||||
it("returns null for empty/unparseable input", () => {
|
||||
expect(parseServerDate(null)).toBeNull();
|
||||
expect(parseServerDate("")).toBeNull();
|
||||
expect(parseServerDate("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
43
agent-team/web/src/lib/time.ts
Normal file
43
agent-team/web/src/lib/time.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Time formatting for the dashboard. All backend timestamps are UTC; we render
|
||||
// them in US Eastern (America/New_York — EDT in summer, EST in winter, DST-aware)
|
||||
// as 24-hour `YYYY-MM-DD HH:MM:SS ZZZ`.
|
||||
//
|
||||
// The backend emits two shapes:
|
||||
// - "2026-06-24 22:54:48 UTC" (status snapshot generated_at)
|
||||
// - "2026-06-24T22:48:10.437876+00:00" (ISO transition timestamps)
|
||||
// parseServerDate normalizes both to a Date; everything else is ISO that the
|
||||
// JS engine parses natively.
|
||||
|
||||
const EASTERN_FMT = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23", // 24-hour, 00–23 (never "24:xx")
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
/** Parse a backend timestamp (UTC) into a Date, or null if unparseable. */
|
||||
export function parseServerDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
let s = String(raw).trim();
|
||||
// "YYYY-MM-DD HH:MM:SS UTC" -> ISO "YYYY-MM-DDTHH:MM:SSZ"
|
||||
if (s.endsWith(" UTC")) s = s.slice(0, -4).trim().replace(" ", "T") + "Z";
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a backend UTC timestamp as Eastern 24-hour time:
|
||||
* "2026-06-24 18:54:48 EDT". Returns "—" for null/unparseable input.
|
||||
*/
|
||||
export function formatEastern(raw: string | null | undefined): string {
|
||||
const d = parseServerDate(raw);
|
||||
if (!d) return "—";
|
||||
const p: Record<string, string> = {};
|
||||
for (const part of EASTERN_FMT.formatToParts(d)) p[part.type] = part.value;
|
||||
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second} ${p.timeZoneName}`;
|
||||
}
|
||||
7
agent-team/web/src/lib/utils.ts
Normal file
7
agent-team/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** shadcn/ui class-name helper: merge conditional classes, dedupe Tailwind. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./theme.css";
|
||||
import "./index.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; }
|
||||
100
agent-team/web/tailwind.config.js
Normal file
100
agent-team/web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import animate from "tailwindcss-animate";
|
||||
|
||||
/**
|
||||
* Tailwind config for the agent-team dashboard.
|
||||
*
|
||||
* Colors are driven by HSL CSS variables defined in src/index.css (shadcn/ui
|
||||
* convention) so the dark theme stays in one place. On top of the standard
|
||||
* shadcn tokens we add pipeline `status.*` colors (active/waiting/parked/done/
|
||||
* failed) that the Board, MapNode, and badges share.
|
||||
*/
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
// Pipeline status palette — each has a tinted surface (`bg`) and a
|
||||
// bright foreground (`fg`), mirroring the original theme.css mapping.
|
||||
status: {
|
||||
active: "hsl(var(--status-active))",
|
||||
"active-bg": "hsl(var(--status-active-bg))",
|
||||
waiting: "hsl(var(--status-waiting))",
|
||||
"waiting-bg": "hsl(var(--status-waiting-bg))",
|
||||
parked: "hsl(var(--status-parked))",
|
||||
"parked-bg": "hsl(var(--status-parked-bg))",
|
||||
done: "hsl(var(--status-done))",
|
||||
"done-bg": "hsl(var(--status-done-bg))",
|
||||
failed: "hsl(var(--status-failed))",
|
||||
"failed-bg": "hsl(var(--status-failed-bg))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
};
|
||||
|
|
@ -15,6 +15,10 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["node", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/// <reference types="vitest/config" />
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
|
|
@ -8,10 +9,45 @@ import react from "@vitejs/plugin-react";
|
|||
// locally (the dashboard is offline/LAN-only).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Split heavy vendors into their own chunks so the app/UI code isn't
|
||||
// re-downloaded when only a component changes, and to clear the 500 kB
|
||||
// single-chunk warning. Function form (not object) so react-dom and
|
||||
// transitive deps bucket precisely instead of collapsing into a stub.
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) return;
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id))
|
||||
return "react";
|
||||
if (id.includes("reactflow") || id.includes("dagre")) return "flow";
|
||||
if (id.includes("@radix-ui")) return "radix";
|
||||
if (
|
||||
id.includes("react-markdown") ||
|
||||
id.includes("micromark") ||
|
||||
id.includes("mdast") ||
|
||||
id.includes("hast") ||
|
||||
id.includes("unist") ||
|
||||
id.includes("unified") ||
|
||||
id.includes("remark") ||
|
||||
id.includes("vfile") ||
|
||||
id.includes("property-information") ||
|
||||
id.includes("/decode-named-character-reference/") ||
|
||||
id.includes("character-entities")
|
||||
)
|
||||
return "markdown";
|
||||
return "vendor";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
|
|
|
|||
Reference in a new issue