feat(agent-team): Confluence-writer node (draft → approve gate → dry-run write) [flag-gated] #66

Closed
amoussa1229 wants to merge 10 commits from feat/agent-team-confluence-node into main
21 changed files with 1788 additions and 71 deletions
Showing only changes of commit 8530074551 - Show all commits

View file

@ -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"

View file

@ -0,0 +1,7 @@
dist
node_modules
coverage
package-lock.json
*.log
playwright-report
test-results

View file

@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80
}

View 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();
});

View 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,
);

File diff suppressed because it is too large Load diff

View file

@ -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"
}

View 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,
},
});

View file

@ -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()}`,
);

View file

@ -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: [],
};

View file

@ -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}

View file

@ -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>
)}

View file

@ -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">

View file

@ -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();
});

View file

@ -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>
))}

View file

@ -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">

View file

@ -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 };

View file

@ -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);

View file

@ -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);
}

View file

@ -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", () => {

View file

@ -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();