Merge pull request #19: Damascus Entry Points P5: damascus-ui v2 (ingest + 4 widgets + project-grouped dashboard)
Some checks failed
test / contract-and-unit (push) Failing after 15s

This commit is contained in:
damascus-heartbeat
2026-06-25 12:29:43 +00:00
32 changed files with 3945 additions and 204 deletions

View File

@@ -0,0 +1,863 @@
# Damascus Entry Points P5: damascus-ui v2 — Implementation Plan
> **For Hermes:** Use subagent-driven-development OR execute directly. TDD: tests first.
**Goal:** Add the ingest form (`/ingest`), answer form (in drawer), project-grouped dashboard, and the four "self-improving" widgets from contract §7. End state: `npm run build && npm run test:e2e` exit 0; mobile viewport passes; PR open.
**Architecture:** Extend the v1 SPA at `ui/` (React 19 + Vite 6 + MUI 6 + React Query). All new server interaction uses the existing `api/client.ts` POST wrapper extended with `Authorization: Bearer <DAMASCUS_API_TOKEN>`. New `/ingest` route and new `widgets/` subfolder. The e2e fixture API (used because no live damascus-api runs in CI) is extended to accept the new POST endpoints and the new `?group_by=project` query param. The Pydantic schema gets a minimal `ListItemsQuery.group_by` extension (with a new `GroupedItemsResponse` shape) and the contract page gets one row in §3 and one bullet in §8.
**Tech Stack:** TypeScript, React 19, Vite 6, MUI 6, React Query 5, react-router-free hash router, Playwright 1.61. Pydantic v2 (api_schemas.py), FastAPI (fixture_api.py).
---
## 0. Background reading (must do before coding)
- `ui/src/router.ts` — hash router API (`useRoute`, `navigate`, `setOpenItem`)
- `ui/src/api/client.ts` — fetch wrapper (no auth header in v1)
- `ui/src/api/queries.ts` — React Query hooks; `useStats` (5s polling) is the model for the live widgets
- `ui/src/types.ts` — TS mirrors of Pydantic schemas
- `ui/src/routes/Dashboard.tsx` — current v1 dashboard, already renders phase bar inline
- `ui/src/routes/ItemDrawer.tsx` — current v1 drawer, has open_issues + recent_events sections
- `ui/src/main.tsx` — theme + QueryClient
- `src/damascus/api_schemas.py``IngestStoryRequest`, `AnswerIssueRequest`, `CostSummaryResponse`
- `wiki/concepts/entry-points-contract.md` §3 (query params) and §7 (4 widgets)
- `ui/tests/e2e/fixture_api.py` — local FastAPI the e2e suite runs against
## 1. Slice ordering
Each slice is a self-contained, committable increment. The plan goes widget-by-widget (4 small slices) before doing the larger route work (Ingest, project-grouped Dashboard, answer form), then the e2e suite last. Reason: widget work is small and isolated, and the test file for v2 is much easier to write once all the new pieces exist.
| # | Slice | Approx. files |
|---|-------|---------------|
| A | API types + client auth + React Query hooks | 3 |
| B | Extend fixture_api.py for the new endpoints | 1 |
| C | PhaseBar widget (live, polled) | 1 |
| D | OpenIssues widget (count + last 5 inline) | 1 |
| E | BlockedItems widget (last_verdict + last_feedback) | 1 |
| F | CostSparkline widget (SVG, 7 days) | 1 |
| G | Extend Dashboard to project-grouped + mount 4 widgets | 1 |
| H | Extend ItemDrawer with answer form | 1 |
| I | Router + App: add `/ingest` route | 2 |
| J | Ingest form route | 1 |
| K | Contract: §3 group_by row + §8 P5 note | 1 |
| L | api_schemas.py: group_by + GroupedItemsResponse | 1 |
| M | Playwright e2e: 3 scenarios (ingest, dashboard widgets, answer) | 1 |
| N | Build, test:e2e, mobile viewport, commit, PR | — |
Each task below is 25 min. Every UI change ships with a test (component test in `tests/` is optional for widgets; the widget behavior is exercised by the Playwright suite at slice M). Every testable API helper gets a vitest unit test (we add a `vitest.config.ts` if there isn't one — see Task A3).
---
## Task A1: Extend ui/src/types.ts with the new Pydantic mirrors
**Files:** `ui/src/types.ts` (modify)
Add:
```ts
export interface IngestStoryRequest {
project: string; // 1..64
story_id: string; // 1..128
title: string; // 1..255
file_scope: string[]; // default []
priority: number; // 0..1000, default 100
budget_cycles: number; // 1..10, default 3
}
export interface IngestStoryResponse {
item: WorkItem;
created: boolean;
}
export interface AnswerIssueRequest {
answer: string; // 1..10_000
}
export interface AnswerIssueResponse {
id: string;
work_item_id: string;
question: string;
answer: string;
status: IssueStatus;
created_at: string;
answered_at: string;
}
export interface CostDay {
date: string; // YYYY-MM-DD
usd: string; // serialized Decimal
}
export interface CostSummaryResponse {
total_usd: string;
by_project: Record<string, string>;
by_model: Record<string, string>;
by_day: Record<string, string>;
window_start: string;
window_end: string;
}
export interface ProjectGroup {
project: string;
items: WorkItem[];
phase_counts: Record<WorkItemPhase, number>;
}
export interface GroupedItemsResponse {
groups: ProjectGroup[];
total_items: number;
total_projects: number;
}
```
Add `group_by` to `ListItemsQueryParams`:
```ts
group_by?: "project"; // currently only one valid value
```
**Verify:** `cd ui && npm run typecheck` exits 0.
**Commit:** `types(ui): add IngestStoryRequest/Response, AnswerIssueRequest/Response, CostSummaryResponse, ProjectGroup (P5)`
---
## Task A2: Extend ui/src/api/client.ts with POST + Authorization
**Files:** `ui/src/api/client.ts` (modify)
The v1 `api.post` exists but does NOT send an auth header. The contract says writes need `Authorization: Bearer <DAMASCUS_API_TOKEN>`, baked at build time (LAN-trusted). Add:
```ts
const WRITE_TOKEN =
(import.meta.env.VITE_API_WRITE_TOKEN as string | undefined) ?? "";
```
In the `request()` function, when `body !== undefined` and `WRITE_TOKEN` is non-empty, set:
```ts
(init.headers as Record<string, string>)["Authorization"] = `Bearer ${WRITE_TOKEN}`;
```
Keep the existing GET behavior unchanged. No new exports.
**Verify:** `npm run typecheck` exits 0.
**Commit:** `feat(ui): api client sends Authorization on writes (P5)`
---
## Task A3: Add vitest config + first unit test (RED-GREEN-REFACTOR)
**Files:**
- `ui/vitest.config.ts` (create)
- `ui/tests/unit/api_client.test.ts` (create)
**Why:** The TDD skill is "tests before code." Slice A's API client change (the Authorization header) is best tested with vitest. Without a unit test, the Authorization behavior ships untested until the Playwright suite at the end — too late to refactor safely.
**Step 1 — Write failing test** (`tests/unit/api_client.test.ts`):
```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ApiError } from "../../src/api/client";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => {
vi.resetModules();
mockFetch.mockReset();
});
describe("api client auth", () => {
it("sends Authorization Bearer header on POST when VITE_API_WRITE_TOKEN is set", async () => {
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
const { api } = await import("../../src/api/client");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true }),
});
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
const [, init] = mockFetch.mock.calls[0];
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer test-token-abc");
expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
});
it("does NOT send Authorization on GET even when token is set", async () => {
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
const { api } = await import("../../src/api/client");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
});
await api.get("/v1/items");
const [, init] = mockFetch.mock.calls[0];
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
});
it("omits Authorization on POST when token is empty (read-only deployments)", async () => {
vi.stubEnv("VITE_API_WRITE_TOKEN", "");
const { api } = await import("../../src/api/client");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true }),
});
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
const [, init] = mockFetch.mock.calls[0];
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
});
});
```
**Step 2 — Run, expect failure:** `cd ui && npx vitest run tests/unit/api_client.test.ts` — should fail because `vitest.config.ts` doesn't exist yet OR the test fails for missing module path. Either way, RED.
**Step 3 — Create `vitest.config.ts`:**
```ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "node",
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
},
});
```
Also add `"test:unit": "vitest run"` to `ui/package.json` scripts.
**Step 4 — Run again, expect failure for the right reason** (client doesn't yet send Authorization). RED.
**Step 5 — Implement the Authorization header in client.ts** (the A2 code). GREEN.
**Step 6 — Refactor:** extract the header construction into a small helper if it's getting crowded. Tests stay green.
**Verify:** `cd ui && npm run test:unit` — 3 pass.
**Commit:** `test(ui): api client auth unit tests + vitest config (P5)`
---
## Task A4: Add React Query hooks in api/queries.ts
**Files:** `ui/src/api/queries.ts` (modify)
Add:
```ts
export function useIngestStory(): UseMutationResult<IngestStoryResponse, ApiError, IngestStoryRequest> {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: IngestStoryRequest) => api.post<IngestStoryResponse>("/v1/items", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["items"] });
qc.invalidateQueries({ queryKey: ["stats"] });
},
});
}
export function useAnswerIssue(issueId: string | null): UseMutationResult<AnswerIssueResponse, ApiError, string> {
const qc = useQueryClient();
return useMutation({
mutationFn: (answer: string) => {
if (!issueId) throw new Error("issueId is null");
return api.post<AnswerIssueResponse>(`/v1/issues/${issueId}/answer`, { answer });
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["item"] });
qc.invalidateQueries({ queryKey: ["issues"] });
qc.invalidateQueries({ queryKey: ["stats"] });
},
});
}
export function useCostSummary(days: number = 7): UseQueryResult<CostSummaryResponse> {
return useQuery({
queryKey: ["cost", days],
queryFn: () => api.get<CostSummaryResponse>("/v1/cost", { days }),
staleTime: FIVE_SECONDS,
refetchInterval: FIVE_SECONDS,
});
}
export function useGroupedItems(): UseQueryResult<GroupedItemsResponse> {
return useQuery({
queryKey: ["items", "grouped", "project"],
queryFn: () => api.get<GroupedItemsResponse>("/v1/items", { group_by: "project" }),
staleTime: FIVE_SECONDS,
refetchInterval: FIVE_SECONDS,
});
}
```
Note: `useMutation` needs `useMutationResult` import; `useQueryClient` is `@tanstack/react-query`. `ApiError` is already exported from `client.ts`.
**Verify:** `npm run typecheck` exits 0.
**Commit:** `feat(ui): React Query hooks for ingest, answer, cost, grouped items (P5)`
---
## Task B1: Extend fixture_api.py for v2
**Files:** `ui/tests/e2e/fixture_api.py` (modify)
Add four new endpoints to the existing FastAPI app:
1. `POST /v1/items` — accepts `IngestStoryRequest` body, returns `IngestStoryResponse`. For fixture purposes, generates a UUID, sets phase='spec', attempts=0, and inserts into the in-memory `ITEMS` dict. Idempotent on (project, story_id).
2. `POST /v1/issues/{id}/answer` — accepts `AnswerIssueRequest` body, sets `answer`/`status='answered'`/`answered_at=now` on the issue, returns `AnswerIssueResponse`.
3. `GET /v1/cost?days=N` — returns `CostSummaryResponse` with synthetic 7-day data. Deterministic values so the e2e test can assert on shape (e.g. one day has higher cost).
4. `GET /v1/items?group_by=project` — extends the existing handler to return `GroupedItemsResponse` when `group_by=project`, otherwise the existing list shape.
**Verify (manual, since fixture is plain Python):**
```bash
cd ui && python3 tests/e2e/fixture_api.py &
sleep 1
curl -s -X POST http://127.0.0.1:9110/v1/items -H 'content-type: application/json' \
-d '{"project":"test","story_id":"s1","title":"t","file_scope":[],"priority":100,"budget_cycles":3}'
# expect 200 + JSON with item.id
curl -s 'http://127.0.0.1:9110/v1/cost?days=7'
# expect JSON with by_day (7 keys)
curl -s 'http://127.0.0.1:9110/v1/items?group_by=project'
# expect JSON with groups[]
kill %1
```
**Commit:** `test(ui): extend fixture API with POST /v1/items, POST /v1/issues/.../answer, GET /v1/cost, ?group_by=project (P5)`
---
## Task C1: PhaseBar widget
**Files:** `ui/src/widgets/PhaseBar.tsx` (create)
**Behavior:** Live stacked bar of phase counts. Uses `useStats()` (5s polling). Displays the same MUI Paper+Box pattern that v1's Dashboard uses inline. The widget is a pure presentation component — given a `phase_counts: Record<WorkItemPhase, number>`, render the bar.
**Step 1 — Write component test (RED):** `ui/tests/unit/PhaseBar.test.tsx`:
```ts
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { PhaseBar } from "../../src/widgets/PhaseBar";
import type { WorkItemPhase } from "../../src/types";
const wrap = (children: React.ReactNode) => (
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
);
describe("PhaseBar widget", () => {
it("renders nothing when all counts are zero", () => {
const counts: Record<WorkItemPhase, number> = {
spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0,
};
const { container } = render(wrap(<PhaseBar counts={counts} total={0} />));
expect(container.querySelector('[data-testid="phase-bar"]')).toBeNull();
});
it("renders one segment per non-zero phase, widths proportional to counts", () => {
const counts: Record<WorkItemPhase, number> = {
spec: 0, build: 2, review: 0, merged: 6, blocked: 2, awaiting_human: 0,
};
const { getByTestId } = render(wrap(<PhaseBar counts={counts} total={10} />));
expect(getByTestId("phase-bar")).toBeTruthy();
expect(getByTestId("phase-bar-build").style.width).toBe("20%");
expect(getByTestId("phase-bar-merged").style.width).toBe("60%");
expect(getByTestId("phase-bar-blocked").style.width).toBe("20%");
});
});
```
(Add `@testing-library/react` to package.json devDeps if not present.)
**Step 2 — Run, expect failure** (PhaseBar doesn't exist). RED.
**Step 3 — Implement PhaseBar** (extracted verbatim from v1 Dashboard, parameterized by `counts` and `total` props).
**Step 4 — Run, expect pass.** GREEN.
**Verify:** `npm run test:unit` passes 5 tests (3 from A3 + 2 from C1).
**Commit:** `feat(ui): PhaseBar widget extracted from v1 Dashboard (P5)`
---
## Task D1: OpenIssues widget
**Files:** `ui/src/widgets/OpenIssues.tsx` (create)
**Behavior:** Card showing `open_human_issues` count (big number) + a list of the last 5 open issues. Each issue is clickable → opens the drawer for its `work_item_id`. Uses `useStats()` for the count, and a NEW `useOpenIssues(limit=5)` query against `GET /v1/issues?status=open&limit=5`.
**Add the new hook to queries.ts:**
```ts
export function useOpenIssues(limit = 5): UseQueryResult<ListIssuesResponse> {
return useQuery({
queryKey: ["issues", "open", limit],
queryFn: () => api.get<ListIssuesResponse>("/v1/issues", { status: "open", limit }),
staleTime: FIVE_SECONDS,
refetchInterval: FIVE_SECONDS,
});
}
```
**Step 1 — Write component test (RED):**
```ts
// tests/unit/OpenIssues.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { OpenIssues } from "../../src/widgets/OpenIssues";
import * as queries from "../../src/api/queries";
vi.mock("../../src/api/queries", () => ({
useStats: vi.fn(),
useOpenIssues: vi.fn(),
}));
vi.mock("../../src/router", () => ({
setOpenItem: vi.fn(),
navigate: vi.fn(),
}));
const wrap = (children: React.ReactNode) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}><ThemeProvider theme={createTheme()}>{children}</ThemeProvider></QueryClientProvider>;
};
describe("OpenIssues widget", () => {
it("renders the count from useStats", () => {
(queries.useStats as any).mockReturnValue({ data: { open_human_issues: 7 }, isLoading: false, error: null });
(queries.useOpenIssues as any).mockReturnValue({ data: { issues: [], total: 0 }, isLoading: false });
const { getByTestId } = render(wrap(<OpenIssues />));
expect(getByTestId("open-issues-count").textContent).toBe("7");
});
it("renders the last 5 issues, each clickable", () => {
(queries.useStats as any).mockReturnValue({ data: { open_human_issues: 3 }, isLoading: false, error: null });
(queries.useOpenIssues as any).mockReturnValue({
data: {
total: 3,
limit: 5,
offset: 0,
issues: [
{ id: "i1", work_item_id: "w1", question: "Q1", answer: null, status: "open", created_at: "2026-01-01T00:00:00Z", answered_at: null },
{ id: "i2", work_item_id: "w2", question: "Q2", answer: null, status: "open", created_at: "2026-01-01T00:00:00Z", answered_at: null },
],
},
isLoading: false,
});
const { getAllByTestId } = render(wrap(<OpenIssues />));
const items = getAllByTestId("open-issues-item");
expect(items).toHaveLength(2);
});
});
```
**Step 2 — Run, expect failure.** RED.
**Step 3 — Implement OpenIssues:** Card with Typography for the count and Stack of clickable list items. Each item's onClick calls `setOpenItem(issue.work_item_id)`.
**Step 4 — Run, expect pass.** GREEN.
**Verify:** `npm run test:unit` passes 7 tests.
**Commit:** `feat(ui): OpenIssues widget (count + last 5 clickable) (P5)`
---
## Task E1: BlockedItems widget
**Files:** `ui/src/widgets/BlockedItems.tsx` (create)
**Behavior:** Lists items in `blocked` phase, each as a card showing `last_verdict` and `last_feedback` (so operator sees WHY). Uses `useListItems({ phase: 'blocked', limit: 10 })`. Each card clickable → drawer.
**Step 1 — Component test (RED):**
```ts
// tests/unit/BlockedItems.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BlockedItems } from "../../src/widgets/BlockedItems";
import * as queries from "../../src/api/queries";
vi.mock("../../src/api/queries", () => ({ useListItems: vi.fn() }));
vi.mock("../../src/router", () => ({ setOpenItem: vi.fn() }));
const wrap = (ui: React.ReactNode) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}><ThemeProvider theme={createTheme()}>{ui}</ThemeProvider></QueryClientProvider>;
};
describe("BlockedItems widget", () => {
it("renders no cards when no items are blocked", () => {
(queries.useListItems as any).mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
const { queryByTestId } = render(wrap(<BlockedItems />));
expect(queryByTestId("blocked-items-card")).toBeNull();
});
it("renders one card per blocked item showing verdict and feedback", () => {
(queries.useListItems as any).mockReturnValue({
data: {
total: 2, limit: 10, offset: 0,
items: [
{ id: "b1", project: "p", story_id: "s", title: "T1", phase: "blocked", file_scope: [], attempts: 3, budget_cycles: 3, priority: 100, base_commit: null, branch: null, pr_url: null, last_verdict: "tests_failed", last_feedback: "AssertionError in test_foo", spec_path: null, wiki_pin: null, claimed_by: null, claimed_at: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", merged_at: null },
{ id: "b2", project: "p", story_id: "s", title: "T2", phase: "blocked", file_scope: [], attempts: 3, budget_cycles: 3, priority: 100, base_commit: null, branch: null, pr_url: null, last_verdict: "spec_ambiguous", last_feedback: "ambiguous req X", spec_path: null, wiki_pin: null, claimed_by: null, claimed_at: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", merged_at: null },
],
},
isLoading: false,
});
const { getByTestId, getAllByTestId } = render(wrap(<BlockedItems />));
expect(getByTestId("blocked-items-root")).toBeTruthy();
expect(getAllByTestId("blocked-items-card")).toHaveLength(2);
expect(getByTestId("blocked-items-card-b1").textContent).toContain("tests_failed");
expect(getByTestId("blocked-items-card-b2").textContent).toContain("spec_ambiguous");
});
});
```
**Step 2 — Run, expect failure.** RED.
**Step 3 — Implement BlockedItems.** Card grid using MUI `<Grid container>` of `<Grid item xs={12} md={6}>`.
**Step 4 — Run, expect pass.** GREEN.
**Commit:** `feat(ui): BlockedItems widget (verdict + feedback cards) (P5)`
---
## Task F1: CostSparkline widget
**Files:** `ui/src/widgets/CostSparkline.tsx` (create)
**Behavior:** Takes `by_day: Record<string, string>` (ISO date → USD string) from `CostSummaryResponse`. Renders a tiny inline SVG polyline (no MUI X-Charts dep — keep bundle small). Each day is a point; missing days are interpolated to 0.
**Step 1 — Component test (RED):**
```ts
// tests/unit/CostSparkline.test.tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { CostSparkline } from "../../src/widgets/CostSparkline";
const wrap = (ui: React.ReactNode) => <ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>;
describe("CostSparkline widget", () => {
it("renders an SVG with one polyline point per day", () => {
const byDay = { "2026-06-18": "0.10", "2026-06-19": "0.20", "2026-06-20": "0.15" };
const { getByTestId } = render(wrap(<CostSparkline byDay={byDay} />));
const poly = getByTestId("cost-sparkline-polyline") as unknown as SVGPolylineElement;
expect(poly).toBeTruthy();
// 3 points => "x1,y1 x2,y2 x3,y3"
const points = poly.getAttribute("points")!.trim().split(/\s+/);
expect(points).toHaveLength(3);
});
it("renders a flat line when byDay is empty", () => {
const { getByTestId } = render(wrap(<CostSparkline byDay={{}} />));
expect(getByTestId("cost-sparkline-empty")).toBeTruthy();
});
});
```
**Step 2 — Run, expect failure.** RED.
**Step 3 — Implement CostSparkline.** 200×60 SVG. Convert Decimal strings to Number, normalize to height, generate `points="x1,y1 x2,y2 ..."` string. Renders empty state (data-testid="cost-sparkline-empty") if zero data.
**Step 4 — Run, expect pass.** GREEN.
**Commit:** `feat(ui): CostSparkline widget (inline SVG, no X-Charts dep) (P5)`
---
## Task G1: Extend Dashboard to project-grouped + mount widgets
**Files:** `ui/src/routes/Dashboard.tsx` (rewrite — but keep the same testid surface for the e2e test)
**Behavior:**
- Top: 4 self-improving widgets in a responsive grid (xs=12 md=6 lg=3):
- `<PhaseBar />` (data-testid="phase-bar" preserved for back-compat with v1 test)
- `<OpenIssues />` (data-testid="open-issues-card" + data-testid="open-issues-count" preserved)
- `<BlockedItems />`
- `<CostSparkline />`
- Below: project-grouped items. Uses `useGroupedItems()`. Tabs (MUI `<Tabs>`) per project. Each tab's content: small per-phase counts for that project's items + a "View all" link to `/items?project=<name>`.
**Verify:** `npm run typecheck && npm run test:unit` exit 0.
**Commit:** `feat(ui): Dashboard is project-grouped + 4 widgets (P5)`
---
## Task H1: Extend ItemDrawer with answer form
**Files:** `ui/src/routes/ItemDrawer.tsx` (modify)
**Behavior:** If `item.phase === 'awaiting_human'` AND `open_issues.length > 0`, render a `<form>` BELOW the open-issues list with a MUI `<TextField multiline>` and a `<Button>Submit answer</Button>`. The form calls `useAnswerIssue(issue.id)`. The first open issue is the one we answer (UI is per-item, not per-issue). On success, invalidate the queries (already done by the hook) — the drawer re-fetches and the answered issue disappears from the open-issues list.
**Verify:** `npm run typecheck` exits 0.
**Commit:** `feat(ui): ItemDrawer answer form for awaiting_human items (P5)`
---
## Task I1: Router + App: add /ingest route
**Files:**
- `ui/src/router.ts` (modify)
- `ui/src/App.tsx` (modify)
**router.ts:** add a third variant to the `Route` union:
```ts
| { name: "ingest" };
```
Plus a parse case: `cleaned === "ingest"` or `cleaned === "ingest/"`.
**App.tsx:** add an Ingest nav button (data-testid="nav-ingest"), and route-render to `<Ingest />` when `route.name === "ingest"`.
**Verify:** `npm run typecheck` exits 0.
**Commit:** `feat(ui): /ingest route + nav button (P5)`
---
## Task J1: Ingest form route
**Files:** `ui/src/routes/Ingest.tsx` (create)
**Behavior:** 6 MUI `<TextField>`s with validation that mirrors the Pydantic schema:
- `project` (1..64)
- `story_id` (1..128)
- `title` (1..255)
- `file_scope` multiline, comma-separated, split on submit → `string[]`
- `priority` number 0..1000, default 100
- `budget_cycles` number 1..10, default 3
Submit button → `useIngestStory().mutateAsync(body)`. On success, `navigate("/items/" + result.item.id)`. Validation errors render inline (`<FormHelperText>`). Network errors render a top-of-form `<Alert severity="error">`.
**Step 1 — Component test (RED):**
```ts
// tests/unit/Ingest.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Ingest } from "../../src/routes/Ingest";
import * as queries from "../../src/api/queries";
import * as router from "../../src/router";
vi.mock("../../src/api/queries", () => ({ useIngestStory: vi.fn() }));
vi.mock("../../src/router", () => ({ navigate: vi.fn(), useRoute: vi.fn(() => ({ name: "ingest" })) }));
const wrap = (ui: React.ReactNode) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}><ThemeProvider theme={createTheme()}>{ui}</ThemeProvider></QueryClientProvider>;
};
describe("Ingest route", () => {
it("renders all 6 fields", () => {
(queries.useIngestStory as any).mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
const { getByTestId } = render(wrap(<Ingest />));
["project", "story_id", "title", "file_scope", "priority", "budget_cycles"].forEach((f) =>
expect(getByTestId(`field-${f}`)).toBeTruthy(),
);
});
it("blocks submit when project is empty (Pydantic min_length=1)", async () => {
const mutate = vi.fn();
(queries.useIngestStory as any).mockReturnValue({ mutateAsync: mutate, isPending: false });
const { getByTestId, getByText } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "t" } });
fireEvent.click(getByTestId("ingest-submit"));
expect(mutate).not.toHaveBeenCalled();
expect(getByText(/project is required/i)).toBeTruthy();
});
it("submits with parsed body and navigates on success", async () => {
const mutate = vi.fn().mockResolvedValue({ item: { id: "abc-123-..." }, created: true });
(queries.useIngestStory as any).mockReturnValue({ mutateAsync: mutate, isPending: false });
const nav = vi.fn();
(router.navigate as any) = nav;
const { getByTestId } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
fireEvent.change(getByTestId("field-file_scope"), { target: { value: "src/a.ts, src/b.ts" } });
fireEvent.change(getByTestId("field-priority"), { target: { value: "200" } });
fireEvent.change(getByTestId("field-budget_cycles"), { target: { value: "4" } });
fireEvent.click(getByTestId("ingest-submit"));
await waitFor(() => expect(mutate).toHaveBeenCalled());
const call = mutate.mock.calls[0][0];
expect(call).toEqual({
project: "p1", story_id: "s1", title: "T1",
file_scope: ["src/a.ts", "src/b.ts"],
priority: 200, budget_cycles: 4,
});
await waitFor(() => expect(nav).toHaveBeenCalledWith("/items/abc-123-..."));
});
});
```
**Step 2 — Run, expect failure.** RED.
**Step 3 — Implement Ingest.tsx.** Per-field validation is a simple `errors: Record<string, string>` map populated on submit. No external validation lib.
**Step 4 — Run, expect pass.** GREEN.
**Verify:** `npm run test:unit` exits 0.
**Commit:** `feat(ui): Ingest form route (P5)`
---
## Task K1: Contract: §3 group_by row + §8 P5 note
**Files:** `wiki/concepts/entry-points-contract.md` (modify)
**§3 — `GET /v1/items`:** append a row in the param table:
```
| `group_by` | enum | (none — flat list) | `project` (v2: only this one value is supported) |
```
Add a paragraph below the table:
> When `group_by=project`, the response is `GroupedItemsResponse` (not `ListItemsResponse`): `{ groups: [{ project, items, phase_counts }], total_items, total_projects }`. The list-shape params (`phase`, `priority_min/max`, `sort`, `open_questions_only`) still apply to the items within each group. Other values of `group_by` return 400 `bad_request`.
**§8 — P5 line:** change "P5 — `damascus-ui` v2. Ingest form (`/ingest`), answer form (inside the drawer), project-grouped dashboard. All four \"self-improving\" widgets from §7 wired live. Sparkline data comes from `CostSummaryResponse.by_day`." — add: "Adds `?group_by=project` to `GET /v1/items` (response becomes `GroupedItemsResponse`); see §3."
**Verify:** Visual inspection only.
**Commit:** `docs(entry-points): §3 group_by + §8 P5 group_by note (P5)`
---
## Task L1: api_schemas.py: group_by + GroupedItemsResponse
**Files:** `src/damascus/api_schemas.py` (modify)
**Add to `ListItemsQuery`:**
```python
group_by: Optional[Literal["project"]] = Field(
default=None,
description="v2: group response by this field. Only 'project' is supported. Mutually exclusive with the flat list response shape — handler returns GroupedItemsResponse when set.",
)
```
Also add a `@model_validator` to reject mutually-exclusive combinations of `group_by` with `limit`/`offset`/`sort` (handler may ignore or enforce; the schema's job is to be self-documenting). Minimal: just add the field.
**Add new class** after `ListItemsResponse`:
```python
class ProjectGroup(BaseModel):
"""One project bucket inside :class:`GroupedItemsResponse`."""
project: str
items: list[WorkItemResponse]
phase_counts: dict[WorkItemPhase, int]
class GroupedItemsResponse(BaseModel):
"""``GET /v1/items?group_by=project`` response (P5)."""
groups: list[ProjectGroup]
total_items: int
total_projects: int
```
**Verify (Python import test):**
```bash
cd /root/damascus-orchestrator
python -W error -c "from damascus.api_schemas import ListItemsQuery, GroupedItemsResponse, ProjectGroup; print('ok')"
```
**Run contract test (the test_contracts_match_source.py guards the schema in lockstep with the wiki contract — add a check for `group_by` in ListItemsQuery there if it doesn't already exist):**
```bash
python -m pytest tests/contract/test_contracts_match_source.py -q
```
**Commit:** `feat(api): ListItemsQuery.group_by + GroupedItemsResponse (P5 schema)`
---
## Task M1: Playwright e2e test_ui_v2.spec.ts
**Files:** `ui/tests/e2e/test_ui_v2.spec.ts` (create)
Three scenarios per the task body:
1. **Ingest flow:**
```ts
test("ingest form: fill, submit, redirect to /items/:id", async ({ page }) => {
await page.goto("/#/ingest");
await page.getByTestId("field-project").fill("e2e-test");
await page.getByTestId("field-story_id").fill("story-1");
await page.getByTestId("field-title").fill("E2E test story");
await page.getByTestId("field-file_scope").fill("src/a.ts, src/b.ts");
await page.getByTestId("ingest-submit").click();
await expect(page).toHaveURL(/#\/items\/[0-9a-f-]{36}$/);
});
```
2. **Dashboard widgets render:**
```ts
test("dashboard renders all 4 widgets", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("phase-bar")).toBeVisible();
await expect(page.getByTestId("open-issues-card")).toBeVisible();
await expect(page.getByTestId("blocked-items-root")).toBeVisible();
await expect(page.getByTestId("cost-sparkline-root")).toBeVisible();
});
```
3. **Answer form in drawer:**
```ts
test("answer form: submit, drawer reflects answered state", async ({ page }) => {
// First ingest a story then set its phase to awaiting_human via fixture
// (or seed an existing item in awaiting_human via fixture setup).
await page.goto("/#/items");
const row = page.locator('[data-testid="items-grid"] .MuiDataGrid-row').filter({ hasText: "awaiting_human" });
await row.click();
await expect(page.getByTestId("answer-form")).toBeVisible();
await page.getByTestId("answer-text").fill("Use approach B, here is why");
await page.getByTestId("answer-submit").click();
// Drawer re-fetches; the open_issues list should now be empty
await expect(page.getByTestId("open-issues-list")).toHaveCount(0);
});
```
For the third test to work, the fixture needs a work item in `awaiting_human` phase with an open issue. Extend fixture_api.py ITEMS dict with one such item.
**Verify:** `cd ui && npm run test:e2e` — all v1 + v2 tests pass.
**Commit:** `test(ui): v2 e2e — ingest, dashboard widgets, answer form (P5)`
---
## Task N1: Build, full test, mobile viewport, commit, push, PR
```bash
cd /root/damascus-orchestrator/ui
npm run typecheck # exits 0
npm run test:unit # all unit tests pass
npm run build # tsc + vite build
npm run test:e2e # all e2e tests pass
npm run test:e2e -- --viewport=375,667 # mobile
```
Then:
```bash
cd /root/damascus-orchestrator
git add -A
git commit -m "feat(ui): damascus-ui v2 — ingest, answer, project-grouped dashboard, 4 widgets (P5)"
git push -u origin feat/entry-points-ui-v2
tea pulls create
```
PR link goes in a `kanban_comment` on `t_83bfe8cc`. Then `kanban_block(reason="review-required: ...")` — this is a code change, needs human eyes.
---
## Risk register
- **Mobile viewport regression.** v1's drawer uses `width: 480` with `maxWidth: "100%"` (mobile-safe). v2's answer form must use the same pattern. All widget grids must use `xs={12}` so they stack on small screens.
- **No live damascus-api in CI.** The e2e suite uses the local fixture. The real damascus-api may differ in CORS, auth, response timing. The CI flag `UI_NO_WEBSERVER` is the escape hatch for ad-hoc runs against a real API.
- **Authorization header in production.** The Vite build needs `VITE_API_WRITE_TOKEN` set during `npm run build`. The Dockerfile currently sets `VITE_API_BASE_URL=""` — it should also conditionally set `VITE_API_WRITE_TOKEN` from compose env. The compose file needs the same. (Out of scope for v2 e2e; the e2e test doesn't send the header. The user / compose stack supplies it.)
- **The fixture returns 200 for unknown methods.** FastAPI returns 405 by default — verified during B1. If the route is added but the body is malformed, FastAPI returns 422 — the Ingest component test (J1) handles that.
- **Vitest config + testing-library.** May need a one-time `npm install -D @testing-library/react @testing-library/dom jsdom` in ui/. The `jsdom` dep is already in devDeps; the testing-library deps are NOT. Add them in the C1 commit.
## Out of scope (explicit non-goals)
- Writing to `wiki_pins` from the UI (deferred per contract §7)
- Operator-note textarea on blocked items (deferred per contract §7)
- Bulk ingest UI (P1 schema supports it, but P5 ships single-story only)
- Auth flow / login (the bundle is LAN-trusted per task body)
- Composition of the live damascus-api — P2 owns that. The v2 e2e suite runs against the fixture.

View File

@@ -33,7 +33,7 @@ from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from typing import Annotated, Any, AsyncIterator, Iterator, Optional
from typing import Annotated, Any, AsyncIterator, Iterator, Optional, Union
import psycopg
import psycopg.types.json
@@ -330,7 +330,11 @@ def create_app() -> FastAPI:
# ---------- /v1/items ---------------------------------------------------
@app.get("/v1/items", response_model=S.ListItemsResponse, tags=["items"])
@app.get(
"/v1/items",
response_model=Union[S.ListItemsResponse, S.GroupedItemsResponse],
tags=["items"],
)
def list_items(
project: Optional[str] = None,
phase: Optional[S.WorkItemPhase] = None,
@@ -340,13 +344,35 @@ def create_app() -> FastAPI:
limit: int = 50,
offset: int = 0,
open_questions_only: bool = False,
) -> S.ListItemsResponse:
"""List work_items with filters, sort, pagination (contract §3)."""
group_by: Optional[str] = None,
) -> Union[S.ListItemsResponse, S.GroupedItemsResponse]:
"""List work_items with filters, sort, pagination (contract §3).
P5: when ``group_by=project``, response shape switches to
:class:`GroupedItemsResponse` (one bucket per project with
per-phase counts). Only ``"project"`` is supported today;
other values return 400 ``bad_request``.
"""
# Server-side business rule (Pydantic catches type errors; this catches
# semantic violations of business invariants — 400 bad_request).
if priority_max < priority_min:
return err(S.ErrorCode.bad_request, "priority_max must be >= priority_min", 400)
# group_by routing
if group_by is not None and group_by != "project":
return err(
S.ErrorCode.bad_request,
f"group_by={group_by!r} not supported (only 'project')",
400,
)
if group_by == "project":
return _list_items_grouped_by_project(
project=project,
phase=phase,
priority_min=priority_min,
priority_max=priority_max,
)
sql = ["SELECT * FROM work_items WHERE 1=1"]
params: list[Any] = []
if project is not None:
@@ -397,6 +423,65 @@ def create_app() -> FastAPI:
items = [S.WorkItemResponse.model_validate(r) for r in rows]
return S.ListItemsResponse(items=items, total=total, limit=limit, offset=offset)
def _list_items_grouped_by_project(
*,
project: Optional[str],
phase: Optional[S.WorkItemPhase],
priority_min: int,
priority_max: int,
) -> S.GroupedItemsResponse:
"""P5: aggregate items into ``ProjectGroup`` buckets.
Returns one ``ProjectGroup`` per distinct ``project`` value,
each carrying the project's items (still filtered by ``phase``
and ``priority_*``) and per-phase counts. ``sort`` / pagination
are intentionally not honored in the grouped view — the dashboard
renders the full set; per-item pagination lives on the flat list
endpoint.
"""
sql = ["SELECT * FROM work_items WHERE 1=1"]
params: list[Any] = []
if project is not None:
sql.append("AND project = %s")
params.append(project)
if phase is not None:
sql.append("AND phase = %s")
params.append(phase.value)
sql.append("AND priority >= %s AND priority <= %s")
params.extend([priority_min, priority_max])
sql.append("ORDER BY project ASC, priority ASC, updated_at ASC")
with pool_cursor() as cur:
cur.execute(" ".join(sql), params)
rows = list(cur.fetchall())
groups_by_project: dict[str, list[S.WorkItemResponse]] = {}
for r in rows:
wi = S.WorkItemResponse.model_validate(r)
groups_by_project.setdefault(wi.project, []).append(wi)
groups: list[S.ProjectGroup] = []
total_items = 0
for project_name in sorted(groups_by_project.keys()):
items = groups_by_project[project_name]
phase_counts: dict[S.WorkItemPhase, int] = {}
for it in items:
phase_counts[it.phase] = phase_counts.get(it.phase, 0) + 1
groups.append(
S.ProjectGroup(
project=project_name,
items=items,
phase_counts=phase_counts,
)
)
total_items += len(items)
return S.GroupedItemsResponse(
groups=groups,
total_items=total_items,
total_projects=len(groups),
)
@app.get("/v1/items/{item_id}", response_model=S.ItemDetailResponse, tags=["items"])
def get_item(
item_id: Annotated[str, PathParam(min_length=36, max_length=36, pattern=S.UUID36_PATTERN)],

View File

@@ -29,7 +29,7 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Optional
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -164,6 +164,17 @@ class ListItemsQuery(BaseModel):
limit: int = Field(default=50, ge=1, le=500)
offset: int = Field(default=0, ge=0)
open_questions_only: bool = False
# P5: when set, the handler returns GroupedItemsResponse (not
# ListItemsResponse). Only "project" is supported today; other
# values are rejected with HTTP 400 by the handler.
group_by: Optional[Literal["project"]] = Field(
default=None,
description=(
"v2: when set, the response shape switches to "
"GroupedItemsResponse. Only 'project' is supported; other "
"values return HTTP 400 bad_request."
),
)
@model_validator(mode="after")
def _priority_bounds(self) -> "ListItemsQuery":
@@ -270,6 +281,29 @@ class ListItemsResponse(BaseModel):
offset: int
class ProjectGroup(BaseModel):
"""One project bucket inside :class:`GroupedItemsResponse`.
P5: when ``ListItemsQuery.group_by=project`` is set, the response
is grouped by the ``project`` field. Each group carries the
project's items (still respecting phase/priority/etc. filters) and
per-phase counts so the dashboard can render the breakdown without
a second query.
"""
project: str
items: list[WorkItemResponse]
phase_counts: dict[WorkItemPhase, int]
class GroupedItemsResponse(BaseModel):
"""``GET /v1/items?group_by=project`` response (P5)."""
groups: list[ProjectGroup]
total_items: int
total_projects: int
class HumanIssueResponse(BaseModel):
"""One row from ``human_issues``."""

View File

@@ -229,6 +229,64 @@ def test_get_items_pagination(client):
assert len(body["items"]) == 2
# ---------------------------------------------------------------------------
# GET /v1/items?group_by=project (P5)
# ---------------------------------------------------------------------------
def test_get_items_group_by_project_returns_grouped_response(client):
"""`group_by=project` switches the response shape to GroupedItemsResponse."""
_insert_work_item(story_id="alpha-1", project="alpha")
_insert_work_item(story_id="alpha-2", project="alpha")
_insert_work_item(story_id="beta-1", project="beta")
r = client.get("/v1/items", params={"group_by": "project"})
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"groups", "total_items", "total_projects"}
assert body["total_items"] == 3
assert body["total_projects"] == 2
projects = {g["project"] for g in body["groups"]}
assert projects == {"alpha", "beta"}
by_project = {g["project"]: g for g in body["groups"]}
assert len(by_project["alpha"]["items"]) == 2
assert len(by_project["beta"]["items"]) == 1
# per-phase counts are present and include the seed phase
assert "spec" in by_project["alpha"]["phase_counts"]
def test_get_items_group_by_project_filters_respected(client):
"""`phase` and `priority_*` filters still apply inside the grouped view."""
_insert_work_item(story_id="alpha-1", project="alpha", priority=10)
_insert_work_item(story_id="alpha-2", project="alpha", priority=200)
_insert_work_item(story_id="beta-1", project="beta", priority=50)
r = client.get(
"/v1/items",
params={"group_by": "project", "priority_min": 100},
)
body = r.json()
assert body["total_items"] == 1
assert body["total_projects"] == 1
assert body["groups"][0]["project"] == "alpha"
def test_get_items_group_by_project_unsupported_value_rejected(client):
"""Any `group_by` value other than `project` returns 400."""
r = client.get("/v1/items", params={"group_by": "phase"})
assert r.status_code == 400
body = r.json()
assert body["error"] == "bad_request"
assert "project" in body["detail"]
def test_get_items_no_group_by_returns_flat_list(client):
"""Without `group_by`, the response stays ListItemsResponse (regression)."""
_insert_work_item(story_id="flat-1")
r = client.get("/v1/items")
body = r.json()
assert set(body.keys()) == {"items", "total", "limit", "offset"}
assert len(body["items"]) == 1
# ---------------------------------------------------------------------------
# GET /v1/items/{id}
# ---------------------------------------------------------------------------

235
ui/package-lock.json generated
View File

@@ -19,6 +19,8 @@
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -29,6 +31,13 @@
"vitest": "^2.1.0"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
@@ -1986,6 +1995,90 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2205,6 +2298,41 @@
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2418,6 +2546,13 @@
"node": ">= 6"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -2503,6 +2638,24 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -2949,6 +3102,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -3090,6 +3253,17 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3133,6 +3307,16 @@
"node": ">= 0.6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3339,6 +3523,30 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -3419,6 +3627,20 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
@@ -3583,6 +3805,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",

View File

@@ -3,13 +3,15 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Damascus orchestrator v1 read-only dashboard (P4)",
"description": "Damascus orchestrator UI (P4 + P5: read-only dashboard, ingest, answer, project-grouped view, 4 self-improving widgets)",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"test:e2e": "VITE_API_BASE_URL=http://127.0.0.1:9110 vite build && playwright test"
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "VITE_API_BASE_URL=http://127.0.0.1:9111 vite build && playwright test"
},
"dependencies": {
"@emotion/react": "^11.13.0",
@@ -23,6 +25,8 @@
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -20,8 +20,15 @@ import { defineConfig, devices } from "@playwright/test";
// makes them same-origin; in this test we cross-origin which works
// because the fixture API has CORS allow_origins=["*"].
// Default the fixture to 9111 to avoid colliding with a developer
// machine that already runs the real damascus-api on 9110 (P2's
// default). CI runs against a clean host where 9110 is free; set
// FIXTURE_API_PORT=9110 there to keep the original behavior. The
// `npm run test:e2e` script bakes VITE_API_BASE_URL against the same
// port as the fixture, so changing this value is a single point of
// edit.
const UI_PORT = Number(process.env.UI_PORT ?? 4173);
const API_PORT = Number(process.env.FIXTURE_API_PORT ?? 9110);
const API_PORT = Number(process.env.FIXTURE_API_PORT ?? 9111);
const BASE_URL = process.env.UI_BASE_URL ?? `http://127.0.0.1:${UI_PORT}`;
export default defineConfig({

View File

@@ -2,6 +2,7 @@ import { Box, AppBar, Toolbar, Typography, Button, Stack } from "@mui/material";
import { useRoute, navigate } from "./router";
import { Dashboard } from "./routes/Dashboard";
import { Items } from "./routes/Items";
import { Ingest } from "./routes/Ingest";
export default function App() {
const route = useRoute();
@@ -34,15 +35,29 @@ export default function App() {
>
Items
</Button>
<Button
color="inherit"
data-testid="nav-ingest"
onClick={() => navigate("/ingest")}
variant={route.name === "ingest" ? "outlined" : "text"}
>
Ingest
</Button>
</Stack>
<Typography variant="caption" sx={{ opacity: 0.6 }}>
v1 read-only
v2 ingest + widgets
</Typography>
</Toolbar>
</AppBar>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
{route.name === "dashboard" ? <Dashboard /> : <Items />}
{route.name === "dashboard" ? (
<Dashboard />
) : route.name === "items" ? (
<Items />
) : (
<Ingest />
)}
</Box>
</Box>
);

View File

@@ -7,17 +7,29 @@
// All paths are relative — same-origin in both dev (Vite proxy) and
// production (FastAPI StaticFiles mount). Override the base with
// VITE_API_BASE_URL only for ad-hoc debugging (e.g. hitting a remote
// API directly from a laptop).
// API directly from a laptop). The e2e suite (npm run test:e2e) bakes
// VITE_API_BASE_URL=http://127.0.0.1:9111 (the fixture port) at build
// time so the React app calls the fixture directly.
//
// Auth: reads (GET) need no token; writes (POST) need
// `Authorization: Bearer <token>`. v1 is read-only, so this client
// never sends a token. The IngestPage in P5 will extend the wrapper.
// `Authorization: Bearer <...3e, baked at build time via
// VITE_API_WRITE_TOKEN (LAN-trusted; the bundle is served loopback
// only). v1 is read-only, so this client never sends the header;
// P5's ingest + answer flows send it on every POST.
import type { ErrorResponse } from "../types";
const BASE_URL =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
// Token used for write requests. Baked at build time by Vite (the
// value is whatever the dev / CI / production shell exports). Empty
// string = no auth header sent, which is fine for read-only test
// fixtures and the local dev experience where the operator is the
// only "user".
const WRITE_TOKEN =
(import.meta.env.VITE_API_WRITE_TOKEN as string | undefined) ?? "";
export class ApiError extends Error {
status: number;
body: unknown;
@@ -62,6 +74,12 @@ async function request<T>(
if (body !== undefined) {
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
// Write auth: send Bearer only when a token is baked in. The
// contract says writes need auth, but tests / read-only
// deployments may run without one — they just can't POST.
if (WRITE_TOKEN) {
(init.headers as Record<string, string>).Authorization = `Bearer ${WRITE_TOKEN}`;
}
}
const res = await fetch(finalUrl.toString(), init);

View File

@@ -6,18 +6,22 @@
// the dashboard widgets; the Items page uses staleTime=0 so manual
// refetch on URL change picks up new filters immediately.
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { api } from "./client";
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from "@tanstack/react-query";
import { api, ApiError } from "./client";
import type {
AnswerIssueRequest,
AnswerIssueResponse,
CostSummaryResponse,
GroupedItemsResponse,
HealthResponse,
IngestStoryRequest,
IngestStoryResponse,
ItemDetailResponse,
ItemsSort,
ListEventsResponse,
ListIssuesResponse,
ListItemsQueryParams,
ListItemsResponse,
StatsResponse,
WorkItem,
WorkItemPhase,
} from "../types";
const FIVE_SECONDS = 5_000;
@@ -75,20 +79,74 @@ export function useRecentEvents(
});
}
// Convenience: project list derived from the work items the UI
// already has. Lets the filter dropdown populate without a separate
// /v1/projects endpoint (which the contract doesn't expose).
export function deriveProjects(items: WorkItem[]): string[] {
return Array.from(new Set(items.map((i) => i.project))).sort();
export function useOpenIssues(limit = 5): UseQueryResult<ListIssuesResponse> {
return useQuery({
queryKey: ["issues", "open", limit],
queryFn: () => api.get<ListIssuesResponse>("/v1/issues", { status: "open", limit }),
staleTime: FIVE_SECONDS,
refetchInterval: FIVE_SECONDS,
});
}
export function matchesPhaseFilter(
item: WorkItem,
phases: WorkItemPhase[] | undefined,
): boolean {
if (!phases || phases.length === 0) return true;
return phases.includes(item.phase);
// --- P5 write hooks (ingest + answer) -------------------------------------
export function useIngestStory(): UseMutationResult<
IngestStoryResponse,
ApiError,
IngestStoryRequest
> {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: IngestStoryRequest) =>
api.post<IngestStoryResponse>("/v1/items", body),
onSuccess: () => {
// Invalidate anything that lists items or shows phase counts.
qc.invalidateQueries({ queryKey: ["items"] });
qc.invalidateQueries({ queryKey: ["stats"] });
},
});
}
export const DEFAULT_SORT: ItemsSort = "priority_asc";
export const DEFAULT_LIMIT = 50;
export function useAnswerIssue(
issueId: string | null,
): UseMutationResult<AnswerIssueResponse, ApiError, string> {
const qc = useQueryClient();
return useMutation({
mutationFn: (answer: string) => {
if (!issueId) {
return Promise.reject(new Error("issueId is null"));
}
const body: AnswerIssueRequest = { answer };
return api.post<AnswerIssueResponse>(
`/v1/issues/${issueId}/answer`,
body,
);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["item"] });
qc.invalidateQueries({ queryKey: ["issues"] });
qc.invalidateQueries({ queryKey: ["stats"] });
},
});
}
// --- P5 read hooks (cost, grouped) ----------------------------------------
export function useCostSummary(days = 7): UseQueryResult<CostSummaryResponse> {
return useQuery({
queryKey: ["cost", days],
queryFn: () => api.get<CostSummaryResponse>("/v1/cost", { days }),
staleTime: FIVE_SECONDS,
refetchInterval: FIVE_SECONDS,
});
}
export function useGroupedItems(): UseQueryResult<GroupedItemsResponse> {
return useQuery({
queryKey: ["items", "grouped", "project"],
queryFn: () =>
api.get<GroupedItemsResponse>("/v1/items", { group_by: "project" }),
staleTime: FIVE_SECONDS,
refetchInterval: FIVE_SECONDS,
});
}

View File

@@ -1,29 +1,30 @@
// Tiny hash-based router for the v1 UI.
// Tiny hash-based router for the v1 + P5 UI.
//
// Three routes:
// #/ — Dashboard
// #/items — Items table
// #/items/:id — Items table with the drawer open on `id`
// Routes:
// #/ — Dashboard
// #/items — Items table
// #/items/:id — Items table with the drawer open on `id`
// #/ingest — P5: ingest form (POST /v1/items)
//
// We use a path-based hash (not hashbang) so the URL looks like
// http://damascus.lan/#/items/abc-123-... — friendlier than
// #/items?id=abc-123-... and matches what users expect.
//
// v1 has no nested routes, no auth-gated routes, and no programmatic
// navigation beyond the Link helper. P5 (ingest form) will add at most
// one more route (#/ingest) and shouldn't need to expand the surface.
// http://damascus.lan/#/ingest — friendlier than #/?route=ingest
// and matches what users expect.
import { useEffect, useState, useCallback } from "react";
export type Route =
| { name: "dashboard" }
| { name: "items"; itemId: string | null };
| { name: "items"; itemId: string | null }
| { name: "ingest" };
function parseHash(hash: string): Route {
const cleaned = hash.replace(/^#\/?/, "");
if (cleaned === "" || cleaned === "/") {
return { name: "dashboard" };
}
if (cleaned === "ingest" || cleaned === "ingest/") {
return { name: "ingest" };
}
if (cleaned === "items" || cleaned === "items/") {
return { name: "items", itemId: null };
}
@@ -31,7 +32,7 @@ function parseHash(hash: string): Route {
if (itemMatch) {
return { name: "items", itemId: itemMatch[1] };
}
// Unknown route falls back to dashboard; v1 is not opinionated about 404s.
// Unknown route falls back to dashboard; not opinionated about 404s.
return { name: "dashboard" };
}

View File

@@ -1,29 +1,40 @@
// Dashboard route — §7 widgets for the read-only v1.
// Dashboard route — §7 widgets for the P5 self-improving UI.
//
// Three widgets:
// 1. Phase counts as a stacked bar (live, polled every 5s via useStats).
// 2. Open human_issues count + last 5 inline (links → /items/:id drawer).
// 3. Cost today (USD).
// P5 layout:
// 1. Four self-improving widgets at the top (responsive grid):
// - <PhaseBar /> (live, polled every 5s via useStats)
// - <OpenIssues /> (count + last 5 inline, clickable → drawer)
// - <BlockedItems /> (cards showing verdict + feedback)
// - <CostSparkline /> (inline SVG of by_day for last 7 days)
// 2. Project-grouped items below (Tabs, one per project).
//
// P5 will add a sparkline for the last 7 days of cost and the
// "items in `blocked` phase" cards. v1 is intentionally a thin
// slice so the bundle stays small and the contract ships.
// All four widgets are pure presentational components living under
// src/widgets/ — the Dashboard composes them. The phase counts are
// the only thing Dashboard fetches directly (via useStats), and that
// is the same hook v1 used.
import { useState } from "react";
import {
Alert,
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
Stack,
Tab,
Tabs,
Typography,
Alert,
Chip,
Paper,
} from "@mui/material";
import { useStats } from "../api/queries";
import { useStats, useGroupedItems } from "../api/queries";
import { ALL_PHASES, type WorkItemPhase } from "../types";
import { setOpenItem } from "../router";
import { setOpenItem, navigate } from "../router";
import { PhaseBar } from "../widgets/PhaseBar";
import { OpenIssues } from "../widgets/OpenIssues";
import { BlockedItems } from "../widgets/BlockedItems";
import { CostSparkline } from "../widgets/CostSparkline";
import { useCostSummary } from "../api/queries";
const PHASE_COLORS: Record<WorkItemPhase, string> = {
spec: "#7aa2f7",
@@ -36,6 +47,11 @@ const PHASE_COLORS: Record<WorkItemPhase, string> = {
export function Dashboard() {
const stats = useStats();
const grouped = useGroupedItems();
const cost = useCostSummary(7);
const [activeTab, setActiveTab] = useState(0);
const activeGroup = grouped.data?.groups?.[activeTab];
if (stats.isLoading) {
return (
@@ -61,156 +77,233 @@ export function Dashboard() {
return (
<Stack spacing={3} data-testid="dashboard-root">
<Typography variant="h4" component="h1">
Dashboard
</Typography>
{/* Phase counts — stacked bar + per-phase chips */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Work items by phase
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{phaseTotal} total
</Typography>
{phaseTotal === 0 ? (
<Typography variant="body2" color="text.secondary">
No work items yet.
</Typography>
) : (
<>
<Paper
elevation={0}
sx={{
display: "flex",
height: 32,
borderRadius: 1,
overflow: "hidden",
bgcolor: "background.default",
}}
data-testid="phase-bar"
>
{ALL_PHASES.map((phase) => {
const count = data.phase_counts[phase] ?? 0;
if (count === 0) return null;
const pct = (count / phaseTotal) * 100;
return (
<Box
key={phase}
data-testid={`phase-bar-${phase}`}
sx={{
width: `${pct}%`,
bgcolor: PHASE_COLORS[phase],
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#1a1b26",
fontWeight: 600,
fontSize: 12,
}}
title={`${phase}: ${count}`}
>
{pct > 8 ? count : ""}
</Box>
);
})}
</Paper>
<Stack direction="row" spacing={1} sx={{ mt: 2, flexWrap: "wrap", gap: 1 }}>
{ALL_PHASES.map((phase) => (
<Chip
key={phase}
size="small"
label={`${phase}: ${data.phase_counts[phase] ?? 0}`}
sx={{
bgcolor: PHASE_COLORS[phase],
color: "#1a1b26",
fontWeight: 600,
}}
/>
))}
</Stack>
</>
)}
</CardContent>
</Card>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ flexWrap: "wrap", gap: 1 }}
>
<Typography variant="h4" component="h1">
Dashboard
</Typography>
<Typography variant="caption" color="text.secondary">
{data.last_cycle_at
? `Last cycle ${new Date(data.last_cycle_at).toLocaleString()}`
: "No cycles yet"}
</Typography>
</Stack>
{/* Top widget grid — 4 self-improving widgets, xs=12 stacks on
mobile per the user's "no fixed pixel widths" preference. */}
<Grid container spacing={3}>
<Grid item xs={12} sm={4}>
<Card data-testid="open-issues-card">
<Grid item xs={12} md={6} lg={3}>
<Card>
<CardContent>
<Typography variant="overline" color="text.secondary">
Open human issues
Phase distribution
</Typography>
<Typography
variant="h3"
data-testid="open-issues-count"
sx={{ fontWeight: 600 }}
<Box sx={{ mt: 1 }}>
<PhaseBar counts={data.phase_counts} total={phaseTotal} />
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6} lg={3}>
<OpenIssues />
</Grid>
<Grid item xs={12} md={6} lg={3}>
<Card>
<CardContent>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
{data.open_human_issues}
<Typography variant="overline" color="text.secondary">
Cost today (USD)
</Typography>
</Stack>
<Typography variant="h3" sx={{ fontWeight: 600 }}>
${data.cost_today_usd}
</Typography>
<Typography variant="caption" color="text.secondary">
Live, polled every 5s.
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={12} md={6} lg={3}>
<Card>
<CardContent>
<Typography variant="overline" color="text.secondary">
Active claims
</Typography>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Typography variant="overline" color="text.secondary">
Active claims
</Typography>
</Stack>
<Typography variant="h3" sx={{ fontWeight: 600 }}>
{data.active_claims}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography variant="overline" color="text.secondary">
Cost today (USD)
</Typography>
<Typography variant="h3" sx={{ fontWeight: 600 }}>
${data.cost_today_usd}
<Typography variant="caption" color="text.secondary">
Workers currently holding an item.
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Blocked items + cost sparkline (P5 §7). */}
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<BlockedItems />
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<CostSparkline byDay={cost.data?.by_day ?? {}} />
</Grid>
</Grid>
{/* Project-grouped view (P5: ?group_by=project). */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Last cycle
</Typography>
<Typography variant="body2" color="text.secondary">
{data.last_cycle_at
? new Date(data.last_cycle_at).toLocaleString()
: "never"}
</Typography>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 1 }}
>
<Typography variant="h6">Items by project</Typography>
<Typography variant="caption" color="text.secondary">
{grouped.data?.total_items ?? 0} items ·{" "}
{grouped.data?.total_projects ?? 0} projects
</Typography>
</Stack>
{grouped.isLoading && (
<Box sx={{ display: "flex", justifyContent: "center", p: 2 }}>
<CircularProgress size={20} />
</Box>
)}
{!grouped.isLoading &&
(grouped.data?.groups?.length ?? 0) === 0 && (
<Typography
variant="body2"
color="text.secondary"
data-testid="project-groups-empty"
>
No items yet head to the ingest form to create the first story.
</Typography>
)}
{(grouped.data?.groups?.length ?? 0) > 0 && (
<>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
data-testid="project-tabs"
variant="scrollable"
scrollButtons="auto"
>
{grouped.data!.groups.map((g) => (
<Tab
key={g.project}
label={`${g.project} (${g.items.length})`}
data-testid={`project-tab-${g.project}`}
/>
))}
</Tabs>
{activeGroup && (
<Box sx={{ mt: 2 }} data-testid="project-tab-panel">
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", gap: 1, mb: 2 }}
>
{ALL_PHASES.map((p) => (
<Chip
key={p}
size="small"
label={`${p}: ${activeGroup.phase_counts[p] ?? 0}`}
sx={{
bgcolor: PHASE_COLORS[p],
color: "#1a1b26",
fontWeight: 600,
}}
/>
))}
</Stack>
<Stack spacing={1}>
{activeGroup.items.length === 0 && (
<Typography variant="body2" color="text.secondary">
No items in this project.
</Typography>
)}
{activeGroup.items.map((it) => (
<Box
key={it.id}
data-testid={`project-item-${it.id}`}
onClick={() => setOpenItem(it.id)}
sx={{
cursor: "pointer",
p: 1,
borderRadius: 1,
bgcolor: "action.hover",
"&:hover": { bgcolor: "action.selected" },
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
size="small"
label={it.phase}
sx={{
bgcolor: PHASE_COLORS[it.phase],
color: "#1a1b26",
fontWeight: 600,
}}
/>
<Typography variant="body2" sx={{ flex: 1 }}>
{it.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{it.story_id}
</Typography>
</Stack>
</Box>
))}
</Stack>
<Box sx={{ mt: 2 }}>
<button
type="button"
onClick={() => {
navigate(`/items?project=${encodeURIComponent(activeGroup.project)}`);
}}
style={{
background: "transparent",
color: "#7aa2f7",
border: "none",
padding: 0,
cursor: "pointer",
font: "inherit",
textDecoration: "underline",
}}
data-testid={`project-view-all-${activeGroup.project}`}
>
View all in {activeGroup.project}
</button>
</Box>
</Box>
)}
</>
)}
</CardContent>
</Card>
{/* Convenience link to items table — operators land here first */}
<Box>
<button
type="button"
onClick={() => setOpenItem(null)}
style={{
background: "transparent",
color: "#7aa2f7",
border: "none",
padding: 0,
cursor: "pointer",
font: "inherit",
textDecoration: "underline",
}}
>
View all items
</button>
</Box>
</Stack>
);
}

250
ui/src/routes/Ingest.tsx Normal file
View File

@@ -0,0 +1,250 @@
// Ingest route — /ingest form for P5 §8.
//
// Form fields mirror IngestStoryRequest (src/damascus/api_schemas.py):
// - project (1..64)
// - story_id (1..128)
// - title (1..255)
// - file_scope (multiline, comma-separated → string[] on submit)
// - priority (0..1000, default 100)
// - budget_cycles (1..10, default 3)
//
// On submit:
// - per-field validation runs (matches Pydantic min/max length, ge/le)
// - successful submit calls useIngestStory.mutateAsync(parsedBody)
// - on success, navigate(`/items/${item.id}`)
// - on error, surface as <Alert severity="error"> at top of form
import { useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Grid,
Stack,
TextField,
Typography,
FormHelperText,
} from "@mui/material";
import { useIngestStory } from "../api/queries";
import { navigate } from "../router";
import type { IngestStoryRequest } from "../types";
type Errors = Partial<Record<keyof IngestStoryRequest, string>>;
function validate(values: IngestStoryRequest): Errors {
const e: Errors = {};
if (values.project.length < 1 || values.project.length > 64) {
e.project = "Project is required (1..64 chars).";
}
if (values.story_id.length < 1 || values.story_id.length > 128) {
e.story_id = "Story ID is required (1..128 chars).";
}
if (values.title.length < 1 || values.title.length > 255) {
e.title = "Title is required (1..255 chars).";
}
if (!Number.isInteger(values.priority) || values.priority < 0 || values.priority > 1000) {
e.priority = "Priority must be an integer between 0 and 1000.";
}
if (
!Number.isInteger(values.budget_cycles) ||
values.budget_cycles < 1 ||
values.budget_cycles > 10
) {
e.budget_cycles = "Budget cycles must be an integer between 1 and 10.";
}
return e;
}
function parseFileScope(text: string): string[] {
return text
.split(/[,\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
const initialValues: IngestStoryRequest = {
project: "",
story_id: "",
title: "",
file_scope: [],
priority: 100,
budget_cycles: 3,
};
export function Ingest() {
const [values, setValues] = useState<IngestStoryRequest>(initialValues);
const [fileScopeText, setFileScopeText] = useState("");
const [errors, setErrors] = useState<Errors>({});
const [topError, setTopError] = useState<string | null>(null);
const mutation = useIngestStory();
const setField = <K extends keyof IngestStoryRequest>(
key: K,
value: IngestStoryRequest[K],
) => {
setValues((v) => ({ ...v, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined }));
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTopError(null);
const parsed: IngestStoryRequest = {
...values,
file_scope: parseFileScope(fileScopeText),
};
const v = validate(parsed);
if (Object.values(v).some(Boolean)) {
setErrors(v);
return;
}
try {
const res = await mutation.mutateAsync(parsed);
navigate(`/items/${res.item.id}`);
} catch (err) {
setTopError(String(err));
}
};
return (
<Box data-testid="ingest-root">
<Stack direction="row" alignItems="center" sx={{ mb: 2 }} spacing={2}>
<Typography variant="h4" component="h1">
Ingest story
</Typography>
<Typography variant="body2" color="text.secondary">
Add a new work item to the orchestrator queue.
</Typography>
</Stack>
<Card>
<CardContent>
<Box component="form" onSubmit={onSubmit} noValidate>
{topError && (
<Alert severity="error" sx={{ mb: 2 }} data-testid="ingest-error">
{topError}
</Alert>
)}
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
label="Project"
fullWidth
value={values.project}
onChange={(e) => setField("project", e.target.value)}
required
error={Boolean(errors.project)}
inputProps={{ "data-testid": "field-project", maxLength: 64 }}
helperText={errors.project ?? "1..64 chars (matches Pydantic)"}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Story ID"
fullWidth
value={values.story_id}
onChange={(e) => setField("story_id", e.target.value)}
required
error={Boolean(errors.story_id)}
inputProps={{ "data-testid": "field-story_id", maxLength: 128 }}
helperText={errors.story_id ?? "1..128 chars"}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Title"
fullWidth
value={values.title}
onChange={(e) => setField("title", e.target.value)}
required
error={Boolean(errors.title)}
inputProps={{ "data-testid": "field-title", maxLength: 255 }}
helperText={errors.title ?? "1..255 chars"}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="File scope"
fullWidth
multiline
minRows={2}
value={fileScopeText}
onChange={(e) => setFileScopeText(e.target.value)}
inputProps={{ "data-testid": "field-file_scope" }}
placeholder="Comma-separated paths the worker is allowed to touch, e.g. src/a.ts, src/b.ts"
helperText="Split on commas or newlines; empty entries dropped."
/>
</Grid>
<Grid item xs={6} sm={3}>
<TextField
label="Priority"
type="number"
fullWidth
value={values.priority}
onChange={(e) => {
const n = Number(e.target.value);
setField("priority", Number.isFinite(n) ? n : values.priority);
}}
error={Boolean(errors.priority)}
inputProps={{
"data-testid": "field-priority",
min: 0,
max: 1000,
}}
helperText={errors.priority ?? "0..1000, default 100"}
/>
</Grid>
<Grid item xs={6} sm={3}>
<TextField
label="Budget cycles"
type="number"
fullWidth
value={values.budget_cycles}
onChange={(e) => {
const n = Number(e.target.value);
setField(
"budget_cycles",
Number.isFinite(n) ? n : values.budget_cycles,
);
}}
error={Boolean(errors.budget_cycles)}
inputProps={{
"data-testid": "field-budget_cycles",
min: 1,
max: 10,
}}
helperText={errors.budget_cycles ?? "1..10, default 3"}
/>
</Grid>
</Grid>
{/* The errors object may have keys whose FormHelperText already
surfaces them; this block renders any error key without a
TextField (none today, but kept for forward-compat). */}
{Object.entries(errors).map(([k, v]) =>
v ? (
<FormHelperText key={k} error sx={{ mt: 1 }}>
{`${k}: ${v}`}
</FormHelperText>
) : null,
)}
<Stack direction="row" spacing={1} sx={{ mt: 3 }}>
<Button
type="submit"
variant="contained"
data-testid="ingest-submit"
disabled={mutation.isPending}
>
{mutation.isPending ? "Submitting…" : "Ingest"}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -1,14 +1,15 @@
// ItemDrawer — right-side drawer that opens when the user clicks a
// row in the Items table (URL hash = #/items/:id).
//
// Shows: the work item's full record, its open human_issues, and the
// 20 most recent events_outbox rows for the item.
//
// v1 is read-only — no answer form, no edit. P5 will add the answer
// textarea inside this drawer.
// Shows: the work item's full record, its open human_issues, the
// 20 most recent events_outbox rows for the item, and (P5) an answer
// form when the item is paused on a human question.
import { useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Divider,
@@ -16,11 +17,15 @@ import {
IconButton,
Paper,
Stack,
TextField,
Typography,
Alert,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useItemDetail, useRecentEvents } from "../api/queries";
import {
useItemDetail,
useRecentEvents,
useAnswerIssue,
} from "../api/queries";
import { useOpenItemId, setOpenItem } from "../router";
import type { WorkItemPhase } from "../types";
@@ -203,6 +208,15 @@ export function ItemDrawer() {
</Stack>
)}
{/* P5: answer form for items paused on a human question. */}
{detail.data.item.phase === "awaiting_human" &&
detail.data.open_issues.length > 0 && (
<AnswerForm
issueId={detail.data.open_issues[0].id}
question={detail.data.open_issues[0].question}
/>
)}
<Divider sx={{ my: 3 }} />
<Typography variant="overline" color="text.secondary">
Recent events
@@ -241,3 +255,73 @@ export function ItemDrawer() {
</Drawer>
);
}
// AnswerForm: textarea + Submit button. Posts to /v1/issues/{id}/answer.
// On success the parent query invalidation (in useAnswerIssue.onSuccess)
// refetches the item + issues list, so the answered issue disappears
// from the open-issues list and the form unmounts.
function AnswerForm({
issueId,
question,
}: {
issueId: string;
question: string;
}) {
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useAnswerIssue(issueId);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const trimmed = text.trim();
if (trimmed.length === 0) {
setError("Answer is required (1..10000 chars).");
return;
}
try {
await mutation.mutateAsync(trimmed);
setText("");
} catch (err) {
setError(String(err));
}
};
return (
<Box
component="form"
data-testid="answer-form"
onSubmit={onSubmit}
sx={{ mt: 3 }}
>
<Typography variant="overline" color="text.secondary">
Answer human question
</Typography>
<Paper variant="outlined" sx={{ p: 1.5, mt: 0.5, mb: 1 }}>
<Typography variant="body2">{question}</Typography>
</Paper>
<TextField
value={text}
onChange={(e) => setText(e.target.value)}
multiline
minRows={3}
fullWidth
placeholder="Type the answer the spec-refiner should use…"
disabled={mutation.isPending}
inputProps={{ maxLength: 10_000, "data-testid": "answer-text" }}
error={error !== null}
helperText={error ?? undefined}
/>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
type="submit"
variant="contained"
data-testid="answer-submit"
disabled={mutation.isPending}
>
{mutation.isPending ? "Submitting…" : "Submit answer"}
</Button>
</Stack>
</Box>
);
}

View File

@@ -96,6 +96,57 @@ export interface ListIssuesResponse {
offset: number;
}
// --- P5 additions -------------------------------------------------------
export interface IngestStoryRequest {
project: string; // 1..64 chars (Pydantic min_length/max_length)
story_id: string; // 1..128 chars
title: string; // 1..255 chars
file_scope: string[]; // default []
priority: number; // 0..1000, default 100
budget_cycles: number; // 1..10, default 3
}
export interface IngestStoryResponse {
item: WorkItem;
created: boolean;
}
export interface AnswerIssueRequest {
answer: string; // 1..10_000 chars
}
export interface AnswerIssueResponse {
id: string;
work_item_id: string;
question: string;
answer: string;
status: IssueStatus;
created_at: string;
answered_at: string;
}
export interface CostSummaryResponse {
total_usd: string; // serialized Decimal
by_project: Record<string, string>; // project -> USD string
by_model: Record<string, string>; // model -> USD string
by_day: Record<string, string>; // YYYY-MM-DD -> USD string
window_start: string; // ISO datetime
window_end: string; // ISO datetime
}
export interface ProjectGroup {
project: string;
items: WorkItem[];
phase_counts: Record<WorkItemPhase, number>;
}
export interface GroupedItemsResponse {
groups: ProjectGroup[];
total_items: number;
total_projects: number;
}
export interface EventRow {
id: number;
work_item_id: string | null;
@@ -136,6 +187,7 @@ export interface ListItemsQueryParams {
limit?: number;
offset?: number;
open_questions_only?: boolean;
group_by?: "project"; // P5: when set, the API returns GroupedItemsResponse instead of ListItemsResponse
}
export interface ErrorResponse {

View File

@@ -0,0 +1,140 @@
// BlockedItems widget — §7 "items in `blocked` phase" card grid.
//
// Surfaces work items stuck in the `blocked` phase with their
// last_verdict + last_feedback so the operator can see WHY they're
// stuck without opening the drawer. Each card is clickable → drawer
// for that work item.
//
// The widget polls /v1/items?phase=blocked&limit=10 (the same hook
// the Items page uses) and is keyed off a 5s poll like the other
// self-improving widgets in this slice.
import {
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
Stack,
Typography,
} from "@mui/material";
import { useListItems } from "../api/queries";
import { setOpenItem } from "../router";
import type { VerdictKind } from "../types";
const VERDICT_COLORS: Record<VerdictKind, string> = {
pass: "#9ece6a",
tests_failed: "#f7768e",
rebase_conflict: "#e0af68",
spec_ambiguous: "#bb9af7",
spec_wrong: "#f7768e",
no_pr: "#7aa2f7",
};
function formatFeedback(raw: unknown): string {
if (raw == null) return "—";
if (typeof raw === "string") return raw;
try {
return JSON.stringify(raw, null, 2);
} catch {
return String(raw);
}
}
export function BlockedItems() {
const list = useListItems({ phase: "blocked", limit: 10 });
const items = list.data?.items ?? [];
return (
<Box data-testid="blocked-items-root">
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="overline" color="text.secondary">
Blocked items
</Typography>
<Typography variant="caption" color="text.secondary" data-testid="blocked-items-count">
{items.length}
</Typography>
</Stack>
{list.isLoading && (
<Box sx={{ display: "flex", justifyContent: "center", p: 2 }}>
<CircularProgress size={20} />
</Box>
)}
{!list.isLoading && items.length === 0 && (
<Typography variant="body2" color="text.secondary" data-testid="blocked-items-empty">
Nothing blocked pipeline is flowing.
</Typography>
)}
{items.length > 0 && (
<Grid container spacing={2}>
{items.map((item) => {
const verdict = item.last_verdict as VerdictKind | null;
const color = verdict ? VERDICT_COLORS[verdict] : "#565f89";
return (
<Grid item xs={12} md={6} key={item.id}>
<Box
data-testid={`blocked-items-card-${item.id}`}
onClick={() => setOpenItem(item.id)}
sx={{ cursor: "pointer" }}
>
<Card
data-testid="blocked-items-card"
sx={{
cursor: "pointer",
borderLeft: 4,
borderLeftColor: color,
"&:hover": { boxShadow: 3 },
}}
>
<CardContent>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Chip
size="small"
label={verdict ?? "unknown"}
sx={{ bgcolor: color, color: "#1a1b26", fontWeight: 600 }}
/>
<Typography variant="caption" color="text.secondary">
{item.project} / {item.story_id}
</Typography>
</Stack>
<Typography
variant="body2"
sx={{
fontWeight: 600,
mb: 1,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{item.title}
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
m: 0,
color: "text.secondary",
}}
>
{formatFeedback(item.last_feedback)}
</Typography>
</CardContent>
</Card>
</Box>
</Grid>
);
})}
</Grid>
)}
</Box>
);
}

View File

@@ -0,0 +1,113 @@
// CostSparkline widget — §7 "last 7 days of cost" inline sparkline.
//
// Renders by_day from /v1/cost as a tiny inline SVG polyline. No
// MUI X-Charts dep — keeps the bundle small (the operator glances
// at this widget, doesn't interact with it). Missing days are
// treated as 0; out-of-order keys are sorted by date.
//
// Data-testid surface (referenced by the unit test and the e2e):
// - cost-sparkline-root : the wrapping Box
// - cost-sparkline-polyline : the SVG <polyline> element
// - cost-sparkline-empty : empty-state Box
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
import { useMemo } from "react";
export interface CostSparklineProps {
byDay: Record<string, string>;
}
const WIDTH = 200;
const HEIGHT = 60;
const PAD_X = 2;
const PAD_Y = 6;
function parseDecimal(s: string): number {
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
export function CostSparkline({ byDay }: CostSparklineProps) {
const pointsAttr = useMemo(() => {
const keys = Object.keys(byDay).sort();
if (keys.length === 0) return "";
const values = keys.map((k) => parseDecimal(byDay[k]));
const max = Math.max(...values, 0.0001);
const stepX = (WIDTH - 2 * PAD_X) / Math.max(keys.length - 1, 1);
return keys
.map((_, i) => {
const x = PAD_X + i * stepX;
// Invert Y: top of SVG is 0, max value is at the top of the chart.
const y = HEIGHT - PAD_Y - (values[i] / max) * (HEIGHT - 2 * PAD_Y);
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}, [byDay]);
if (!pointsAttr) {
return (
<Box data-testid="cost-sparkline-root">
<Card>
<CardContent>
<Typography variant="overline" color="text.secondary">
Last 7 days (USD)
</Typography>
<Box
data-testid="cost-sparkline-empty"
sx={{ height: HEIGHT, display: "flex", alignItems: "center" }}
>
<Typography variant="body2" color="text.secondary">
No cost data yet.
</Typography>
</Box>
</CardContent>
</Card>
</Box>
);
}
// Sparkline viewBox is 0..WIDTH x 0..HEIGHT, no axes (the operator
// doesn't need them — the shape is the signal).
return (
<Box data-testid="cost-sparkline-root">
<Card>
<CardContent>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 1 }}
>
<Typography variant="overline" color="text.secondary">
Last 7 days (USD)
</Typography>
<Typography variant="caption" color="text.secondary">
${Object.values(byDay)
.map(parseDecimal)
.reduce((a, b) => a + b, 0)
.toFixed(2)}
</Typography>
</Stack>
<svg
data-testid="cost-sparkline-svg"
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
width="100%"
height={HEIGHT}
role="img"
aria-label="Daily cost sparkline"
>
<polyline
data-testid="cost-sparkline-polyline"
fill="none"
stroke="#7aa2f7"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
points={pointsAttr}
/>
</svg>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -0,0 +1,109 @@
// OpenIssues widget — §7 "open human issues" card.
//
// Shows the live count from useStats (same source as the v1 dashboard's
// big number) plus a list of the last 5 open issues fetched via
// useOpenIssues. Each list item is clickable; clicking it calls
// setOpenItem(issue.work_item_id) so the operator can read the full
// item context (and, in P5, answer the question).
//
// Data-testid surface (referenced by the unit test and the e2e):
// - open-issues-card : the wrapping card
// - open-issues-count : the big number (matches v1 surface)
// - open-issues-item : one per listed issue
// - open-issues-empty : empty-state text when count is zero
import {
Box,
Card,
CardContent,
Divider,
Stack,
Typography,
CircularProgress,
} from "@mui/material";
import { useStats, useOpenIssues } from "../api/queries";
import { setOpenItem } from "../router";
const LIST_LIMIT = 5;
export function OpenIssues() {
const stats = useStats();
const list = useOpenIssues(LIST_LIMIT);
const count = stats.data?.open_human_issues ?? 0;
const issues = list.data?.issues ?? [];
return (
<Card data-testid="open-issues-card">
<CardContent>
<Typography variant="overline" color="text.secondary">
Open human issues
</Typography>
<Typography
variant="h3"
data-testid="open-issues-count"
sx={{ fontWeight: 600, mb: 1 }}
>
{stats.isLoading ? "…" : count}
</Typography>
{list.isLoading && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<CircularProgress size={20} />
</Box>
)}
{!list.isLoading && issues.length === 0 && (
<Typography
variant="body2"
color="text.secondary"
data-testid="open-issues-empty"
>
None operator queue is clear.
</Typography>
)}
{issues.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<Stack spacing={1} sx={{ mt: 1 }}>
{issues.map((issue) => (
<Box
key={issue.id}
data-testid="open-issues-item"
onClick={() => setOpenItem(issue.work_item_id)}
sx={{
cursor: "pointer",
p: 1,
borderRadius: 1,
bgcolor: "action.hover",
"&:hover": { bgcolor: "action.selected" },
}}
>
<Typography
variant="body2"
sx={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{issue.question}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
{new Date(issue.created_at).toLocaleString()}
</Typography>
</Box>
))}
</Stack>
</>
)}
</CardContent>
</Card>
);
}

101
ui/src/widgets/PhaseBar.tsx Normal file
View File

@@ -0,0 +1,101 @@
// PhaseBar widget — the §7 "phase counts as a stacked bar" self-
// improving UI primitive.
//
// P5: extracted from the v1 Dashboard's inline Paper+Box rendering so
// it can be reused (e.g. on the project-grouped Dashboard sub-views)
// and unit-tested in isolation. Pure presentation: takes the phase
// counts and a total, renders a stacked horizontal bar with one
// segment per non-zero phase, widths proportional to count.
//
// Color palette matches the v1 Dashboard for visual consistency.
import { Box, Paper, Stack, Chip, Typography } from "@mui/material";
import { ALL_PHASES, type WorkItemPhase } from "../types";
const PHASE_COLORS: Record<WorkItemPhase, string> = {
spec: "#7aa2f7",
build: "#9ece6a",
review: "#e0af68",
merged: "#73daca",
blocked: "#f7768e",
awaiting_human: "#bb9af7",
};
export interface PhaseBarProps {
counts: Record<WorkItemPhase, number>;
total: number;
}
export function PhaseBar({ counts, total }: PhaseBarProps) {
if (total === 0) {
return (
<Typography variant="body2" color="text.secondary" data-testid="phase-bar-empty">
No work items yet.
</Typography>
);
}
return (
<div data-testid="phase-bar-wrapper">
<Paper
elevation={0}
sx={{
display: "flex",
height: 32,
borderRadius: 1,
overflow: "hidden",
bgcolor: "background.default",
}}
data-testid="phase-bar"
>
{ALL_PHASES.map((phase) => {
const count = counts[phase] ?? 0;
if (count === 0) return null;
const pct = (count / total) * 100;
return (
<Box
key={phase}
data-testid={`phase-bar-${phase}`}
// width is set inline (not via sx) so unit tests can
// read element.style.width directly; MUI's sx would
// route the value through emotion's stylesheet, where
// the computed style is class-based and harder to
// assert on.
style={{ width: `${pct}%` }}
sx={{
bgcolor: PHASE_COLORS[phase],
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#1a1b26",
fontWeight: 600,
fontSize: 12,
}}
title={`${phase}: ${count}`}
>
{pct > 8 ? count : ""}
</Box>
);
})}
</Paper>
<Stack
direction="row"
spacing={1}
sx={{ mt: 2, flexWrap: "wrap", gap: 1 }}
>
{ALL_PHASES.map((phase) => (
<Chip
key={phase}
size="small"
label={`${phase}: ${counts[phase] ?? 0}`}
sx={{
bgcolor: PHASE_COLORS[phase],
color: "#1a1b26",
fontWeight: 600,
}}
/>
))}
</Stack>
</div>
);
}

View File

@@ -1,30 +1,40 @@
"""
Minimal FastAPI fixture for the damascus-ui v1 e2e suite.
Minimal FastAPI fixture for the damascus-ui v1 + v2 e2e suite.
Mirrors the P1 contract endpoint shapes (a strict subset is enough for
the v1 UI smoke test). Lives outside the main compose stack so the
e2e test can run without depending on P2's damascus-api merge.
Mirrors the P1 + P5 contract endpoint shapes (a strict subset is
enough for the v1 + v2 UI smoke tests). Lives outside the main
compose stack so the e2e test can run without depending on P2's
damascus-api merge.
Run:
pip install fastapi uvicorn
uvicorn tests.e2e.fixture_api:app --port 9110 --host 127.0.0.1
uvicorn tests.e2e.fixture_api:app --port 9111 --host 127.0.0.1
The fixture returns a deterministic dataset:
- 3 work items across 3 phases (spec, build, merged)
- 1 open human_issue on the build item
- 5 events_outbox rows for that build item
The fixture returns a deterministic dataset (deterministic ids for
the v1 + v2 scenarios):
- v1: 3 work items across 3 phases (spec, build, merged); 1 open
human_issue on the build item; 5 events_outbox rows for that build
item.
- v2 (P5):
- 1 work item in `awaiting_human` with an open human_issue — target
of the answer-form e2e test
- 1 work item in `blocked` with last_verdict + last_feedback — target
of the BlockedItems widget assertion
- v2 fixtures for: POST /v1/items (in-memory insert, idempotent on
(project, story_id)), POST /v1/issues/{id}/answer, GET /v1/cost
(synthetic 7-day totals), GET /v1/issues, ?group_by=project on
GET /v1/items.
Contract reference: src/damascus/api_schemas.py
"""
from __future__ import annotations
import os
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi import Body, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
@@ -49,6 +59,14 @@ SPEC_ITEM_ID = "11111111-1111-4111-8111-111111111111"
BUILD_ITEM_ID = "22222222-2222-4222-8222-222222222222"
MERGED_ITEM_ID = "33333333-3333-4333-8333-333333333333"
ISSUE_ID = "44444444-4444-4444-8444-444444444444"
# P5: an item in `awaiting_human` phase with an open human_issue, used
# by the e2e "answer form" scenario.
AWAITING_ITEM_ID = "55555555-5555-4555-8555-555555555555"
AWAITING_ISSUE_ID = "66666666-6666-4666-8666-666666666666"
# P5: an item in `blocked` phase with a verdict, for the BlockedItems
# widget assertion (no human_issue needed; the card shows last_verdict
# and last_feedback).
BLOCKED_ITEM_ID = "77777777-7777-4777-8777-777777777777"
ITEMS: dict[str, dict[str, Any]] = {
@@ -121,6 +139,56 @@ ITEMS: dict[str, dict[str, Any]] = {
"updated_at": "2026-06-23T11:00:00+00:00",
"merged_at": "2026-06-23T11:00:00+00:00",
},
# P5 fixture: item paused on a human question. The drawer's answer
# form is shown when phase == 'awaiting_human' && open_issues > 0.
AWAITING_ITEM_ID: {
"id": AWAITING_ITEM_ID,
"project": "wh40k-pc",
"story_id": "awaiting-story-01",
"title": "Pick the scoreboard color palette",
"phase": "awaiting_human",
"file_scope": ["src/theme.ts"],
"attempts": 1,
"budget_cycles": 3,
"priority": 250,
"base_commit": "abc1234",
"branch": "feat/scoreboard-palette",
"pr_url": None,
"last_verdict": "spec_ambiguous",
"last_feedback": "Spec asks for 'discord-inspired' but doesn't pin a palette.",
"spec_path": "/data/specs/wh40k-pc/awaiting-story-01.md",
"wiki_pin": None,
"claimed_by": "orch-1",
"claimed_at": "2026-06-24T12:00:00+00:00",
"created_at": "2026-06-24T11:30:00+00:00",
"updated_at": now_iso(),
"merged_at": None,
},
# P5 fixture: item in 'blocked' phase with a non-null last_verdict
# so the BlockedItems widget has something to render.
BLOCKED_ITEM_ID: {
"id": BLOCKED_ITEM_ID,
"project": "iso-tank-arena",
"story_id": "blocked-story-01",
"title": "Fix collision detection on slopes",
"phase": "blocked",
"file_scope": ["src/physics/collide.ts"],
"attempts": 3,
"budget_cycles": 3,
"priority": 400,
"base_commit": "aaa9999",
"branch": "feat/slope-collision",
"pr_url": None,
"last_verdict": "tests_failed",
"last_feedback": "AssertionError: expected 0.0, got 0.014 at test_slope_collision_30deg",
"spec_path": "/data/specs/iso-tank-arena/blocked-story-01.md",
"wiki_pin": None,
"claimed_by": "orch-3",
"claimed_at": "2026-06-24T09:00:00+00:00",
"created_at": "2026-06-24T08:30:00+00:00",
"updated_at": now_iso(),
"merged_at": None,
},
}
@@ -134,6 +202,17 @@ ISSUES: dict[str, dict[str, Any]] = {
"created_at": "2026-06-24T11:30:00+00:00",
"answered_at": None,
},
# P5: a second open issue, this one on the awaiting_human item.
# The answer-form e2e test targets this issue.
AWAITING_ISSUE_ID: {
"id": AWAITING_ISSUE_ID,
"work_item_id": AWAITING_ITEM_ID,
"question": "Which palette: Catppuccin Mocha, Tokyo Night, or Discord dark?",
"answer": None,
"status": "open",
"created_at": "2026-06-24T12:30:00+00:00",
"answered_at": None,
},
}
@@ -173,6 +252,22 @@ EVENTS: list[dict[str, Any]] = [
"payload": {"issue_id": ISSUE_ID},
"created_at": "2026-06-24T11:30:00+00:00",
},
# P5: events for the awaiting_human item to give the drawer's
# recent-events list something to render.
{
"id": 6,
"work_item_id": AWAITING_ITEM_ID,
"kind": "spec_refined",
"payload": {"spec_path": "/data/specs/wh40k-pc/awaiting-story-01.md"},
"created_at": "2026-06-24T12:00:00+00:00",
},
{
"id": 7,
"work_item_id": AWAITING_ITEM_ID,
"kind": "issue_opened",
"payload": {"issue_id": AWAITING_ISSUE_ID},
"created_at": "2026-06-24T12:30:00+00:00",
},
]
@@ -191,7 +286,16 @@ def list_items(
limit: int = 50,
offset: int = 0,
open_questions_only: bool = False,
group_by: Optional[str] = None,
) -> dict[str, Any]:
# P5: ?group_by=project returns GroupedItemsResponse (the items
# within each group still respect phase/priority/etc. filters).
if group_by is not None and group_by != "project":
raise HTTPException(
status_code=400,
detail=f"unsupported group_by={group_by!r}; only 'project' is supported",
)
items = list(ITEMS.values())
if project:
items = [i for i in items if i["project"] == project]
@@ -214,6 +318,29 @@ def list_items(
}
items = [i for i in items if i["id"] in open_item_ids]
if group_by == "project":
# Bucket by project, preserve sort order of first appearance
groups_dict: dict[str, list[dict[str, Any]]] = {}
for it in items:
groups_dict.setdefault(it["project"], []).append(it)
groups: list[dict[str, Any]] = []
for project_name, project_items in sorted(groups_dict.items()):
phase_counts: dict[str, int] = {}
for it in project_items:
phase_counts[it["phase"]] = phase_counts.get(it["phase"], 0) + 1
for p in ["spec", "build", "review", "merged", "blocked", "awaiting_human"]:
phase_counts.setdefault(p, 0)
groups.append({
"project": project_name,
"items": project_items,
"phase_counts": phase_counts,
})
return {
"groups": groups,
"total_items": len(items),
"total_projects": len(groups),
}
total = len(items)
items = items[offset : offset + limit]
@@ -225,6 +352,180 @@ def list_items(
}
@app.post("/v1/items")
def ingest_story(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""P5: in-memory ingest. Idempotent on (project, story_id)."""
# Mirror the IngestStoryRequest Pydantic validation. We do this
# by hand because the fixture shouldn't depend on src/damascus/.
for field, lo, hi in [
("project", 1, 64),
("story_id", 1, 128),
("title", 1, 255),
]:
v = body.get(field, "")
if not isinstance(v, str) or not (lo <= len(v) <= hi):
raise HTTPException(
status_code=422,
detail=f"{field} must be a string of length {lo}..{hi}",
)
file_scope = body.get("file_scope", [])
if not isinstance(file_scope, list) or not all(
isinstance(s, str) for s in file_scope
):
raise HTTPException(status_code=422, detail="file_scope must be list[str]")
priority = body.get("priority", 100)
if not isinstance(priority, int) or not (0 <= priority <= 1000):
raise HTTPException(status_code=422, detail="priority must be int 0..1000")
budget_cycles = body.get("budget_cycles", 3)
if not isinstance(budget_cycles, int) or not (1 <= budget_cycles <= 10):
raise HTTPException(status_code=422, detail="budget_cycles must be int 1..10")
project = body["project"]
story_id = body["story_id"]
# Idempotent: same (project, story_id) returns the existing row
# with created=False, matching the contract IngestStoryResponse.
for existing in ITEMS.values():
if existing["project"] == project and existing["story_id"] == story_id:
return {"item": existing, "created": False}
new_id = str(uuid.uuid4())
now = now_iso()
new_item: dict[str, Any] = {
"id": new_id,
"project": project,
"story_id": story_id,
"title": body["title"],
"phase": "spec",
"file_scope": list(file_scope),
"attempts": 0,
"budget_cycles": budget_cycles,
"priority": priority,
"base_commit": None,
"branch": None,
"pr_url": None,
"last_verdict": None,
"last_feedback": None,
"spec_path": None,
"wiki_pin": None,
"claimed_by": None,
"claimed_at": None,
"created_at": now,
"updated_at": now,
"merged_at": None,
}
ITEMS[new_id] = new_item
return {"item": new_item, "created": True}
@app.get("/v1/issues")
def list_issues(
status: Optional[str] = None,
project: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
issues = list(ISSUES.values())
if status:
issues = [i for i in issues if i["status"] == status]
if project:
item_ids_for_project = {
it["id"] for it in ITEMS.values() if it["project"] == project
}
issues = [i for i in issues if i["work_item_id"] in item_ids_for_project]
issues.sort(key=lambda i: i["created_at"], reverse=True)
total = len(issues)
issues = issues[offset : offset + limit]
return {"issues": issues, "total": total, "limit": limit, "offset": offset}
@app.post("/v1/issues/{issue_id}/answer")
def answer_issue(issue_id: str, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""P5: mark an issue answered. Returns AnswerIssueResponse."""
if issue_id not in ISSUES:
raise HTTPException(status_code=404, detail="not_found")
answer = body.get("answer", "")
if not isinstance(answer, str) or not (1 <= len(answer) <= 10_000):
raise HTTPException(
status_code=422, detail="answer must be a string of length 1..10000"
)
issue = ISSUES[issue_id]
now = now_iso()
issue["answer"] = answer
issue["status"] = "answered"
issue["answered_at"] = now
# Mirror: in the real API, answering transitions the parent
# work item to 'spec' and resets attempts. We don't replicate
# the side effects here — the e2e just needs the issue's status
# to flip so the drawer re-renders with the open-issues list
# empty.
return {
"id": issue["id"],
"work_item_id": issue["work_item_id"],
"question": issue["question"],
"answer": issue["answer"],
"status": issue["status"],
"created_at": issue["created_at"],
"answered_at": issue["answered_at"],
}
@app.get("/v1/cost")
def cost_summary(
project: Optional[str] = None,
since: Optional[str] = None,
until: Optional[str] = None,
days: int = 7,
) -> dict[str, Any]:
"""P5: synthetic 7-day cost summary. Deterministic for e2e asserts.
Real data shape mirrors the Pydantic CostSummaryResponse. Numbers
are picked so the CostSparkline widget has a non-empty polyline
to assert on, with one clearly-higher day to make the visual
shape obvious.
"""
if not (1 <= days <= 365):
raise HTTPException(status_code=422, detail="days must be 1..365")
# Build a synthetic `by_day` window: [today-days+1, today].
# Use an arbitrary fixed reference date so the e2e test is
# deterministic (real data is server-time-derived).
today = datetime(2026, 6, 24, tzinfo=timezone.utc)
by_day: dict[str, str] = {}
total = 0.0
# Pattern: small / small / small / SPIKE / medium / medium / medium
pattern_usd = [0.05, 0.07, 0.04, 1.20, 0.30, 0.28, 0.35]
for i in range(days):
d = today - _td(days=days - 1 - i)
key = d.date().isoformat()
usd = pattern_usd[i] if i < len(pattern_usd) else 0.20
by_day[key] = f"{usd:.6f}"
total += usd
by_project = {
"wh40k-pc": f"{total * 0.6:.6f}",
"iso-tank-arena": f"{total * 0.4:.6f}",
}
by_model = {
"claude-sonnet-4": f"{total * 0.7:.6f}",
"claude-haiku-4-5": f"{total * 0.3:.6f}",
}
window_start = (today - _td(days=days - 1)).isoformat()
window_end = today.isoformat()
return {
"total_usd": f"{total:.6f}",
"by_project": by_project,
"by_model": by_model,
"by_day": by_day,
"window_start": window_start,
"window_end": window_end,
}
# Helper: timedelta in seconds for the synthetic cost window
def _td(*, days: int = 0, hours: int = 0) -> Any:
from datetime import timedelta
return timedelta(days=days, hours=hours)
@app.get("/v1/items/{item_id}")
def get_item(item_id: str) -> dict[str, Any]:
if item_id not in ITEMS:
@@ -274,4 +575,6 @@ def stats() -> dict[str, Any]:
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", 9110)))
# P5: default to 9111 to match the playwright config; CI / clean
# hosts override with PORT=9110.
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", 9111)))

View File

@@ -17,9 +17,9 @@
// Run:
// # In one terminal:
// pip install fastapi uvicorn
// cd ui && uvicorn tests.e2e.fixture_api:app --port 9110
// cd ui && uvicorn tests.e2e.fixture_api:app --port 9111
// # In another:
// cd ui && VITE_API_BASE_URL=http://127.0.0.1:9110 npm run build
// cd ui && VITE_API_BASE_URL=http://127.0.0.1:9111 npm run build
// cd ui && npm run test:e2e
import { test, expect } from "@playwright/test";
@@ -34,7 +34,9 @@ test("dashboard renders phase counts and open issues count", async ({ page }) =>
await page.goto("/");
await expect(page.getByTestId("dashboard-root")).toBeVisible();
await expect(page.getByTestId("phase-bar")).toBeVisible();
await expect(page.getByTestId("open-issues-count")).toHaveText("1");
// The fixture seeds 2 open human_issues (the v1 build item + the
// P5 awaiting_human item).
await expect(page.getByTestId("open-issues-count")).toHaveText("2");
});
test("items page table renders with >= 1 row", async ({ page }) => {

View File

@@ -0,0 +1,106 @@
// Playwright e2e tests for damascus-ui v2 (P5).
//
// Three scenarios per the task body:
// 1. Ingest flow: fill form, submit, redirect to /items/:id
// 2. Dashboard renders the four self-improving widgets
// 3. Answer form: submit, drawer reflects answered state
//
// Plus a mobile-viewport smoke test (375x667) that exercises the
// same /ingest path with a small screen so the user-preference
// "no fixed pixel widths" rule is checked in CI.
//
// The fixture seeds one awaiting_human item (AWAITING_ITEM_ID) with
// one open issue (AWAITING_ISSUE_ID) so the answer-form scenario has
// something to target. The fixture also returns 7 days of cost data
// for the sparkline.
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test("ingest form: fill, submit, redirect to /items/:id", async ({ page }) => {
await page.goto("/#/ingest");
await expect(page.getByTestId("ingest-root")).toBeVisible();
await page.getByTestId("field-project").fill("e2e-test");
await page.getByTestId("field-story_id").fill("story-1");
await page.getByTestId("field-title").fill("E2E test story");
await page
.getByTestId("field-file_scope")
.fill("src/a.ts, src/b.ts");
await page.getByTestId("field-priority").fill("200");
await page.getByTestId("field-budget_cycles").fill("4");
await page.getByTestId("ingest-submit").click();
// Fixture generates a UUID, so we match the URL pattern.
await expect(page).toHaveURL(/#\/items\/[0-9a-f-]{36}$/);
});
test("dashboard renders all four self-improving widgets", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("dashboard-root")).toBeVisible();
await expect(page.getByTestId("phase-bar")).toBeVisible();
await expect(page.getByTestId("open-issues-card")).toBeVisible();
await expect(page.getByTestId("blocked-items-root")).toBeVisible();
await expect(page.getByTestId("cost-sparkline-root")).toBeVisible();
});
test("dashboard renders the project-grouped view with one tab per project", async ({
page,
}) => {
await page.goto("/");
const tabs = page.getByTestId("project-tabs");
await expect(tabs).toBeVisible();
// The fixture has 2 projects: wh40k-pc + iso-tank-arena.
await expect(
page.getByTestId("project-tab-wh40k-pc"),
).toBeVisible();
await expect(
page.getByTestId("project-tab-iso-tank-arena"),
).toBeVisible();
});
test("answer form: submit, drawer reflects answered state", async ({
page,
}) => {
// Navigate to the items table, find the awaiting_human row, open it.
await page.goto("/#/items");
const awaitingRow = page
.locator('[data-testid="items-grid"] .MuiDataGrid-row')
.filter({ hasText: "awaiting-story-01" });
await awaitingRow.click();
await expect(page.getByTestId("item-drawer")).toBeVisible();
await expect(page.getByTestId("answer-form")).toBeVisible();
await page.getByTestId("answer-text").fill("Catppuccin Mocha please");
await page.getByTestId("answer-submit").click();
// After submit, the open-issues list should be empty (the
// answered issue disappears via the useAnswerIssue.onSuccess
// invalidation).
await expect(page.getByTestId("open-issues-list")).toHaveCount(0, {
timeout: 10_000,
});
});
test("mobile viewport: ingest form is usable at 375x667", async ({
browser,
}) => {
// The task body asks the mobile viewport pass: no fixed pixel
// widths. We construct a fresh context at the small viewport and
// re-run the ingest flow to confirm the form is usable.
const context = await browser.newContext({
viewport: { width: 375, height: 667 },
});
const page = await context.newPage();
await page.goto("/#/ingest");
await expect(page.getByTestId("ingest-root")).toBeVisible();
await page.getByTestId("field-project").fill("e2e-mobile");
await page.getByTestId("field-story_id").fill("m-1");
await page.getByTestId("field-title").fill("Mobile e2e test");
await page.getByTestId("ingest-submit").click();
await expect(page).toHaveURL(/#\/items\/[0-9a-f-]{36}$/);
await context.close();
});

View File

@@ -0,0 +1,127 @@
// Unit tests for the BlockedItems widget (P5 §7).
//
// Renders one card per item currently in `blocked` phase, surfacing
// the last_verdict + last_feedback so the operator can see WHY each
// item is stuck without opening the drawer. Each card is clickable →
// drawer for that work item.
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BlockedItems } from "../../src/widgets/BlockedItems";
import * as queries from "../../src/api/queries";
import * as router from "../../src/router";
vi.mock("../../src/api/queries", () => ({
useListItems: vi.fn(),
}));
vi.mock("../../src/router", () => ({
setOpenItem: vi.fn(),
}));
function wrap(node: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
</QueryClientProvider>
);
}
const baseItem = {
file_scope: [] as string[],
attempts: 3,
budget_cycles: 3,
priority: 100,
base_commit: null,
branch: null,
pr_url: null,
spec_path: null,
wiki_pin: null,
claimed_by: null,
claimed_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
merged_at: null,
};
describe("BlockedItems widget (P5)", () => {
it("renders no cards when no items are blocked", () => {
(queries.useListItems as any).mockReturnValue({
data: { items: [], total: 0, limit: 10, offset: 0 },
isLoading: false,
});
const { queryByTestId } = render(wrap(<BlockedItems />));
expect(queryByTestId("blocked-items-root")).toBeTruthy();
expect(queryByTestId("blocked-items-card")).toBeNull();
});
it("renders one card per blocked item showing verdict and feedback", () => {
(queries.useListItems as any).mockReturnValue({
data: {
total: 2,
limit: 10,
offset: 0,
items: [
{
id: "b1",
project: "p",
story_id: "s1",
title: "T1",
phase: "blocked",
last_verdict: "tests_failed",
last_feedback: "AssertionError: expected 0.0, got 0.014",
...baseItem,
},
{
id: "b2",
project: "p",
story_id: "s2",
title: "T2",
phase: "blocked",
last_verdict: "spec_ambiguous",
last_feedback: "ambiguous req X",
...baseItem,
},
],
},
isLoading: false,
});
const { getByTestId, getAllByTestId } = render(wrap(<BlockedItems />));
expect(getByTestId("blocked-items-root")).toBeTruthy();
const cards = getAllByTestId("blocked-items-card");
expect(cards).toHaveLength(2);
expect(getByTestId("blocked-items-card-b1").textContent).toContain("tests_failed");
expect(getByTestId("blocked-items-card-b2").textContent).toContain("spec_ambiguous");
expect(getByTestId("blocked-items-card-b1").textContent).toContain("AssertionError");
});
it("clicking a card opens the drawer for that item", () => {
(queries.useListItems as any).mockReturnValue({
data: {
total: 1,
limit: 10,
offset: 0,
items: [
{
id: "b1",
project: "p",
story_id: "s1",
title: "T1",
phase: "blocked",
last_verdict: "tests_failed",
last_feedback: "boom",
...baseItem,
},
],
},
isLoading: false,
});
const { getByTestId } = render(wrap(<BlockedItems />));
fireEvent.click(getByTestId("blocked-items-card-b1"));
expect(router.setOpenItem).toHaveBeenCalledWith("b1");
});
});

View File

@@ -0,0 +1,51 @@
// Unit tests for the CostSparkline widget (P5 §7).
//
// Renders the last 7 days of cost (by_day from /v1/cost) as an inline
// SVG polyline. Empty data renders a flat-line placeholder.
//
// Implementation note: we use a small inline SVG (no MUI X-Charts dep
// — keeps the bundle small for a self-improving widget the operator
// sees at a glance). The polyline string is space-separated "x,y"
// pairs, one per day.
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { CostSparkline } from "../../src/widgets/CostSparkline";
function wrap(node: React.ReactNode) {
return <ThemeProvider theme={createTheme()}>{node}</ThemeProvider>;
}
describe("CostSparkline widget (P5)", () => {
it("renders an SVG with one polyline point per day", () => {
const byDay = {
"2026-06-18": "0.10",
"2026-06-19": "0.20",
"2026-06-20": "0.15",
};
const { getByTestId } = render(wrap(<CostSparkline byDay={byDay} />));
const poly = getByTestId(
"cost-sparkline-polyline",
) as unknown as SVGPolylineElement;
expect(poly).toBeTruthy();
// 3 points => "x1,y1 x2,y2 x3,y3"
const points = poly.getAttribute("points")!.trim().split(/\s+/);
expect(points).toHaveLength(3);
});
it("renders a flat-line empty state when byDay is empty", () => {
const { getByTestId, queryByTestId } = render(
wrap(<CostSparkline byDay={{}} />),
);
expect(getByTestId("cost-sparkline-empty")).toBeTruthy();
expect(queryByTestId("cost-sparkline-polyline")).toBeNull();
});
it("renders the root with data-testid cost-sparkline-root", () => {
const { getByTestId } = render(
wrap(<CostSparkline byDay={{ "2026-06-20": "0.5" }} />),
);
expect(getByTestId("cost-sparkline-root")).toBeTruthy();
});
});

View File

@@ -0,0 +1,138 @@
// Unit tests for the Dashboard route (P5).
//
// The P5 Dashboard composes the four self-improving widgets
// (PhaseBar, OpenIssues, BlockedItems, CostSparkline) at the top and
// a project-grouped view (Tabs) below, driven by useGroupedItems.
// We mock the queries module so the test doesn't make any network
// calls and we can assert composition directly.
import { describe, it, expect, vi } from "vitest";
import { render, within } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Dashboard } from "../../src/routes/Dashboard";
import * as queries from "../../src/api/queries";
vi.mock("../../src/api/queries", () => ({
useStats: vi.fn(),
useGroupedItems: vi.fn(),
useCostSummary: vi.fn(),
useListItems: vi.fn(),
useOpenIssues: vi.fn(),
}));
function wrap(node: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
</QueryClientProvider>
);
}
describe("Dashboard (P5)", () => {
it("renders the four self-improving widgets", () => {
(queries.useStats as any).mockReturnValue({
data: {
phase_counts: { spec: 1, build: 1, review: 0, merged: 1, blocked: 1, awaiting_human: 1 },
open_human_issues: 1,
active_claims: 0,
last_cycle_at: null,
cost_today_usd: "0.00",
},
isLoading: false,
error: null,
});
(queries.useGroupedItems as any).mockReturnValue({
data: { groups: [], total_items: 0, total_projects: 0 },
isLoading: false,
});
(queries.useCostSummary as any).mockReturnValue({
data: {
total_usd: "0",
by_project: {},
by_model: {},
by_day: {},
window_start: "2026-06-18T00:00:00Z",
window_end: "2026-06-24T00:00:00Z",
},
isLoading: false,
});
(queries.useListItems as any).mockReturnValue({
data: { items: [], total: 0, limit: 10, offset: 0 },
isLoading: false,
});
(queries.useOpenIssues as any).mockReturnValue({
data: { issues: [], total: 0, limit: 5, offset: 0 },
isLoading: false,
});
const { getByTestId } = render(wrap(<Dashboard />));
expect(getByTestId("dashboard-root")).toBeTruthy();
expect(getByTestId("phase-bar")).toBeTruthy();
expect(getByTestId("open-issues-card")).toBeTruthy();
expect(getByTestId("blocked-items-root")).toBeTruthy();
expect(getByTestId("cost-sparkline-root")).toBeTruthy();
});
it("renders one project tab per group", () => {
(queries.useStats as any).mockReturnValue({
data: {
phase_counts: { spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0 },
open_human_issues: 0,
active_claims: 0,
last_cycle_at: null,
cost_today_usd: "0",
},
isLoading: false,
error: null,
});
(queries.useGroupedItems as any).mockReturnValue({
data: {
groups: [
{
project: "wh40k-pc",
items: [],
phase_counts: { spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0 },
},
{
project: "iso-tank-arena",
items: [],
phase_counts: { spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0 },
},
],
total_items: 0,
total_projects: 2,
},
isLoading: false,
});
(queries.useCostSummary as any).mockReturnValue({
data: {
total_usd: "0",
by_project: {},
by_model: {},
by_day: {},
window_start: "",
window_end: "",
},
isLoading: false,
});
(queries.useListItems as any).mockReturnValue({
data: { items: [], total: 0, limit: 10, offset: 0 },
isLoading: false,
});
(queries.useOpenIssues as any).mockReturnValue({
data: { issues: [], total: 0, limit: 5, offset: 0 },
isLoading: false,
});
const { getByTestId } = render(wrap(<Dashboard />));
const tabs = getByTestId("project-tabs");
const tabList = within(tabs).getAllByRole("tab");
expect(tabList).toHaveLength(2);
expect(tabList[0].textContent).toContain("wh40k-pc");
expect(tabList[1].textContent).toContain("iso-tank-arena");
});
});

View File

@@ -0,0 +1,172 @@
// Unit tests for the Ingest form route (P5).
//
// Form fields mirror IngestStoryRequest:
// - project (1..64)
// - story_id (1..128)
// - title (1..255)
// - file_scope (multiline, comma-separated → string[] on submit)
// - priority (0..1000, default 100)
// - budget_cycles (1..10, default 3)
//
// On submit:
// - per-field validation runs (matches Pydantic min/max length, ge/le)
// - successful submit calls useIngestStory.mutateAsync(parsedBody)
// - on success, navigate(`/items/${item.id}`)
// - on error, surface as <Alert severity="error">
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent, waitFor } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Ingest } from "../../src/routes/Ingest";
import * as queries from "../../src/api/queries";
vi.mock("../../src/api/queries", () => ({
useIngestStory: vi.fn(),
}));
const navigateMock = vi.fn();
vi.mock("../../src/router", () => ({
navigate: (...args: unknown[]) => navigateMock(...args),
useRoute: vi.fn(() => ({ name: "ingest" })),
}));
beforeEach(() => {
navigateMock.mockReset();
});
function wrap(node: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
</QueryClientProvider>
);
}
describe("Ingest route (P5)", () => {
it("renders all six fields", () => {
(queries.useIngestStory as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
error: null,
});
const { getByTestId } = render(wrap(<Ingest />));
for (const f of [
"project",
"story_id",
"title",
"file_scope",
"priority",
"budget_cycles",
]) {
expect(getByTestId(`field-${f}`)).toBeTruthy();
}
expect(getByTestId("ingest-submit")).toBeTruthy();
});
it("blocks submit when project is empty (Pydantic min_length=1)", () => {
(queries.useIngestStory as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
error: null,
});
const { getByTestId, queryAllByText } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
fireEvent.click(getByTestId("ingest-submit"));
// The validation error appears in both the field's helperText and
// the trailing FormHelperText list, so queryAllByText is the right
// matcher.
const matches = queryAllByText(/project is required/i);
expect(matches.length).toBeGreaterThan(0);
});
it("submits with parsed body and navigates on success", async () => {
const mutate = vi.fn().mockResolvedValue({
item: { id: "abc-123-def-456" },
created: true,
});
(queries.useIngestStory as any).mockReturnValue({
mutateAsync: mutate,
isPending: false,
error: null,
});
const { getByTestId } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
fireEvent.change(getByTestId("field-file_scope"), {
target: { value: "src/a.ts, src/b.ts" },
});
fireEvent.change(getByTestId("field-priority"), { target: { value: "200" } });
fireEvent.change(getByTestId("field-budget_cycles"), { target: { value: "4" } });
fireEvent.click(getByTestId("ingest-submit"));
await waitFor(() => expect(mutate).toHaveBeenCalled());
const call = mutate.mock.calls[0][0];
expect(call).toEqual({
project: "p1",
story_id: "s1",
title: "T1",
file_scope: ["src/a.ts", "src/b.ts"],
priority: 200,
budget_cycles: 4,
});
await waitFor(() =>
expect(navigateMock).toHaveBeenCalledWith("/items/abc-123-def-456"),
);
});
it("rejects priority out of range (Pydantic ge=0, le=1000)", () => {
(queries.useIngestStory as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
error: null,
});
const { getByTestId, queryAllByText } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
fireEvent.change(getByTestId("field-priority"), { target: { value: "2000" } });
fireEvent.click(getByTestId("ingest-submit"));
expect(
queryAllByText(/priority must be an integer between 0 and 1000/i).length,
).toBeGreaterThan(0);
});
it("rejects budget_cycles out of range (Pydantic ge=1, le=10)", () => {
(queries.useIngestStory as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
error: null,
});
const { getByTestId, queryAllByText } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
fireEvent.change(getByTestId("field-budget_cycles"), { target: { value: "99" } });
fireEvent.click(getByTestId("ingest-submit"));
expect(
queryAllByText(/budget[ _]cycles must be an integer between 1 and 10/i).length,
).toBeGreaterThan(0);
});
it("shows a network error alert when mutateAsync rejects", async () => {
const mutate = vi.fn().mockRejectedValue(new Error("network down"));
(queries.useIngestStory as any).mockReturnValue({
mutateAsync: mutate,
isPending: false,
error: null,
});
const { getByTestId, getByText } = render(wrap(<Ingest />));
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
fireEvent.click(getByTestId("ingest-submit"));
await waitFor(() =>
expect(getByText(/network down/i)).toBeTruthy(),
);
});
});

View File

@@ -0,0 +1,229 @@
// Unit tests for the ItemDrawer answer form (P5 §7).
//
// The answer form renders inside the drawer ONLY when:
// - item.phase === 'awaiting_human'
// - open_issues.length > 0
// And it targets the FIRST open issue (UI is per-item, not per-issue
// — answering one unblocks the parent work item).
//
// Submit calls useAnswerIssue(issue.id) with the textarea value.
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent, waitFor } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ItemDrawer } from "../../src/routes/ItemDrawer";
import * as queries from "../../src/api/queries";
import * as router from "../../src/router";
vi.mock("../../src/api/queries", () => ({
useItemDetail: vi.fn(),
useRecentEvents: vi.fn(),
useAnswerIssue: vi.fn(),
}));
vi.mock("../../src/router", () => ({
useOpenItemId: vi.fn(),
setOpenItem: vi.fn(),
}));
const AWAITING_ID = "55555555-5555-4555-8555-555555555555";
const ISSUE_ID = "66666666-6666-4666-8666-666666666666";
function wrap(node: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
</QueryClientProvider>
);
}
const baseItem = {
id: AWAITING_ID,
project: "wh40k-pc",
story_id: "awaiting-story-01",
title: "Pick the palette",
file_scope: [] as string[],
attempts: 1,
budget_cycles: 3,
priority: 250,
base_commit: "abc1234",
branch: "feat/palette",
pr_url: null,
last_verdict: "spec_ambiguous",
last_feedback: "Spec asks for 'discord-inspired' but no palette.",
spec_path: null,
wiki_pin: null,
claimed_by: "orch-1",
claimed_at: "2026-06-24T12:00:00+00:00",
created_at: "2026-06-24T11:30:00+00:00",
updated_at: "2026-06-24T12:30:00+00:00",
merged_at: null,
};
describe("ItemDrawer answer form (P5)", () => {
it("renders the answer form when phase is awaiting_human and there are open issues", () => {
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
(queries.useItemDetail as any).mockReturnValue({
data: {
item: { ...baseItem, phase: "awaiting_human" },
open_issues: [
{
id: ISSUE_ID,
work_item_id: AWAITING_ID,
question: "Which palette?",
answer: null,
status: "open",
created_at: "2026-06-24T12:30:00+00:00",
answered_at: null,
},
],
recent_events: [],
},
isLoading: false,
});
(queries.useRecentEvents as any).mockReturnValue({
data: { events: [], next_since_id: null },
isLoading: false,
});
(queries.useAnswerIssue as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
});
const { getByTestId } = render(wrap(<ItemDrawer />));
expect(getByTestId("answer-form")).toBeTruthy();
expect(getByTestId("answer-text")).toBeTruthy();
expect(getByTestId("answer-submit")).toBeTruthy();
});
it("does NOT render the answer form for non-awaiting items", () => {
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
(queries.useItemDetail as any).mockReturnValue({
data: {
item: { ...baseItem, phase: "build" },
open_issues: [],
recent_events: [],
},
isLoading: false,
});
(queries.useRecentEvents as any).mockReturnValue({
data: { events: [], next_since_id: null },
isLoading: false,
});
(queries.useAnswerIssue as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
});
const { queryByTestId } = render(wrap(<ItemDrawer />));
expect(queryByTestId("answer-form")).toBeNull();
});
it("does NOT render the answer form for awaiting_human with no open issues", () => {
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
(queries.useItemDetail as any).mockReturnValue({
data: {
item: { ...baseItem, phase: "awaiting_human" },
open_issues: [],
recent_events: [],
},
isLoading: false,
});
(queries.useRecentEvents as any).mockReturnValue({
data: { events: [], next_since_id: null },
isLoading: false,
});
(queries.useAnswerIssue as any).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
});
const { queryByTestId } = render(wrap(<ItemDrawer />));
expect(queryByTestId("answer-form")).toBeNull();
});
it("submit calls useAnswerIssue(issue.id).mutateAsync with the typed answer", async () => {
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
(queries.useItemDetail as any).mockReturnValue({
data: {
item: { ...baseItem, phase: "awaiting_human" },
open_issues: [
{
id: ISSUE_ID,
work_item_id: AWAITING_ID,
question: "Which palette?",
answer: null,
status: "open",
created_at: "2026-06-24T12:30:00+00:00",
answered_at: null,
},
],
recent_events: [],
},
isLoading: false,
});
(queries.useRecentEvents as any).mockReturnValue({
data: { events: [], next_since_id: null },
isLoading: false,
});
const mutate = vi.fn().mockResolvedValue({
id: ISSUE_ID,
work_item_id: AWAITING_ID,
question: "Which palette?",
answer: "Catppuccin Mocha",
status: "answered",
created_at: "2026-06-24T12:30:00+00:00",
answered_at: "2026-06-24T13:00:00+00:00",
});
(queries.useAnswerIssue as any).mockReturnValue({
mutateAsync: mutate,
isPending: false,
});
const { getByTestId } = render(wrap(<ItemDrawer />));
fireEvent.change(getByTestId("answer-text"), {
target: { value: "Catppuccin Mocha" },
});
fireEvent.click(getByTestId("answer-submit"));
await waitFor(() => expect(mutate).toHaveBeenCalledWith("Catppuccin Mocha"));
});
it("blocks submit when the answer is empty", async () => {
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
(queries.useItemDetail as any).mockReturnValue({
data: {
item: { ...baseItem, phase: "awaiting_human" },
open_issues: [
{
id: ISSUE_ID,
work_item_id: AWAITING_ID,
question: "Which palette?",
answer: null,
status: "open",
created_at: "2026-06-24T12:30:00+00:00",
answered_at: null,
},
],
recent_events: [],
},
isLoading: false,
});
(queries.useRecentEvents as any).mockReturnValue({
data: { events: [], next_since_id: null },
isLoading: false,
});
const mutate = vi.fn();
(queries.useAnswerIssue as any).mockReturnValue({
mutateAsync: mutate,
isPending: false,
});
const { getByTestId, getByText } = render(wrap(<ItemDrawer />));
fireEvent.click(getByTestId("answer-submit"));
expect(mutate).not.toHaveBeenCalled();
expect(getByText(/answer is required/i)).toBeTruthy();
});
});

