ci(agent-team-web): wire Prettier + ESLint + Playwright and enable the CI steps (#65)
- Prettier 3.6.2: .prettierrc.json + .prettierignore + format/format:check scripts; formatted the web/ tree. - ESLint 9 flat config (@eslint/js + typescript-eslint + react-hooks + react-refresh, eslint-config-prettier last so Prettier owns formatting); lint script; src/components/ui/** opts out of react-refresh (shadcn primitives co-export their cva variants). 0 problems. - Playwright 1.61.1: playwright.config.ts (build+preview webServer) + an e2e Board-renders smoke against a mocked /api; test:e2e script. Reusable workflow installs chromium itself. - ci-web.yaml: required-scripts -> format:check,lint,build,test,test:e2e; run-format-check/lint/e2e -> true; e2e-browser chromium. All five scripts verified green locally.
This commit is contained in:
parent
9ee2e3c127
commit
8530074551
21 changed files with 1788 additions and 71 deletions
18
.github/workflows/ci-web.yaml
vendored
18
.github/workflows/ci-web.yaml
vendored
|
|
@ -5,10 +5,11 @@ name: 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.
|
||||
# package.json exposes the full reusable-workflow script set: format:check
|
||||
# (Prettier), lint (ESLint flat config), build (`tsc -b && vite build`), test
|
||||
# (vitest), and test:e2e (Playwright chromium browser smoke against a mocked
|
||||
# /api). All reusable steps are enabled (#65). The reusable workflow installs the
|
||||
# Playwright browser itself (`npx playwright install --with-deps chromium`).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -27,7 +28,8 @@ jobs:
|
|||
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
|
||||
required-scripts: "format:check,lint,build,test,test:e2e"
|
||||
run-format-check: true
|
||||
run-lint: true
|
||||
run-e2e: true
|
||||
e2e-browser: "chromium"
|
||||
|
|
|
|||
7
agent-team/web/.prettierignore
Normal file
7
agent-team/web/.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
dist
|
||||
node_modules
|
||||
coverage
|
||||
package-lock.json
|
||||
*.log
|
||||
playwright-report
|
||||
test-results
|
||||
6
agent-team/web/.prettierrc.json
Normal file
6
agent-team/web/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80
|
||||
}
|
||||
74
agent-team/web/e2e/dashboard.smoke.spec.ts
Normal file
74
agent-team/web/e2e/dashboard.smoke.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Minimal browser smoke: the dashboard shell mounts and renders live `/api/state`
|
||||
// into the Board. The backend is fully mocked via route fulfilment so the test
|
||||
// needs no coordinator, DB, or network — it asserts the read path end to end
|
||||
// (fetch → state → Board card), which a unit/jsdom test does not exercise.
|
||||
|
||||
const TOPOLOGY = {
|
||||
trees: [{ id: "main", label: "Main", root: true }],
|
||||
nodes: [
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
agent: "planner",
|
||||
tree: "main",
|
||||
kind: "stage",
|
||||
gated: false,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const SMOKE_TASK = {
|
||||
thread_id: "t-smoke-0001",
|
||||
short_id: "t-smoke",
|
||||
task: "Playwright smoke task",
|
||||
current_phase: "plan",
|
||||
status: "active",
|
||||
waiting: false,
|
||||
waiting_since: null,
|
||||
};
|
||||
|
||||
const STATE = {
|
||||
ok: true,
|
||||
error: null,
|
||||
generated_at: "2026-06-24T00:00:00Z",
|
||||
db_path: ":memory:",
|
||||
stages: [
|
||||
{
|
||||
key: "plan",
|
||||
label: "Plan",
|
||||
agent: "planner",
|
||||
phases: ["plan"],
|
||||
gated: false,
|
||||
role_node: true,
|
||||
state: "active",
|
||||
count: 1,
|
||||
tasks: [SMOKE_TASK],
|
||||
},
|
||||
],
|
||||
tasks: [SMOKE_TASK],
|
||||
waiting: [],
|
||||
question_counts: {},
|
||||
summary: { active: 1, parked: 0, waiting: 0, open_questions: 0, total: 1 },
|
||||
budget: { available: false },
|
||||
nodes: { plan: { state: "active", count: 1 } },
|
||||
};
|
||||
|
||||
test("dashboard Board renders live state from a mocked /api", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/topology*", (route) =>
|
||||
route.fulfill({ json: TOPOLOGY }),
|
||||
);
|
||||
await page.route("**/api/state*", (route) => route.fulfill({ json: STATE }));
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// App shell mounted: the Board / Pipeline tabs render.
|
||||
await expect(page.getByRole("tab", { name: /board/i })).toBeVisible();
|
||||
// The mocked live state flowed through fetch → state → the Board column.
|
||||
await expect(page.getByText("Plan", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Playwright smoke task")).toBeVisible();
|
||||
});
|
||||
51
agent-team/web/eslint.config.js
Normal file
51
agent-team/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import prettier from "eslint-config-prettier";
|
||||
|
||||
// Flat config for the agent-team dashboard SPA (Vite + React + TS). Lints the
|
||||
// `src/` tree only; build/test config files are excluded. `eslint-config-prettier`
|
||||
// is applied LAST so Prettier owns all formatting (no rule conflicts with
|
||||
// `format:check`).
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist",
|
||||
"coverage",
|
||||
"node_modules",
|
||||
"playwright-report",
|
||||
"test-results",
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// shadcn/ui primitives intentionally co-export their `cva` variants
|
||||
// (e.g. `buttonVariants`) alongside the component — the documented pattern;
|
||||
// they are static library primitives, not HMR-sensitive app screens.
|
||||
files: ["src/components/ui/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
1364
agent-team/web/package-lock.json
generated
1364
agent-team/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,11 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "tsc -b",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint .",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "1.1.4",
|
||||
|
|
@ -31,6 +35,8 @@
|
|||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@testing-library/jest-dom": "6.4.8",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@types/node": "20.14.15",
|
||||
|
|
@ -38,11 +44,18 @@
|
|||
"@types/react-dom": "18.3.0",
|
||||
"@vitejs/plugin-react": "4.3.1",
|
||||
"autoprefixer": "^10.5.1",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"globals": "16.5.0",
|
||||
"jsdom": "24.1.3",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "3.6.2",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.5.4",
|
||||
"typescript-eslint": "8.46.0",
|
||||
"vite": "5.4.8",
|
||||
"vitest": "1.6.0"
|
||||
}
|
||||
|
|
|
|||
26
agent-team/web/playwright.config.ts
Normal file
26
agent-team/web/playwright.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Browser smoke for the read-only dashboard SPA. The e2e mocks the `/api`
|
||||
// backend (Playwright route fulfilment), so no live coordinator / DB is needed.
|
||||
// The webServer builds + serves the production bundle via `vite preview`.
|
||||
const PORT = 4173;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
command: `npm run build && npm run preview -- --port ${PORT} --strictPort`,
|
||||
url: `http://localhost:${PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
|
|
@ -116,4 +116,6 @@ export const fetchTopology = (): Promise<Topology> =>
|
|||
getJSON<Topology>("/api/topology");
|
||||
|
||||
export const fetchTask = (threadId: string): Promise<TaskDetail> =>
|
||||
getJSON<TaskDetail>(`/api/task/${encodeURIComponent(threadId)}?ts=${Date.now()}`);
|
||||
getJSON<TaskDetail>(
|
||||
`/api/task/${encodeURIComponent(threadId)}?ts=${Date.now()}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,9 +16,30 @@ const TOPO: Topology = {
|
|||
{ 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 },
|
||||
{
|
||||
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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { buildTreeByNode, filterTasks, phaseToNode } from "../taskFilters";
|
||||
|
|
@ -146,7 +150,10 @@ export function Board({
|
|||
/>
|
||||
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Filter by status">
|
||||
<SelectTrigger
|
||||
className="h-8 w-40 text-sm"
|
||||
aria-label="Filter by status"
|
||||
>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -159,11 +166,16 @@ export function Board({
|
|||
</Select>
|
||||
|
||||
<Select value={tree} onValueChange={setTree}>
|
||||
<SelectTrigger className="h-8 w-36 text-sm" aria-label="Filter by tree">
|
||||
<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>
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RFData } from "../layout";
|
||||
|
||||
|
|
@ -19,7 +23,8 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
|
|||
"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 === "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
|
||||
|
|
@ -49,7 +54,10 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
|
|||
|
||||
{meta.kind === "gate" && (
|
||||
<span className="inline-flex" title="human gate">
|
||||
<Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<Lock
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export function PipelineMap({
|
|||
[topology, state, pathNodeIds, loopedEdges],
|
||||
);
|
||||
|
||||
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
|
||||
const handleNodeClick: NodeMouseHandler = (_evt, node) =>
|
||||
onNodeClick(node.id);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" aria-label="pipeline map">
|
||||
|
|
|
|||
|
|
@ -12,8 +12,26 @@ const DETAIL: TaskDetail = {
|
|||
current_phase: "done",
|
||||
partial: false,
|
||||
timeline: [
|
||||
{ from_phase: null, to_phase: "intake", entered_at: "2026-06-23T00:00:00+00:00", exited_at: "2026-06-23T00:00:01+00:00", duration_s: 1, status: "active", note: null, cost_usd: 0 },
|
||||
{ from_phase: "intake", to_phase: "plan", entered_at: "2026-06-23T00:00:01+00:00", exited_at: null, duration_s: null, status: "active", note: null, cost_usd: 0.12 },
|
||||
{
|
||||
from_phase: null,
|
||||
to_phase: "intake",
|
||||
entered_at: "2026-06-23T00:00:00+00:00",
|
||||
exited_at: "2026-06-23T00:00:01+00:00",
|
||||
duration_s: 1,
|
||||
status: "active",
|
||||
note: null,
|
||||
cost_usd: 0,
|
||||
},
|
||||
{
|
||||
from_phase: "intake",
|
||||
to_phase: "plan",
|
||||
entered_at: "2026-06-23T00:00:01+00:00",
|
||||
exited_at: null,
|
||||
duration_s: null,
|
||||
status: "active",
|
||||
note: null,
|
||||
cost_usd: 0.12,
|
||||
},
|
||||
],
|
||||
qa_history: [{ turn: 0, answer: "scope it" }],
|
||||
review_verdicts: [],
|
||||
|
|
@ -24,7 +42,14 @@ const DETAIL: TaskDetail = {
|
|||
|
||||
describe("TaskDrawer", () => {
|
||||
it("renders the timeline steps with the task description and spend", () => {
|
||||
render(<TaskDrawer detail={DETAIL} loading={false} error={null} onClose={() => {}} />);
|
||||
render(
|
||||
<TaskDrawer
|
||||
detail={DETAIL}
|
||||
loading={false}
|
||||
error={null}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("remediate CVE-2026-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("intake")).toBeInTheDocument();
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
|
|
@ -46,7 +71,12 @@ describe("TaskDrawer", () => {
|
|||
|
||||
it("renders nothing when idle (no detail, not loading)", () => {
|
||||
const { container } = render(
|
||||
<TaskDrawer detail={null} loading={false} error={null} onClose={() => {}} />,
|
||||
<TaskDrawer
|
||||
detail={null}
|
||||
loading={false}
|
||||
error={null}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
|
@ -63,7 +93,9 @@ describe("TaskDrawer", () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
|
||||
render(
|
||||
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("Missing phase").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("run the tests").closest("li")).not.toBeNull();
|
||||
expect(screen.getByText("request changes")).toBeInTheDocument();
|
||||
|
|
@ -74,10 +106,14 @@ describe("TaskDrawer", () => {
|
|||
...DETAIL,
|
||||
plan: {
|
||||
summary: "Add a smoke test",
|
||||
phases: [{ name: "Recon", steps: ["read conftest", "read test_graph"] }],
|
||||
phases: [
|
||||
{ name: "Recon", steps: ["read conftest", "read test_graph"] },
|
||||
],
|
||||
},
|
||||
};
|
||||
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
|
||||
render(
|
||||
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("Recon")).toBeInTheDocument();
|
||||
expect(screen.getByText("read conftest").closest("li")).not.toBeNull();
|
||||
});
|
||||
|
|
@ -85,9 +121,14 @@ describe("TaskDrawer", () => {
|
|||
it("renders both string and dict Q&A entries", () => {
|
||||
const d: TaskDetail = {
|
||||
...DETAIL,
|
||||
qa_history: ["plain answer text", { turn: 1, answer: "dict answer text" }],
|
||||
qa_history: [
|
||||
"plain answer text",
|
||||
{ turn: 1, answer: "dict answer text" },
|
||||
],
|
||||
};
|
||||
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
|
||||
render(
|
||||
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("plain answer text")).toBeInTheDocument();
|
||||
expect(screen.getByText("dict answer text")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,8 +38,7 @@ 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";
|
||||
const isClarifyGate = isWaiting && detail?.current_phase === "clarify";
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -167,7 +166,9 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
|||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{open ? "in progress" : fmtDuration(step.duration_s)}
|
||||
{open
|
||||
? "in progress"
|
||||
: fmtDuration(step.duration_s)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
|
|
@ -177,7 +178,10 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
|||
</span>
|
||||
)}
|
||||
{step.status && (
|
||||
<Badge variant={statusVariant(step.status)} className="text-[10px] py-0 px-1.5">
|
||||
<Badge
|
||||
variant={statusVariant(step.status)}
|
||||
className="text-[10px] py-0 px-1.5"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
|
|
@ -278,7 +282,11 @@ function CollapsibleSection({
|
|||
}
|
||||
|
||||
function asText(v: unknown): string {
|
||||
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
|
||||
return typeof v === "string"
|
||||
? v
|
||||
: v == null
|
||||
? ""
|
||||
: JSON.stringify(v, null, 2);
|
||||
}
|
||||
|
||||
// Q&A: each entry is the answer text for a turn (a string), or an older
|
||||
|
|
@ -289,9 +297,12 @@ function QASection({ entries }: { entries: unknown[] }) {
|
|||
<div className="space-y-2">
|
||||
{entries.map((entry, i) => {
|
||||
const obj =
|
||||
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
|
||||
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);
|
||||
const answer =
|
||||
obj && "answer" in obj ? asText(obj.answer) : asText(entry);
|
||||
return (
|
||||
<Card key={i} className="bg-background">
|
||||
<CardHeader className="pb-1 pt-2 px-3">
|
||||
|
|
@ -316,7 +327,9 @@ function VerdictsSection({ entries }: { entries: unknown[] }) {
|
|||
<div className="space-y-2">
|
||||
{entries.map((entry, i) => {
|
||||
const v =
|
||||
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: {};
|
||||
const verdict = asText(v.verdict) || "verdict";
|
||||
const round = "round_index" in v ? Number(v.round_index) : i + 1;
|
||||
const outcome = asText(v.outcome);
|
||||
|
|
@ -358,7 +371,9 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
|
|||
const phases = Array.isArray(plan.phases) ? plan.phases : null;
|
||||
const extra = Object.entries(plan).filter(
|
||||
([k, val]) =>
|
||||
k !== "summary" && k !== "phases" && (typeof val !== "object" || val === null),
|
||||
k !== "summary" &&
|
||||
k !== "phases" &&
|
||||
(typeof val !== "object" || val === null),
|
||||
);
|
||||
|
||||
if (!summary && !phases) {
|
||||
|
|
@ -409,7 +424,9 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
|
|||
<dl className="space-y-1">
|
||||
{extra.map(([k, val]) => (
|
||||
<div key={k} className="flex gap-2 text-xs">
|
||||
<dt className="text-muted-foreground font-medium shrink-0">{k}</dt>
|
||||
<dt className="text-muted-foreground font-medium shrink-0">
|
||||
{k}
|
||||
</dt>
|
||||
<dd className="text-foreground truncate">{asText(val)}</dd>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,12 @@ export function TopBar({ state, connected, generatedAt }: Props) {
|
|||
c.bgCls,
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm font-bold leading-none tabular-nums", c.numCls)}>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
position === "popper" &&
|
||||
"w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -76,10 +77,4 @@ const SelectItem = React.forwardRef<
|
|||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
};
|
||||
export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem };
|
||||
|
|
|
|||
|
|
@ -8,10 +8,38 @@ const TOPO: Topology = {
|
|||
{ id: "sdlc", label: "SDLC", root: false },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
|
||||
{ id: "clarify", label: "Clarify", agent: "Claude", tree: "sdlc", kind: "gate", gated: false },
|
||||
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
|
||||
{ id: "review", label: "Review", agent: "GPT-4.1", tree: "sdlc", kind: "phase", gated: false },
|
||||
{
|
||||
id: "intake",
|
||||
label: "Intake",
|
||||
agent: "coordinator",
|
||||
tree: "core",
|
||||
kind: "phase",
|
||||
gated: false,
|
||||
},
|
||||
{
|
||||
id: "clarify",
|
||||
label: "Clarify",
|
||||
agent: "Claude",
|
||||
tree: "sdlc",
|
||||
kind: "gate",
|
||||
gated: false,
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
agent: "Claude",
|
||||
tree: "sdlc",
|
||||
kind: "phase",
|
||||
gated: false,
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
agent: "GPT-4.1",
|
||||
tree: "sdlc",
|
||||
kind: "phase",
|
||||
gated: false,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: "intake", to: "clarify", kind: "spine" },
|
||||
|
|
@ -36,7 +64,9 @@ describe("layout", () => {
|
|||
|
||||
it("populates loopBackTo on the loop-back source node (drives the ↺ chip)", () => {
|
||||
const { nodes } = layout(TOPO, {}, null);
|
||||
expect(nodes.find((n) => n.id === "review")!.data.loopBackTo).toEqual(["plan"]);
|
||||
expect(nodes.find((n) => n.id === "review")!.data.loopBackTo).toEqual([
|
||||
"plan",
|
||||
]);
|
||||
expect(nodes.find((n) => n.id === "plan")!.data.loopBackTo).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -52,7 +82,11 @@ describe("layout", () => {
|
|||
});
|
||||
|
||||
it("applies live state + count to the matching node", () => {
|
||||
const { nodes } = layout(TOPO, { clarify: { state: "awaiting_human", count: 2 } }, null);
|
||||
const { nodes } = layout(
|
||||
TOPO,
|
||||
{ clarify: { state: "awaiting_human", count: 2 } },
|
||||
null,
|
||||
);
|
||||
const clarify = nodes.find((n) => n.id === "clarify")!;
|
||||
expect(clarify.data.state).toBe("awaiting_human");
|
||||
expect(clarify.data.count).toBe(2);
|
||||
|
|
|
|||
|
|
@ -35,12 +35,19 @@ export function layout(
|
|||
loopedEdges: Set<string> | null = null,
|
||||
): { nodes: Node<RFData>[]; edges: Edge[] } {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 });
|
||||
g.setGraph({
|
||||
rankdir: "LR",
|
||||
nodesep: 36,
|
||||
ranksep: 72,
|
||||
marginx: 24,
|
||||
marginy: 24,
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Lay out using ONLY forward edges, so the backward loop-backs don't distort
|
||||
// the left-to-right ranking.
|
||||
for (const n of topo.nodes) g.setNode(n.id, { width: NODE_W, height: NODE_H });
|
||||
for (const n of topo.nodes)
|
||||
g.setNode(n.id, { width: NODE_W, height: NODE_H });
|
||||
for (const e of topo.edges) {
|
||||
if (FORWARD.has(e.kind)) g.setEdge(e.from, e.to);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ describe("phaseToNode", () => {
|
|||
|
||||
describe("filterTasks", () => {
|
||||
it("returns all tasks when no filters applied", () => {
|
||||
const tasks = [task(), task({ thread_id: "bbbb2222", short_id: "bbbb2222" })];
|
||||
const tasks = [
|
||||
task(),
|
||||
task({ thread_id: "bbbb2222", short_id: "bbbb2222" }),
|
||||
];
|
||||
expect(filterTasks(tasks, BASE_PARAMS)).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
|
@ -73,7 +76,9 @@ describe("filterTasks", () => {
|
|||
|
||||
it("query matching is case-insensitive", () => {
|
||||
const tasks = [task({ task: "Remediate CVE" })];
|
||||
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" })).toHaveLength(1);
|
||||
expect(
|
||||
filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by status", () => {
|
||||
|
|
@ -91,13 +96,15 @@ describe("filterTasks", () => {
|
|||
task({ status: "active" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", status: "done" }),
|
||||
];
|
||||
expect(filterTasks(tasks, { ...BASE_PARAMS, status: "all" })).toHaveLength(2);
|
||||
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
|
||||
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);
|
||||
|
|
@ -119,7 +126,10 @@ describe("filterTasks", () => {
|
|||
task({ current_phase: "build" }),
|
||||
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }),
|
||||
];
|
||||
const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "build_node" });
|
||||
const result = filterTasks(tasks, {
|
||||
...BASE_PARAMS,
|
||||
nodeFilter: "build_node",
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].current_phase).toBe("build");
|
||||
});
|
||||
|
|
@ -127,17 +137,33 @@ describe("filterTasks", () => {
|
|||
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" }),
|
||||
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" });
|
||||
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);
|
||||
expect(
|
||||
filterTasks(tasks, { ...BASE_PARAMS, query: "nonexistent" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty task list", () => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ export function phaseToNode(phase: string): string {
|
|||
}
|
||||
|
||||
/** Build a node-id -> tree-id lookup from topology. */
|
||||
export function buildTreeByNode(topology: Topology | null): Record<string, string> {
|
||||
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;
|
||||
|
|
@ -23,14 +25,17 @@ export function buildTreeByNode(topology: Topology | null): Record<string, strin
|
|||
|
||||
export interface FilterParams {
|
||||
query: string;
|
||||
status: string; // "all" | task status value
|
||||
tree: string; // "all" | tree id
|
||||
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[] {
|
||||
export function filterTasks(
|
||||
tasks: TaskView[],
|
||||
params: FilterParams,
|
||||
): TaskView[] {
|
||||
const { query, status, tree, nodeFilter, treeByNode } = params;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
|
|
|
|||
Reference in a new issue