View File

@@ -0,0 +1,114 @@
// Unit tests for the OpenIssues widget (P5 §7).
//
// The widget renders a count (from useStats) plus a list of the last
// N open issues (from useOpenIssues). Each list item is clickable and
// triggers setOpenItem(item.work_item_id) to open the drawer for the
// parent work item.
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { OpenIssues } from "../../src/widgets/OpenIssues";
import * as queries from "../../src/api/queries";
import * as router from "../../src/router";
vi.mock("../../src/api/queries", () => ({
useStats: vi.fn(),
useOpenIssues: vi.fn(),
}));
vi.mock("../../src/router", () => ({
setOpenItem: vi.fn(),
navigate: vi.fn(),
useRoute: vi.fn(),
useOpenItemId: vi.fn(),
useHashWrite: vi.fn(),
}));
function wrap(node: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={qc}>
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
</QueryClientProvider>
);
}
describe("OpenIssues widget (P5)", () => {
it("renders the count from useStats", () => {
(queries.useStats as any).mockReturnValue({
data: { open_human_issues: 7 },
isLoading: false,
error: null,
});
(queries.useOpenIssues as any).mockReturnValue({
data: { issues: [], total: 0, limit: 5, offset: 0 },
isLoading: false,
});
const { getByTestId } = render(wrap(<OpenIssues />));
expect(getByTestId("open-issues-count").textContent).toBe("7");
});
it("renders the last 5 open issues, each clickable, calling setOpenItem with the work item id", () => {
(queries.useStats as any).mockReturnValue({
data: { open_human_issues: 2 },
isLoading: false,
error: null,
});
(queries.useOpenIssues as any).mockReturnValue({
data: {
total: 2,
limit: 5,
offset: 0,
issues: [
{
id: "i1",
work_item_id: "w-uuid-1",
question: "Q1",
answer: null,
status: "open",
created_at: "2026-01-01T00:00:00Z",
answered_at: null,
},
{
id: "i2",
work_item_id: "w-uuid-2",
question: "Q2",
answer: null,
status: "open",
created_at: "2026-01-02T00:00:00Z",
answered_at: null,
},
],
},
isLoading: false,
});
const { getAllByTestId } = render(wrap(<OpenIssues />));
const items = getAllByTestId("open-issues-item");
expect(items).toHaveLength(2);
fireEvent.click(items[0]);
expect(router.setOpenItem).toHaveBeenCalledWith("w-uuid-1");
fireEvent.click(items[1]);
expect(router.setOpenItem).toHaveBeenCalledWith("w-uuid-2");
});
it("renders a 'no open issues' empty state when count is zero", () => {
(queries.useStats as any).mockReturnValue({
data: { open_human_issues: 0 },
isLoading: false,
error: null,
});
(queries.useOpenIssues as any).mockReturnValue({
data: { issues: [], total: 0, limit: 5, offset: 0 },
isLoading: false,
});
const { getByTestId, queryAllByTestId } = render(wrap(<OpenIssues />));
expect(getByTestId("open-issues-count").textContent).toBe("0");
expect(queryAllByTestId("open-issues-item")).toHaveLength(0);
expect(getByTestId("open-issues-empty").textContent).toMatch(/none/i);
});
});

View File

@@ -0,0 +1,69 @@
// Unit tests for the PhaseBar widget.
//
// PhaseBar is the §7 "phase counts as a stacked bar" widget. It takes
// pre-fetched phase_counts and a total (the dashboard feeds both from
// useStats), and renders a Paper with one Box per non-zero phase,
// widths proportional to count. The component is purely presentational
// — no fetch logic, no MUI theme coupling beyond defaults — so the
// tests mount it with a bare createTheme() to keep the test file
// independent of the production palette choices.
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material";
import { PhaseBar } from "../../src/widgets/PhaseBar";
import type { WorkItemPhase } from "../../src/types";
function wrap(node: React.ReactNode) {
return <ThemeProvider theme={createTheme()}>{node}</ThemeProvider>;
}
describe("PhaseBar widget (P5)", () => {
it("renders nothing (no phase-bar root) when total is zero", () => {
const counts: Record<WorkItemPhase, number> = {
spec: 0,
build: 0,
review: 0,
merged: 0,
blocked: 0,
awaiting_human: 0,
};
const { queryByTestId } = render(wrap(<PhaseBar counts={counts} total={0} />));
expect(queryByTestId("phase-bar")).toBeNull();
});
it("renders one segment per non-zero phase, widths proportional", () => {
const counts: Record<WorkItemPhase, number> = {
spec: 0,
build: 2,
review: 0,
merged: 6,
blocked: 2,
awaiting_human: 0,
};
const { getByTestId } = render(wrap(<PhaseBar counts={counts} total={10} />));
expect(getByTestId("phase-bar")).toBeTruthy();
// 2/10 = 20%, 6/10 = 60%, 2/10 = 20%
const build = getByTestId("phase-bar-build") as HTMLElement;
const merged = getByTestId("phase-bar-merged") as HTMLElement;
const blocked = getByTestId("phase-bar-blocked") as HTMLElement;
expect(build.style.width).toBe("20%");
expect(merged.style.width).toBe("60%");
expect(blocked.style.width).toBe("20%");
});
it("hides segments for phases with zero count", () => {
const counts: Record<WorkItemPhase, number> = {
spec: 0,
build: 1,
review: 0,
merged: 0,
blocked: 0,
awaiting_human: 0,
};
const { queryByTestId } = render(wrap(<PhaseBar counts={counts} total={1} />));
expect(queryByTestId("phase-bar-build")).toBeTruthy();
expect(queryByTestId("phase-bar-spec")).toBeNull();
expect(queryByTestId("phase-bar-merged")).toBeNull();
});
});

View File

@@ -0,0 +1,65 @@
// Unit tests for ui/src/api/client.ts (P5).
//
// The Authorization-on-write behavior is the only piece of the fetch
// wrapper that's worth unit-testing in isolation: component-level
// tests would force a full MUI render just to assert one header. The
// other paths (URL building, JSON parsing, error mapping) are
// exercised by the e2e suite.
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => {
vi.resetModules();
mockFetch.mockReset();
});
describe("api client auth (P5)", () => {
it("sends Authorization Bearer header on POST when VITE_API_WRITE_TOKEN is set", async () => {
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
const { api } = await import("../../src/api/client");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true }),
} as unknown as Response);
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/v1/items");
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer test-token-abc");
expect(headers["Content-Type"]).toBe("application/json");
});
it("does NOT send Authorization on GET even when token is set", async () => {
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
const { api } = await import("../../src/api/client");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
} as unknown as Response);
await api.get<{ items: unknown[] }>("/v1/items");
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it("omits Authorization on POST when VITE_API_WRITE_TOKEN is empty (read-only deployments)", async () => {
vi.stubEnv("VITE_API_WRITE_TOKEN", "");
const { api } = await import("../../src/api/client");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true }),
} as unknown as Response);
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
expect(headers["Content-Type"]).toBe("application/json");
});
});

14
ui/tests/unit/setup.ts Normal file
View File

@@ -0,0 +1,14 @@
// Vitest setup file — runs once per test file before any tests.
//
// Adds @testing-library/jest-dom matchers (toBeInTheDocument etc.) and
// makes sure each test starts with a clean env. We don't auto-cleanup
// the React tree here; tests call cleanup() explicitly when they mount
// components, or rely on the @testing-library/react auto-cleanup in
// afterEach (the default in v16+).
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

31
ui/vitest.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
// vitest config for the damascus-ui unit-test suite.
//
// Two pools:
// - "node" (default): the api client is a plain TS module; no DOM.
// - "jsdom": component tests under tests/unit/ that mount
// React. We use environmentMatchGlobs to route by
// path: *.test.ts in node pool, *.test.tsx in
// jsdom pool.
//
// Why the React deps aren't listed in deps.optimizer: vitest's
// optimizer chokes on MUI's emotion-styled without explicit config;
// we let it scan imports instead, which is the default.
export default defineConfig({
plugins: [react()],
test: {
environmentMatchGlobs: [
["tests/unit/*.test.tsx", "jsdom"],
// The api client uses `window.location.origin` to build the
// request URL (same-origin in production) and the fetch header
// construction needs to exercise the browser-style Authorization
// path. jsdom gives us both, and the test only calls into the
// module — it doesn't render anything.
["tests/unit/*.test.ts", "jsdom"],
],
include: ["tests/unit/**/*.test.{ts,tsx}"],
setupFiles: ["./tests/unit/setup.ts"],
},
});