diff --git a/webapp/.gitignore b/webapp/.gitignore new file mode 100644 index 00000000..7f12b07f --- /dev/null +++ b/webapp/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +playwright-report/ +test-results/ +e2e/.kb-state.json +.superpowers/ diff --git a/webapp/README.md b/webapp/README.md new file mode 100644 index 00000000..4fd11745 --- /dev/null +++ b/webapp/README.md @@ -0,0 +1,63 @@ +# vouch-ui + +A chat-first browser console for [vouch](https://github.com/vouchdev/vouch) — the +git-native, review-gated knowledge base for LLM agents. Inspired by the AgentOS +control-plane UX: point it at a running endpoint and talk to your KB. + +## What you get + +- **Chat** — ask questions; answers are synthesized *only* from approved claims, + with clickable citation chips, a confidence badge, and explicit gaps. `/search + ` runs a raw index search with highlighted hits. The terminal toggle + switches to **Claude Code mode**: messages run `claude -p` headless in the + project workspace (dev server only, via `plugins/claude-bridge.ts`), stream + progress, and keep session continuity turn to turn. Set `VOUCH_PROJECT_DIR` + to point the bridge at a different workspace (default: `../vouch`). +- **Review** — captured sessions that still need an LLM summary: one click + runs `kb.summarize_session` and the result moves on to Pending. +- **Pending** — the propose → approve gate, made visible: pending queue, + payload inspection, approve / reject (with a required, audited reason). +- **Claims** — every approved claim, with a **Start Here** button that opens + the chat in Claude Code mode seeded with the claim — resuming the claim's + originating session when provenance records one. +- **Browse** — claims, pages, entities, relations, with citations and + provenance ("why does this claim exist?") in a detail drawer. +- **Stats** — artifact counts, review throughput, citation coverage, endpoint + health and capabilities. + +## Quick start + +This app lives inside the [vouch](https://github.com/vouchdev/vouch) repo under +`webapp/`. From the repo root, one command runs the backend and this console +together (installs deps on first run, Ctrl-C stops both): + + make console # vouch serve + this UI, then open :5173 + +To run just the console against a vouch you started yourself: + + # 1. serve a knowledge base (any directory with a .vouch/) + vouch serve --transport http # binds 127.0.0.1:8731 + + # 2. run the console + npm install + npm run dev # http://localhost:5173 + +Enter the endpoint (default `http://127.0.0.1:8731`) and an optional bearer +token in the connect dialog. The Vite server proxies requests same-origin +(`/proxy/*` → your endpoint via the `X-Vouch-Target` header), so vouch needs +no CORS configuration and stays unmodified. + +## Scripts + + npm run dev # dev server + npm run build # typecheck + production build + npm run preview # serve the production build (proxy included) + npm test # unit + component tests (vitest) + npm run e2e # playwright smoke test (needs `vouch` on PATH) + +## Notes + +- The UI is capability-aware: views light up based on what `GET /capabilities` + advertises. A read-only endpoint renders a read-only console. +- vouch's default self-approval guard applies: approving a proposal you + proposed (as the same agent identity) fails with `forbidden_self_approval`. diff --git a/webapp/app.py b/webapp/app.py new file mode 100644 index 00000000..07fbfbb6 --- /dev/null +++ b/webapp/app.py @@ -0,0 +1,314 @@ +"""vouch-ui — a self-hosted console over a vouch knowledge base. + +One FastAPI process, one static page. The browser talks only to this app; +this app talks only to `vouch serve --transport http` (POST /rpc) and, when +a key is configured, to one OpenAI-compatible chat endpoint. + +The review gate stays exactly where vouch put it: this app can search, read, +and *propose* — the LLM is never given an approve tool, and no route here +calls kb.approve / kb.reject. Reviewing stays in `vouch review-ui` / the CLI. + +Config (env, or a .env file loaded by run.sh): + VOUCH_RPC_URL default http://127.0.0.1:8731/rpc + VOUCH_HTTP_TOKEN bearer token if the vouch server requires one + LLM_API_KEY enables chat; without it the console is read-only + LLM_BASE_URL default https://api.openai.com/v1 (any OpenAI-compatible) + LLM_MODEL default gpt-5.4 + UI_HOST/UI_PORT default 127.0.0.1:8900 +""" + +from __future__ import annotations + +import json +import os +import uuid +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +VOUCH_RPC_URL = os.environ.get("VOUCH_RPC_URL", "http://127.0.0.1:8731/rpc") +VOUCH_HTTP_TOKEN = os.environ.get("VOUCH_HTTP_TOKEN", "") +LLM_API_KEY = os.environ.get("LLM_API_KEY", "") +LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/") +LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-5.4") + +STATIC_DIR = Path(__file__).parent / "static" +MAX_TOOL_ROUNDS = 6 +MAX_HISTORY_MESSAGES = 24 + +app = FastAPI(title="vouch-ui") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +# in-memory chat sessions; the durable record lives in vouch, not here +_sessions: dict[str, list[dict[str, Any]]] = {} + + +class RpcError(Exception): + pass + + +async def rpc(method: str, params: dict[str, Any] | None = None) -> Any: + headers = {"Content-Type": "application/json", "X-Vouch-Agent": "vouch-ui"} + if VOUCH_HTTP_TOKEN: + headers["Authorization"] = f"Bearer {VOUCH_HTTP_TOKEN}" + envelope = {"id": str(uuid.uuid4()), "method": method, "params": params or {}} + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post(VOUCH_RPC_URL, json=envelope, headers=headers) + except httpx.HTTPError as e: + raise RpcError(f"vouch server unreachable at {VOUCH_RPC_URL}: {e}") from e + if resp.status_code != 200: + raise RpcError(f"vouch /rpc returned HTTP {resp.status_code}") + body = resp.json() + if not body.get("ok"): + err = body.get("error") or {} + raise RpcError(err.get("message") or "vouch rpc error") + return body.get("result") + + +# --- read routes (work with no LLM key) ------------------------------------ + + +@app.get("/") +async def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/api/overview") +async def overview() -> dict[str, Any]: + try: + status = await rpc("kb.status") + digest = await rpc("kb.digest", {"since": "7d", "limit": 8}) + except RpcError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + return {"status": status, "digest": digest, "llm_configured": bool(LLM_API_KEY), + "llm_model": LLM_MODEL if LLM_API_KEY else None} + + +@app.get("/api/pending") +async def pending() -> Any: + try: + return await rpc("kb.list_pending") + except RpcError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + + +@app.get("/api/search") +async def search(q: str, limit: int = 12) -> Any: + try: + return await rpc("kb.search", {"query": q, "limit": limit}) + except RpcError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + + +@app.get("/api/record/{record_id}") +async def record(record_id: str) -> dict[str, Any]: + """Resolve an id against claims, pages, entities, then sources.""" + for kind, method, key in ( + ("claim", "kb.read_claim", "claim_id"), + ("page", "kb.read_page", "page_id"), + ("entity", "kb.read_entity", "entity_id"), + ): + try: + body = await rpc(method, {key: record_id}) + result: dict[str, Any] = {"kind": kind, "record": body} + if kind == "claim" and body.get("evidence"): + sources = await rpc("kb.list_sources") + wanted = set(body["evidence"]) + result["evidence_sources"] = [ + s for s in sources if s.get("id") in wanted + ] + return result + except RpcError: + continue + try: + sources = await rpc("kb.list_sources") + except RpcError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + for s in sources: + if s.get("id") == record_id: + return {"kind": "source", "record": s} + raise HTTPException(status_code=404, detail=f"no record with id {record_id!r}") + + +# --- chat (needs LLM_API_KEY) ---------------------------------------------- + +SYSTEM_PROMPT = """You are the console over a review-gated team knowledge base. + +Rules: +- Answer only from the KB via your tools. Cite every claim, page, or source + you rely on as [its-id] inline. If the KB cannot support an answer, say so + and show the closest results — never substitute general knowledge silently. +- "What needs attention" -> kb_digest. +- When asked to remember or record something: kb_register_source with the + user's words, then kb_propose_claim / kb_propose_page citing that source + id. Report the proposal ids and note they await human review. +- You have no approve tool, and that is by design: reviewing is the human's + job. Do not offer to approve anything. +- Be terse. Ids in brackets, no restating tool output verbatim. +""" + +TOOLS: list[dict[str, Any]] = [ + {"type": "function", "function": { + "name": "kb_search", + "description": "Search the KB (claims, pages, entities). Returns scored hits.", + "parameters": {"type": "object", "properties": { + "query": {"type": "string"}, "limit": {"type": "integer", "default": 10}}, + "required": ["query"]}}}, + {"type": "function", "function": { + "name": "kb_context", + "description": "Assemble a cited context pack for a task or question.", + "parameters": {"type": "object", "properties": { + "task": {"type": "string"}, "limit": {"type": "integer", "default": 10}}, + "required": ["task"]}}}, + {"type": "function", "function": { + "name": "kb_read_claim", + "description": "Read one claim by id.", + "parameters": {"type": "object", "properties": { + "claim_id": {"type": "string"}}, "required": ["claim_id"]}}}, + {"type": "function", "function": { + "name": "kb_read_page", + "description": "Read one page by id.", + "parameters": {"type": "object", "properties": { + "page_id": {"type": "string"}}, "required": ["page_id"]}}}, + {"type": "function", "function": { + "name": "kb_list_pages", + "description": "List pages, filterable by kind and frontmatter " + "(e.g. type='followup', meta={'followup_status': 'open'}).", + "parameters": {"type": "object", "properties": { + "type": {"type": "string"}, + "meta": {"type": "object"}, + "meta_before": {"type": "object"}, + "meta_after": {"type": "object"}}}}}, + {"type": "function", "function": { + "name": "kb_digest", + "description": "Reviewer briefing: pending queue, recent decisions, stale claims, followups due.", + "parameters": {"type": "object", "properties": { + "since": {"type": "string", "default": "7d"}, + "limit": {"type": "integer", "default": 10}}}}}, + {"type": "function", "function": { + "name": "kb_list_pending", + "description": "List proposals awaiting human review.", + "parameters": {"type": "object", "properties": {}}}}, + {"type": "function", "function": { + "name": "kb_register_source", + "description": "Register text as a content-addressed evidence source; returns its id.", + "parameters": {"type": "object", "properties": { + "content": {"type": "string"}, "title": {"type": "string"}, + "source_type": {"type": "string", "default": "message"}}, + "required": ["content", "title"]}}}, + {"type": "function", "function": { + "name": "kb_propose_claim", + "description": "File a claim PROPOSAL citing evidence source ids. Lands pending; a human approves.", + "parameters": {"type": "object", "properties": { + "text": {"type": "string"}, + "evidence": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}}, + "required": ["text", "evidence"]}}}, + {"type": "function", "function": { + "name": "kb_propose_page", + "description": "File a page PROPOSAL (optionally a typed kind with metadata frontmatter). Lands pending.", + "parameters": {"type": "object", "properties": { + "title": {"type": "string"}, "body": {"type": "string"}, + "page_type": {"type": "string", "default": "concept"}, + "metadata": {"type": "object"}, + "source_ids": {"type": "array", "items": {"type": "string"}}}, + "required": ["title", "body"]}}}, +] + +_TOOL_TO_METHOD = { + "kb_search": "kb.search", + "kb_context": "kb.context", + "kb_read_claim": "kb.read_claim", + "kb_read_page": "kb.read_page", + "kb_list_pages": "kb.list_pages", + "kb_digest": "kb.digest", + "kb_list_pending": "kb.list_pending", + "kb_register_source": "kb.register_source", + "kb_propose_claim": "kb.propose_claim", + "kb_propose_page": "kb.propose_page", +} + + +class ChatRequest(BaseModel): + message: str + session_id: str = "default" + + +async def _llm(messages: list[dict[str, Any]]) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + f"{LLM_BASE_URL}/chat/completions", + headers={"Authorization": f"Bearer {LLM_API_KEY}"}, + json={"model": LLM_MODEL, "messages": messages, "tools": TOOLS}, + ) + if resp.status_code != 200: + detail = resp.text[:300] + raise HTTPException(status_code=502, detail=f"model endpoint error: {detail}") + return resp.json()["choices"][0]["message"] + + +@app.post("/api/chat") +async def chat(req: ChatRequest) -> dict[str, Any]: + if not LLM_API_KEY: + raise HTTPException( + status_code=503, + detail="no model configured — set LLM_API_KEY (and optionally " + "LLM_BASE_URL / LLM_MODEL) to enable chat. Search and the " + "gate panel work without it.", + ) + history = _sessions.setdefault(req.session_id, []) + history.append({"role": "user", "content": req.message}) + messages: list[dict[str, Any]] = [ + {"role": "system", "content": SYSTEM_PROMPT}, + *history[-MAX_HISTORY_MESSAGES:], + ] + trace: list[dict[str, Any]] = [] + + for _ in range(MAX_TOOL_ROUNDS): + msg = await _llm(messages) + calls = msg.get("tool_calls") + if not calls: + reply = msg.get("content") or "(no reply)" + history.append({"role": "assistant", "content": reply}) + return {"reply": reply, "trace": trace} + messages.append(msg) + for call in calls: + name = call["function"]["name"] + try: + args = json.loads(call["function"].get("arguments") or "{}") + except json.JSONDecodeError: + args = {} + method = _TOOL_TO_METHOD.get(name) + if method is None: + payload: Any = {"error": f"unknown tool {name}"} + else: + try: + payload = await rpc(method, args) + except RpcError as e: + payload = {"error": str(e)} + trace.append({"tool": name, "args": args}) + messages.append({ + "role": "tool", + "tool_call_id": call["id"], + "content": json.dumps(payload, default=str)[:20000], + }) + + history.append({"role": "assistant", "content": "(stopped: tool-round limit)"}) + return {"reply": "stopped after too many tool rounds — try a narrower question", + "trace": trace} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + app, + host=os.environ.get("UI_HOST", "127.0.0.1"), + port=int(os.environ.get("UI_PORT", "8900")), + ) diff --git a/webapp/docs/superpowers/plans/2026-07-04-vouch-ui.md b/webapp/docs/superpowers/plans/2026-07-04-vouch-ui.md new file mode 100644 index 00000000..ae9f3b18 --- /dev/null +++ b/webapp/docs/superpowers/plans/2026-07-04-vouch-ui.md @@ -0,0 +1,4081 @@ +# vouch-ui Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A chat-first, AgentOS-style (os.agno.com) browser console for vouch — connect to a running `vouch serve --transport http` endpoint, ask the KB questions with cited answers, review pending proposals, browse artifacts, and watch stats. + +**Architecture:** Pure client-side Vite + React SPA. A custom Vite plugin forwards same-origin `/proxy/*` requests to the endpoint named in an `X-Vouch-Target` header (vouch has no CORS — this is the bridge; vouch stays unmodified). One typed `rpc()` wrapper speaks the vouch envelope; TanStack Query owns server state; React Router provides `/chat`, `/review`, `/browse`, `/stats` behind a sidebar shell. + +**Tech Stack:** Vite 8, React 19, TypeScript (strict), Tailwind CSS v4 (`@tailwindcss/vite`, CSS-first config), TanStack Query v5, React Router v7, lucide-react, Vitest 4 + React Testing Library, Playwright. + +## Global Constraints + +- Node >= 20 (dev machine has v24.14.1, npm 11). +- Install dependencies **unpinned** exactly as written in Task 1. Do NOT pin `vite@7` to match older docs: `@vitejs/plugin-react@6` requires `vite ^8` and the install would conflict. +- vouch is an **unmodified dependency**; the UI only speaks to `vouch serve --transport http`. +- Every server call goes through same-origin `/proxy/` with header `X-Vouch-Target: `; bearer token (if any) rides in `Authorization: Bearer `. +- RPC envelope (verified live): request `{"id","method","params"}`; success `{"id","ok":true,"result":...}`; failure `{"id","ok":false,"error":{"code","message"}}`. `GET /health` → `{"ok":true}`. `GET /capabilities` → `{name, level, methods: string[], review_gated, ...}`. +- Claim ids are kebab-case slugs (e.g. `the-vouch-http-server-binds-127-0-0-1-8731-by-default`); proposal ids look like `20260704-021728-c4b86871`; source ids are sha256 hex. +- Synthesize answers embed citations as ` [].` — verified: `"The vouch HTTP server binds 127.0.0.1:8731 by default [the-vouch-http-server-binds-127-0-0-1-8731-by-default]. …"`. +- Search snippets mark matches with guillemets: `"The vouch «HTTP» «server» binds …"`. +- `kb.reject` **requires** a non-empty `reason` param. `kb.approve` takes `{proposal_id, reason?}` and can fail `forbidden_self_approval` (vouch default forbids approving your own proposal). +- Brand tokens (from vouch's site, copy verbatim): paper `#0d0a09`, paper-2 `#161110`, paper-3 `#201917`, ink `#f5efe8`, ink-2 `#c9bfb5`, sepia `#93887c`, vermillion accent `#ff5a3d` / hover `#ff7c60`, rule `#2a2320`, ok-green `#86c06c`. Mono font: JetBrains Mono → ui-monospace fallback. No external font/CDN requests — system stacks only. +- TypeScript `strict: true`; no `any` unless interfacing with untyped JSON (`unknown` + narrowing preferred). +- Conventional commits (`feat:`, `test:`, `chore:`, `docs:`). +- Working dir: `/home/a/Dev/plind-junior/vouch-ui` (git repo already initialized; spec committed). + +## Verified API surface used by the UI + +| Method | Params | Returns | +|---|---|---| +| `kb.synthesize` | `{query, depth?=3, max_chars?=4000}` | `{query, answer, claims: string[], gaps: string[], _meta:{synthesis_confidence:'low'\|'medium'\|'high'}}` | +| `kb.search` | `{query, limit?=10, backend?='auto'}` | `{backend, viewer:{project,agent}, hits:[{kind,id,snippet,score,backend}]}` | +| `kb.list_pending` | `{}` | `Proposal[]` — `{id, kind, proposed_by, session_id, payload, status, proposed_at, rationale?, decided_at?, decided_by?, decision_reason?}` (NOTE: the timestamp is `proposed_at`, **not** `created_at` — models.py:376) | +| `kb.approve` | `{proposal_id, reason?}` | `{kind, id}` | +| `kb.reject` | `{proposal_id, reason}` (reason required) | `{proposal_id, status:'rejected'}` | +| `kb.list_claims` | `{status?}` | `Claim[]` — `{id, text, type, status, confidence, created_at, ...}` | +| `kb.list_pages` | `{}` | `Page[]` — `{id, title, body, type, status, created_at}` | +| `kb.list_entities` | `{entity_type?}` | `Entity[]` — `{id, name, type, created_at}` | +| `kb.list_relations` | `{node_id?}` | `Relation[]` — `{id, source, relation, target, confidence, created_at}` | +| `kb.read_claim` | `{claim_id}` | `Claim` | +| `kb.read_page` / `kb.read_entity` / `kb.read_relation` | `{page_id}` / `{entity_id}` / `{relation_id}` | artifact dict | +| `kb.cite` | `{claim_id}` | `list` of citation/evidence dicts | +| `kb.why` | `{claim_id, depth?=3}` | `{schema_version, root, node_kind, depth, provenance:[{kind,target,target_kind,event_ts,session_id,cycle,children[]}]}` | +| `kb.status` | `{}` | `{kb_dir, claims, pages, sources, entities, relations, evidence, sessions, pending_proposals, audit_events, index_present}` | +| `kb.stats` | `{days?=30}` | `{kb_dir, generated_at, counts:, pending:{total,by_agent,age_days{median,max,oldest_id}}, review:{window_days (null when days=0), decided_in_window,approved,rejected,expired,approval_rate,audit_totals,by_agent}, citations:{claims_total,claims_loadable,claims_with_valid_citation,broken_citation,invalid_claim,coverage_rate}}` | + +Dict results also carry `_meta.vouch_trust` — ignore it except `synthesis_confidence`. + +## File Structure + +``` +vouch-ui/ +├── package.json / tsconfig.json / vite.config.ts / vitest.config.ts / playwright.config.ts +├── index.html +├── plugins/ +│ ├── vouch-proxy.ts # dynamic X-Vouch-Target proxy (dev + preview) +│ └── vouch-proxy.test.ts # node-env unit tests +├── src/ +│ ├── main.tsx # React root +│ ├── App.tsx # providers + router + shell +│ ├── styles.css # Tailwind v4 + brand tokens (dark default, light override) +│ ├── lib/ +│ │ ├── types.ts # envelope + artifact TS types (single source of truth) +│ │ ├── rpc.ts # rpc(), fetchHealth(), fetchCapabilities(), error classes +│ │ ├── rpc.test.ts +│ │ ├── citations.ts # parseAnswer() + parseSnippet() +│ │ └── citations.test.ts +│ ├── connection/ +│ │ ├── ConnectionContext.tsx # provider: endpoint/token/caps/health + QueryClient +│ │ ├── ConnectionContext.test.tsx +│ │ ├── ConnectDialog.tsx +│ │ └── ConnectDialog.test.tsx +│ ├── components/ +│ │ ├── Shell.tsx # sidebar + topbar + + connect overlay +│ │ ├── Shell.test.tsx +│ │ ├── Toast.tsx # ToastProvider + useToast +│ │ ├── EmptyState.tsx +│ │ ├── ErrorCard.tsx +│ │ ├── ArtifactDrawer.tsx # claim/page/entity/relation detail (chat + browse reuse) +│ │ └── ArtifactDrawer.test.tsx +│ ├── views/ +│ │ ├── ChatView.tsx +│ │ ├── ChatView.test.tsx +│ │ ├── ReviewView.tsx +│ │ ├── ReviewView.test.tsx +│ │ ├── BrowseView.tsx +│ │ ├── BrowseView.test.tsx +│ │ ├── StatsView.tsx +│ │ └── StatsView.test.tsx +│ └── test/ +│ ├── setup.ts # jest-dom matchers +│ └── utils.tsx # renderWithProviders + connection fixtures +├── e2e/ +│ ├── global-setup.ts # temp KB: init, seed, approve, serve http +│ ├── global-teardown.ts +│ └── smoke.spec.ts +└── README.md +``` + +--- + +### Task 1: Scaffold — Vite + React + TS + Tailwind v4 + Vitest, brand theme + +**Files:** +- Create: `package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`, `index.html`, `.gitignore`, `src/main.tsx`, `src/App.tsx`, `src/styles.css`, `src/test/setup.ts`, `src/App.test.tsx` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: a running dev server + test runner; `src/styles.css` exposes Tailwind utilities `bg-paper`, `bg-paper-2`, `bg-paper-3`, `text-ink`, `text-ink-2`, `text-sepia`, `text-accent`, `bg-accent`, `border-rule`, `text-ok`, `font-mono` used by every later task; `App` renders placeholder text `vouch console`. + +- [ ] **Step 1: Write package.json, configs, entry files** + +`package.json`: + +```json +{ + "name": "vouch-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "e2e": "playwright test" + } +} +``` + +Install dependencies (this fills in versions): + +```bash +npm install react react-dom react-router-dom @tanstack/react-query lucide-react +npm install -D vite @vitejs/plugin-react typescript tailwindcss @tailwindcss/vite vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/react @types/react-dom @types/node @playwright/test +``` + +`tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "plugins", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"] +} +``` + +`vite.config.ts` (proxy plugin joins in Task 2): + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) +``` + +`vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}', 'plugins/**/*.test.ts'], + }, +}) +``` + +`.gitignore`: + +``` +node_modules/ +dist/ +playwright-report/ +test-results/ +e2e/.kb-state.json +``` + +`index.html`: + +```html + + + + + + vouch console + + +
+ + + +``` + +`src/styles.css` — Tailwind v4 CSS-first config. Dark is the default; light overrides the same runtime vars, and `@theme inline` maps them to utilities: + +```css +@import "tailwindcss"; + +:root { + --paper: #0d0a09; + --paper-2: #161110; + --paper-3: #201917; + --ink: #f5efe8; + --ink-2: #c9bfb5; + --sepia: #93887c; + --accent: #ff5a3d; + --accent-2: #ff7c60; + --rule: #2a2320; + --ok: #86c06c; +} + +:root[data-theme="light"] { + --paper: #faf6f0; + --paper-2: #ffffff; + --paper-3: #f0e9e0; + --ink: #1c1614; + --ink-2: #5c534b; + --sepia: #93887c; + --accent: #e04425; + --accent-2: #ff5a3d; + --rule: #e5ddd2; + --ok: #4e8a37; +} + +@theme inline { + --color-paper: var(--paper); + --color-paper-2: var(--paper-2); + --color-paper-3: var(--paper-3); + --color-ink: var(--ink); + --color-ink-2: var(--ink-2); + --color-sepia: var(--sepia); + --color-accent: var(--accent); + --color-accent-2: var(--accent-2); + --color-rule: var(--rule); + --color-ok: var(--ok); + --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +} + +body { + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); +} + +::selection { background: var(--accent); color: var(--paper); } +``` + +`src/main.tsx`: + +```tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './styles.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) +``` + +`src/App.tsx` (placeholder until Task 6): + +```tsx +export default function App() { + return
vouch console
+} +``` + +`src/test/setup.ts` — three things, all load-bearing: jest-dom matchers; **explicit RTL cleanup** (without `globals: true`, RTL's auto-cleanup never registers, and every multi-test component file would fail with duplicate-element errors from test 2 onward); and a **`scrollIntoView` stub** (jsdom doesn't implement it — ChatView's autoscroll effect would throw on every message): + +```ts +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => cleanup()) + +// jsdom has no scrollIntoView; ChatView autoscroll calls it after each message. +window.HTMLElement.prototype.scrollIntoView ??= () => {} +``` + +- [ ] **Step 2: Write the smoke test** + +`src/App.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' +import App from './App' + +test('renders the console shell', () => { + render() + expect(screen.getByText(/vouch console/i)).toBeInTheDocument() +}) +``` + +- [ ] **Step 3: Install and run the test** + +Run: `npm install` (as listed in Step 1) then `npm test` +Expected: `Test Files 1 passed`, `Tests 1 passed` + +- [ ] **Step 4: Verify dev build compiles** + +Run: `npm run build` +Expected: `tsc` silent, `vite build` ends with `✓ built in …` (a `dist/` appears) + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore: scaffold vite+react+ts+tailwind4 with vouch brand tokens" +``` + +--- + +### Task 2: Dynamic proxy plugin (`X-Vouch-Target`) + +**Files:** +- Create: `plugins/vouch-proxy.ts`, `plugins/vouch-proxy.test.ts` +- Modify: `vite.config.ts` (register plugin) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: `vouchProxy(): Plugin` (registered in vite.config) and exported `proxyMiddleware(): (req, res, next) => void`. Contract used by ALL later runtime code: browser fetches `/proxy/rpc`, `/proxy/health`, `/proxy/capabilities` with header `X-Vouch-Target: http://host:port`; the middleware forwards method/body/`Authorization` to `/` and streams status + body back. Missing/invalid target → 400 JSON `{"ok":false,"error":{"code":"bad_target","message":…}}`. Upstream connection failure → 502 JSON `{"ok":false,"error":{"code":"proxy_error","message":…}}`. + +- [ ] **Step 1: Write the failing tests** + +`plugins/vouch-proxy.test.ts` (note the node environment docblock — these tests spin up real HTTP servers): + +```ts +// @vitest-environment node +import http from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, expect, test } from 'vitest' +import { proxyMiddleware } from './vouch-proxy' + +let upstream: http.Server +let upstreamUrl: string +let proxy: http.Server +let proxyUrl: string + +beforeAll(async () => { + // Upstream echoes method, path, auth header, and body back as JSON. + upstream = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + method: req.method, + path: req.url, + auth: req.headers.authorization ?? null, + body, + }), + ) + }) + }) + await new Promise((r) => upstream.listen(0, '127.0.0.1', r)) + upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}` + + const mw = proxyMiddleware() + proxy = http.createServer((req, res) => { + mw(req, res, () => { + res.statusCode = 404 + res.end('not proxied') + }) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', r)) + proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` +}) + +afterAll(() => { + upstream.close() + proxy.close() +}) + +test('forwards POST body and Authorization to the target, rewriting /proxy prefix', async () => { + const res = await fetch(`${proxyUrl}/proxy/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-vouch-target': upstreamUrl, + authorization: 'Bearer sekrit', + }, + body: JSON.stringify({ id: '1', method: 'kb.status', params: {} }), + }) + expect(res.status).toBe(200) + const echoed = await res.json() + expect(echoed.method).toBe('POST') + expect(echoed.path).toBe('/rpc') + expect(echoed.auth).toBe('Bearer sekrit') + expect(JSON.parse(echoed.body).method).toBe('kb.status') +}) + +test('forwards GET /proxy/health to target /health', async () => { + const res = await fetch(`${proxyUrl}/proxy/health`, { + headers: { 'x-vouch-target': upstreamUrl }, + }) + const echoed = await res.json() + expect(echoed.method).toBe('GET') + expect(echoed.path).toBe('/health') +}) + +test('rejects a missing X-Vouch-Target with 400', async () => { + const res = await fetch(`${proxyUrl}/proxy/rpc`, { method: 'POST', body: '{}' }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('bad_target') +}) + +test('rejects a non-http(s) target with 400', async () => { + const res = await fetch(`${proxyUrl}/proxy/rpc`, { + method: 'POST', + headers: { 'x-vouch-target': 'file:///etc/passwd' }, + body: '{}', + }) + expect(res.status).toBe(400) +}) + +test('returns 502 when the target is unreachable', async () => { + const res = await fetch(`${proxyUrl}/proxy/rpc`, { + method: 'POST', + headers: { 'x-vouch-target': 'http://127.0.0.1:1' }, + body: '{}', + }) + expect(res.status).toBe(502) + const body = await res.json() + expect(body.error.code).toBe('proxy_error') +}) + +test('ignores non-/proxy paths (calls next)', async () => { + const res = await fetch(`${proxyUrl}/other`) + expect(res.status).toBe(404) + expect(await res.text()).toBe('not proxied') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run plugins/vouch-proxy.test.ts` +Expected: FAIL — `Cannot find module './vouch-proxy'` (or export missing) + +- [ ] **Step 3: Implement the plugin** + +`plugins/vouch-proxy.ts`: + +```ts +import http from 'node:http' +import https from 'node:https' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { Plugin } from 'vite' + +type NextFn = (err?: unknown) => void + +function fail(res: ServerResponse, status: number, code: string, message: string): void { + res.statusCode = status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ ok: false, error: { code, message } })) +} + +/** + * Same-origin bridge to a vouch HTTP endpoint. The browser cannot call + * vouch cross-origin (vouch sends no CORS headers, deliberately), so the UI + * sends every request to /proxy/* on its own origin with the real endpoint + * in X-Vouch-Target. Third-party pages cannot drive this: a custom request + * header forces a CORS preflight, which this middleware never answers. + */ +export function proxyMiddleware(): (req: IncomingMessage, res: ServerResponse, next: NextFn) => void { + return (req, res, next) => { + if (!req.url || !(req.url === '/proxy' || req.url.startsWith('/proxy/'))) return next() + + const raw = req.headers['x-vouch-target'] + if (typeof raw !== 'string' || raw.length === 0) { + return fail(res, 400, 'bad_target', 'missing X-Vouch-Target header') + } + let target: URL + try { + target = new URL(raw) + } catch { + return fail(res, 400, 'bad_target', `not a valid URL: ${raw}`) + } + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return fail(res, 400, 'bad_target', `unsupported protocol: ${target.protocol}`) + } + + const path = req.url.slice('/proxy'.length) || '/' + const mod = target.protocol === 'https:' ? https : http + const headers: Record = {} + if (req.headers['content-type']) headers['content-type'] = String(req.headers['content-type']) + if (req.headers.authorization) headers.authorization = String(req.headers.authorization) + + const upstream = mod.request( + { + hostname: target.hostname, + port: target.port || (target.protocol === 'https:' ? 443 : 80), + path, + method: req.method, + headers, + }, + (ures) => { + res.statusCode = ures.statusCode ?? 502 + res.setHeader('content-type', ures.headers['content-type'] ?? 'application/json') + ures.pipe(res) + }, + ) + upstream.on('error', (err) => fail(res, 502, 'proxy_error', err.message)) + req.pipe(upstream) + } +} + +export function vouchProxy(): Plugin { + return { + name: 'vouch-proxy', + configureServer(server) { + server.middlewares.use(proxyMiddleware()) + }, + configurePreviewServer(server) { + server.middlewares.use(proxyMiddleware()) + }, + } +} +``` + +Update `vite.config.ts`: + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { vouchProxy } from './plugins/vouch-proxy' + +export default defineConfig({ + plugins: [react(), tailwindcss(), vouchProxy()], +}) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run plugins/vouch-proxy.test.ts` +Expected: 6 passed + +- [ ] **Step 5: Commit** + +```bash +git add plugins/ vite.config.ts +git commit -m "feat: dynamic X-Vouch-Target proxy plugin (dev+preview)" +``` + +--- + +### Task 3: Types + RPC client + +**Files:** +- Create: `src/lib/types.ts`, `src/lib/rpc.ts`, `src/lib/rpc.test.ts` + +**Interfaces:** +- Consumes: the `/proxy/*` contract from Task 2. +- Produces (used by every later task): + - `types.ts`: `VouchConnectionInfo {endpoint: string; token?: string}`, `Envelope`, `SynthesizeResult`, `SearchResult`, `SearchHit`, `Claim`, `Page`, `Entity`, `Relation`, `Proposal`, `KbStatus`, `KbStats`, `Capabilities`, `WhyResult`, `WhyEdge`, `Citation` (loose dict). + - `rpc.ts`: `class VouchRpcError extends Error {code: string}`, `class VouchHttpError extends Error {status: number}`, `rpc(conn, method, params?): Promise`, `fetchHealth(conn): Promise`, `fetchCapabilities(conn): Promise`. + +- [ ] **Step 1: Write `src/lib/types.ts`** (types only — no test cycle of its own) + +```ts +export interface VouchConnectionInfo { + endpoint: string + token?: string +} + +export interface Envelope { + id: string | null + ok: boolean + result?: T + error?: { code: string; message: string } +} + +export type Confidence = 'low' | 'medium' | 'high' + +export interface SynthesizeResult { + query: string + answer: string + claims: string[] + gaps: string[] + _meta?: { synthesis_confidence?: Confidence } +} + +export interface SearchHit { + kind: string + id: string + snippet: string + score: number + backend: string +} + +export interface SearchResult { + backend: string + viewer?: { project: string | null; agent: string | null } + hits: SearchHit[] +} + +export interface Claim { + id: string + text: string + type: string + status: string + confidence: number + created_at?: string + [k: string]: unknown +} + +export interface Page { + id: string + title: string + body: string + type: string + status: string + created_at?: string + [k: string]: unknown +} + +export interface Entity { + id: string + name: string + type: string + created_at?: string + [k: string]: unknown +} + +export interface Relation { + id: string + source: string + relation: string + target: string + confidence: number + created_at?: string + [k: string]: unknown +} + +export interface Proposal { + id: string + kind: string + proposed_by: string + session_id: string | null + payload: Record + status: string + proposed_at?: string + rationale?: string | null + decided_at?: string | null + decided_by?: string | null + decision_reason?: string | null + [k: string]: unknown +} + +export interface KbStatus { + kb_dir: string + claims: number + pages: number + sources: number + entities: number + relations: number + evidence: number + sessions: number + pending_proposals: number + audit_events: number + index_present: boolean +} + +export interface KbStats { + kb_dir?: string + generated_at: string + counts: KbStatus + pending: { + total: number + by_agent: Record + age_days: { median: number | null; max: number | null; oldest_id: string | null } + } + review: { + window_days: number | null + decided_in_window: number + approved: number + rejected: number + expired: number + approval_rate: number | null + audit_totals?: { approved: number; rejected: number; expired: number } + by_agent: Record + } + citations: { + claims_total: number + claims_loadable?: number + claims_with_valid_citation: number + broken_citation: number + invalid_claim: number + coverage_rate: number | null + } +} + +export interface Capabilities { + name: string + level: number + methods: string[] + review_gated: boolean + [k: string]: unknown +} + +export interface WhyEdge { + kind: string + target: string + target_kind: string + event_ts: string | null + session_id: string | null + cycle: boolean + children: WhyEdge[] +} + +export interface WhyResult { + schema_version?: number + root: string + node_kind: string + depth: number + provenance: WhyEdge[] +} + +/** kb.cite returns heterogeneous evidence/source dicts — render defensively. */ +export type Citation = Record +``` + +- [ ] **Step 2: Write the failing rpc tests** + +`src/lib/rpc.test.ts`: + +```ts +import { afterEach, expect, test, vi } from 'vitest' +import { fetchCapabilities, fetchHealth, rpc, VouchHttpError, VouchRpcError } from './rpc' +import type { VouchConnectionInfo } from './types' + +const conn: VouchConnectionInfo = { endpoint: 'http://127.0.0.1:8731', token: 'tok' } + +function mockFetch(status: number, body: unknown) { + const fn = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }), + ) + vi.stubGlobal('fetch', fn) + return fn +} + +afterEach(() => vi.unstubAllGlobals()) + +test('rpc posts the envelope to /proxy/rpc with target + bearer headers and unwraps result', async () => { + const fn = mockFetch(200, { id: 'ui-1', ok: true, result: { claims: 2 } }) + const out = await rpc<{ claims: number }>(conn, 'kb.status') + expect(out.claims).toBe(2) + const [url, init] = fn.mock.calls[0] + expect(url).toBe('/proxy/rpc') + expect(init.method).toBe('POST') + expect(init.headers['x-vouch-target']).toBe('http://127.0.0.1:8731') + expect(init.headers.authorization).toBe('Bearer tok') + const sent = JSON.parse(init.body) + expect(sent.method).toBe('kb.status') + expect(sent.params).toEqual({}) + expect(sent.id).toMatch(/^ui-\d+$/) +}) + +test('rpc omits authorization header when no token', async () => { + const fn = mockFetch(200, { id: 'x', ok: true, result: {} }) + await rpc({ endpoint: 'http://127.0.0.1:8731' }, 'kb.status') + const [, init] = fn.mock.calls[0] + expect(init.headers.authorization).toBeUndefined() +}) + +test('rpc throws VouchRpcError on ok:false envelopes', async () => { + mockFetch(200, { id: 'x', ok: false, error: { code: 'method_not_found', message: 'unknown method: bogus' } }) + const err = await rpc(conn, 'bogus').catch((e) => e) + expect(err).toBeInstanceOf(VouchRpcError) + expect(err.code).toBe('method_not_found') + expect(err.message).toBe('unknown method: bogus') +}) + +test('rpc throws VouchHttpError(401) on unauthorized', async () => { + mockFetch(401, { detail: 'unauthorized' }) + const err = await rpc(conn, 'kb.status').catch((e) => e) + expect(err).toBeInstanceOf(VouchHttpError) + expect(err.status).toBe(401) +}) + +test('fetchHealth returns true only for {ok:true}', async () => { + mockFetch(200, { ok: true }) + expect(await fetchHealth(conn)).toBe(true) + mockFetch(200, { ok: false }) + expect(await fetchHealth(conn)).toBe(false) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))) + expect(await fetchHealth(conn)).toBe(false) +}) + +test('fetchCapabilities returns the descriptor', async () => { + mockFetch(200, { name: 'vouch', level: 3, methods: ['kb.status'], review_gated: true }) + const caps = await fetchCapabilities(conn) + expect(caps.methods).toContain('kb.status') +}) +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run src/lib/rpc.test.ts` +Expected: FAIL — `Cannot find module './rpc'` + +- [ ] **Step 4: Implement `src/lib/rpc.ts`** + +```ts +import type { Capabilities, Envelope, VouchConnectionInfo } from './types' + +export class VouchRpcError extends Error { + constructor( + public code: string, + message: string, + ) { + super(message) + this.name = 'VouchRpcError' + } +} + +export class VouchHttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message) + this.name = 'VouchHttpError' + } +} + +let seq = 0 + +function baseHeaders(conn: VouchConnectionInfo): Record { + const h: Record = { 'x-vouch-target': conn.endpoint } + if (conn.token) h.authorization = `Bearer ${conn.token}` + return h +} + +export async function rpc( + conn: VouchConnectionInfo, + method: string, + params: Record = {}, +): Promise { + const res = await fetch('/proxy/rpc', { + method: 'POST', + headers: { ...baseHeaders(conn), 'content-type': 'application/json' }, + body: JSON.stringify({ id: `ui-${++seq}`, method, params }), + }) + if (res.status === 401) throw new VouchHttpError(401, 'unauthorized — check the bearer token') + if (!res.ok) throw new VouchHttpError(res.status, `endpoint returned HTTP ${res.status}`) + const body = (await res.json()) as Envelope + if (!body.ok || body.error) { + throw new VouchRpcError(body.error?.code ?? 'unknown', body.error?.message ?? 'unknown error') + } + return body.result as T +} + +export async function fetchHealth(conn: VouchConnectionInfo): Promise { + try { + const res = await fetch('/proxy/health', { headers: baseHeaders(conn) }) + if (!res.ok) return false + const body = (await res.json()) as { ok?: boolean } + return body.ok === true + } catch { + return false + } +} + +export async function fetchCapabilities(conn: VouchConnectionInfo): Promise { + const res = await fetch('/proxy/capabilities', { headers: baseHeaders(conn) }) + if (res.status === 401) throw new VouchHttpError(401, 'unauthorized — check the bearer token') + if (!res.ok) throw new VouchHttpError(res.status, `capabilities returned HTTP ${res.status}`) + return (await res.json()) as Capabilities +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/lib/rpc.test.ts` +Expected: 6 passed + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/types.ts src/lib/rpc.ts src/lib/rpc.test.ts +git commit -m "feat: typed vouch envelope rpc client + health/capabilities fetchers" +``` + +--- + +### Task 4: Citation + snippet parsers + +**Files:** +- Create: `src/lib/citations.ts`, `src/lib/citations.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (used by ChatView and search rendering): + - `type AnswerSegment = {kind:'text'; text:string} | {kind:'citation'; claimId:string}` + - `parseAnswer(answer: string): AnswerSegment[]` — splits synthesize prose on `[claim-id]` tokens. + - `type SnippetSegment = {kind:'plain'|'match'; text:string}` + - `parseSnippet(snippet: string): SnippetSegment[]` — splits search snippets on `«match»` guillemets. + +- [ ] **Step 1: Write the failing tests** + +`src/lib/citations.test.ts`: + +```ts +import { expect, test } from 'vitest' +import { parseAnswer, parseSnippet } from './citations' + +test('parses a real synthesize answer into text + citation segments', () => { + const answer = + 'The vouch HTTP server binds 127.0.0.1:8731 by default [the-vouch-http-server-binds-127-0-0-1-8731-by-default]. Vouch stores reviewed knowledge [vouch-starter-reviewed-knowledge].' + const segs = parseAnswer(answer) + expect(segs).toEqual([ + { kind: 'text', text: 'The vouch HTTP server binds 127.0.0.1:8731 by default ' }, + { kind: 'citation', claimId: 'the-vouch-http-server-binds-127-0-0-1-8731-by-default' }, + { kind: 'text', text: '. Vouch stores reviewed knowledge ' }, + { kind: 'citation', claimId: 'vouch-starter-reviewed-knowledge' }, + { kind: 'text', text: '.' }, + ]) +}) + +test('answer with no citations is a single text segment', () => { + expect(parseAnswer('nothing cited here')).toEqual([{ kind: 'text', text: 'nothing cited here' }]) +}) + +test('adjacent citations produce no empty text segments', () => { + const segs = parseAnswer('[claim-a][claim-b]') + expect(segs).toEqual([ + { kind: 'citation', claimId: 'claim-a' }, + { kind: 'citation', claimId: 'claim-b' }, + ]) +}) + +test('bracketed text that is not a slug stays text', () => { + // uppercase / spaces / underscores are not claim slugs + expect(parseAnswer('see [NOT A SLUG] ok')).toEqual([{ kind: 'text', text: 'see [NOT A SLUG] ok' }]) +}) + +test('empty answer parses to empty list', () => { + expect(parseAnswer('')).toEqual([]) +}) + +test('parseSnippet splits guillemet highlights', () => { + expect(parseSnippet('The vouch «HTTP» «server» binds')).toEqual([ + { kind: 'plain', text: 'The vouch ' }, + { kind: 'match', text: 'HTTP' }, + { kind: 'plain', text: ' ' }, + { kind: 'match', text: 'server' }, + { kind: 'plain', text: ' binds' }, + ]) +}) + +test('parseSnippet without highlights returns one plain segment', () => { + expect(parseSnippet('plain text')).toEqual([{ kind: 'plain', text: 'plain text' }]) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/lib/citations.test.ts` +Expected: FAIL — `Cannot find module './citations'` + +- [ ] **Step 3: Implement `src/lib/citations.ts`** + +```ts +export type AnswerSegment = { kind: 'text'; text: string } | { kind: 'citation'; claimId: string } + +/** Claim ids are kebab slugs: lowercase alphanumerics and dashes, len >= 3. */ +const CITATION = /\[([a-z0-9][a-z0-9-]{1,}[a-z0-9])\]/g + +export function parseAnswer(answer: string): AnswerSegment[] { + const segments: AnswerSegment[] = [] + let last = 0 + for (const m of answer.matchAll(CITATION)) { + const at = m.index ?? 0 + if (at > last) segments.push({ kind: 'text', text: answer.slice(last, at) }) + segments.push({ kind: 'citation', claimId: m[1] }) + last = at + m[0].length + } + if (last < answer.length) segments.push({ kind: 'text', text: answer.slice(last) }) + return segments +} + +export type SnippetSegment = { kind: 'plain' | 'match'; text: string } + +const HIGHLIGHT = /«([^»]*)»/g + +export function parseSnippet(snippet: string): SnippetSegment[] { + const segments: SnippetSegment[] = [] + let last = 0 + for (const m of snippet.matchAll(HIGHLIGHT)) { + const at = m.index ?? 0 + if (at > last) segments.push({ kind: 'plain', text: snippet.slice(last, at) }) + segments.push({ kind: 'match', text: m[1] }) + last = at + m[0].length + } + if (last < snippet.length) segments.push({ kind: 'plain', text: snippet.slice(last) }) + return segments +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/lib/citations.test.ts` +Expected: 7 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/citations.ts src/lib/citations.test.ts +git commit -m "feat: citation + snippet parsers for synthesize answers and search hits" +``` + +--- + +### Task 5: Connection context + connect dialog + test utils + +**Files:** +- Create: `src/connection/ConnectionContext.tsx`, `src/connection/ConnectionContext.test.tsx`, `src/connection/ConnectDialog.tsx`, `src/connection/ConnectDialog.test.tsx`, `src/test/utils.tsx` + +**Interfaces:** +- Consumes: `rpc`, `fetchHealth`, `fetchCapabilities`, `VouchHttpError` (Task 3). +- Produces (used by Shell and all views): + - `ConnectionProvider` — owns the TanStack `QueryClient` (401s anywhere flip `needsAuth`), renders `QueryClientProvider`. + - `useConnection(): { conn: VouchConnectionInfo | null; caps: Capabilities | null; health: 'connecting'|'ok'|'down'; needsAuth: boolean; connect(info): Promise; disconnect(): void; hasMethod(m: string): boolean; reportError(err: unknown): void }` — `reportError` lets code that calls `rpc()` outside TanStack Query (ChatView's submit) feed 401s into the same needsAuth gate. + - `ConnectDialog` — modal; rendered by Shell when `conn === null` or `needsAuth`. + - `STORAGE_KEY = 'vouch-ui.connection.v1'` (exported for tests/e2e). + - `src/test/utils.tsx`: `seedConnection(endpoint?)` writes localStorage; `renderWithProviders(ui, {route?})` wraps in router + connection providers. Task 5's version deliberately has **no Toast import** (`src/components/Toast.tsx` doesn't exist until Task 6); Task 6 rewrites the file to add `ToastProvider` — both full file versions are shown in their tasks. + +- [ ] **Step 1: Write the failing context tests** + +`src/connection/ConnectionContext.test.tsx`: + +```tsx +import { act, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { ConnectionProvider, STORAGE_KEY, useConnection } from './ConnectionContext' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { + ...actual, + fetchHealth: vi.fn(), + fetchCapabilities: vi.fn(), + } +}) +import { fetchCapabilities, fetchHealth } from '../lib/rpc' + +const CAPS = { name: 'vouch', level: 3, methods: ['kb.status', 'kb.approve'], review_gated: true } + +function Probe() { + const c = useConnection() + return ( +
+ {c.conn?.endpoint ?? 'none'} + {c.health} + {String(c.hasMethod('kb.approve'))} + + +
+ ) +} + +beforeEach(() => localStorage.clear()) +afterEach(() => vi.clearAllMocks()) + +test('starts disconnected with empty storage', () => { + render( + + + , + ) + expect(screen.getByTestId('endpoint')).toHaveTextContent('none') +}) + +test('connect() validates, persists, and exposes capabilities', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + render( + + + , + ) + await act(() => screen.getByText('go').click()) + await waitFor(() => expect(screen.getByTestId('endpoint')).toHaveTextContent('http://127.0.0.1:9999')) + expect(screen.getByTestId('health')).toHaveTextContent('ok') + expect(screen.getByTestId('can-approve')).toHaveTextContent('true') + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).endpoint).toBe('http://127.0.0.1:9999') +}) + +test('connect() rejects when health check fails and stays disconnected', async () => { + vi.mocked(fetchHealth).mockResolvedValue(false) + let caught: unknown + function Trier() { + const c = useConnection() + return ( + + ) + } + render( + + + + , + ) + await act(() => screen.getByText('try').click()) + await waitFor(() => expect(caught).toBeInstanceOf(Error)) + expect(screen.getByTestId('endpoint')).toHaveTextContent('none') + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() +}) + +test('restores a stored connection on mount and validates it', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + localStorage.setItem(STORAGE_KEY, JSON.stringify({ endpoint: 'http://127.0.0.1:8731' })) + render( + + + , + ) + expect(screen.getByTestId('endpoint')).toHaveTextContent('http://127.0.0.1:8731') + await waitFor(() => expect(screen.getByTestId('health')).toHaveTextContent('ok')) +}) + +test('disconnect clears state and storage', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + localStorage.setItem(STORAGE_KEY, JSON.stringify({ endpoint: 'http://127.0.0.1:8731' })) + render( + + + , + ) + await act(() => screen.getByText('bye').click()) + expect(screen.getByTestId('endpoint')).toHaveTextContent('none') + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/connection/ConnectionContext.test.tsx` +Expected: FAIL — `Cannot find module './ConnectionContext'` + +- [ ] **Step 3: Implement `src/connection/ConnectionContext.tsx`** + +```tsx +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { fetchCapabilities, fetchHealth, VouchHttpError } from '../lib/rpc' +import type { Capabilities, VouchConnectionInfo } from '../lib/types' + +export const STORAGE_KEY = 'vouch-ui.connection.v1' +const HEALTH_POLL_MS = 15_000 + +export type HealthState = 'connecting' | 'ok' | 'down' + +interface ConnectionValue { + conn: VouchConnectionInfo | null + caps: Capabilities | null + health: HealthState + needsAuth: boolean + connect: (info: VouchConnectionInfo) => Promise + disconnect: () => void + hasMethod: (method: string) => boolean + /** Feed errors from rpc() calls made outside TanStack Query into the 401 gate. */ + reportError: (err: unknown) => void +} + +const Ctx = createContext(null) + +export function useConnection(): ConnectionValue { + const v = useContext(Ctx) + if (!v) throw new Error('useConnection outside ConnectionProvider') + return v +} + +function loadStored(): VouchConnectionInfo | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as VouchConnectionInfo + return typeof parsed.endpoint === 'string' ? parsed : null + } catch { + return null + } +} + +export function ConnectionProvider({ children }: { children: ReactNode }) { + const [conn, setConn] = useState(loadStored) + const [caps, setCaps] = useState(null) + const [health, setHealth] = useState(conn ? 'connecting' : 'down') + const [needsAuth, setNeedsAuth] = useState(false) + + // One QueryClient for the app; any 401 anywhere re-opens the connect dialog. + const flagAuth = useCallback((err: unknown) => { + if (err instanceof VouchHttpError && err.status === 401) setNeedsAuth(true) + }, []) + const clientRef = useRef(null) + if (!clientRef.current) { + clientRef.current = new QueryClient({ + queryCache: new QueryCache({ onError: flagAuth }), + mutationCache: new MutationCache({ onError: flagAuth }), + // retryDelay 0: this is a loopback tool — an immediate single retry is + // fine, and it keeps failing-query tests inside RTL's 1s findBy timeout. + defaultOptions: { queries: { retry: 1, retryDelay: 0, staleTime: 5_000 } }, + }) + } + + const validate = useCallback(async (info: VouchConnectionInfo) => { + const healthy = await fetchHealth(info) + if (!healthy) throw new Error(`no healthy vouch endpoint at ${info.endpoint}`) + return fetchCapabilities(info) + }, []) + + const connect = useCallback( + async (info: VouchConnectionInfo) => { + const c = await validate(info) // throws → caller (dialog) shows the error + localStorage.setItem(STORAGE_KEY, JSON.stringify(info)) + setConn(info) + setCaps(c) + setHealth('ok') + setNeedsAuth(false) + clientRef.current?.clear() + }, + [validate], + ) + + const disconnect = useCallback(() => { + localStorage.removeItem(STORAGE_KEY) + setConn(null) + setCaps(null) + setHealth('down') + setNeedsAuth(false) + clientRef.current?.clear() + }, []) + + // Validate a restored connection once, then poll health. + useEffect(() => { + if (!conn) return + let stop = false + validate(conn) + .then((c) => { + if (stop) return + setCaps(c) + setHealth('ok') + }) + .catch((err) => { + if (stop) return + setHealth('down') + flagAuth(err) + }) + const timer = setInterval(() => { + fetchHealth(conn).then((ok) => { + if (!stop) setHealth(ok ? 'ok' : 'down') + }) + }, HEALTH_POLL_MS) + return () => { + stop = true + clearInterval(timer) + } + }, [conn, validate, flagAuth]) + + const value = useMemo( + () => ({ + conn, + caps, + health, + needsAuth, + connect, + disconnect, + hasMethod: (m) => caps?.methods.includes(m) ?? false, + reportError: flagAuth, + }), + [conn, caps, health, needsAuth, connect, disconnect, flagAuth], + ) + + return ( + + {children} + + ) +} +``` + +- [ ] **Step 4: Run context tests** + +Run: `npx vitest run src/connection/ConnectionContext.test.tsx` +Expected: 5 passed + +- [ ] **Step 5: Write the failing dialog test** + +`src/connection/ConnectDialog.test.tsx`: + +```tsx +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' +import { ConnectionProvider } from './ConnectionContext' +import { ConnectDialog } from './ConnectDialog' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth } from '../lib/rpc' + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() +}) + +test('renders with the default endpoint and connects on submit', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue({ name: 'vouch', level: 3, methods: [], review_gated: true }) + render( + + + , + ) + const endpoint = screen.getByLabelText(/endpoint/i) + expect(endpoint).toHaveValue('http://127.0.0.1:8731') + await userEvent.click(screen.getByRole('button', { name: /connect/i })) + await waitFor(() => expect(fetchCapabilities).toHaveBeenCalled()) +}) + +test('shows the error when the endpoint is unreachable', async () => { + vi.mocked(fetchHealth).mockResolvedValue(false) + render( + + + , + ) + await userEvent.click(screen.getByRole('button', { name: /connect/i })) + expect(await screen.findByText(/no healthy vouch endpoint/i)).toBeInTheDocument() +}) + +test('sends the token when provided', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue({ name: 'vouch', level: 3, methods: [], review_gated: true }) + render( + + + , + ) + await userEvent.type(screen.getByLabelText(/token/i), 'sekrit') + await userEvent.click(screen.getByRole('button', { name: /connect/i })) + await waitFor(() => + expect(fetchHealth).toHaveBeenCalledWith(expect.objectContaining({ token: 'sekrit' })), + ) +}) +``` + +- [ ] **Step 6: Run to verify failure** + +Run: `npx vitest run src/connection/ConnectDialog.test.tsx` +Expected: FAIL — `Cannot find module './ConnectDialog'` + +- [ ] **Step 7: Implement `src/connection/ConnectDialog.tsx`** + +```tsx +import { useState } from 'react' +import type { FormEvent } from 'react' +import { useConnection } from './ConnectionContext' + +export function ConnectDialog() { + const { conn, connect, needsAuth } = useConnection() + const [endpoint, setEndpoint] = useState(conn?.endpoint ?? 'http://127.0.0.1:8731') + const [token, setToken] = useState(conn?.token ?? '') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + await connect({ endpoint: endpoint.trim().replace(/\/+$/, ''), token: token || undefined }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( +
+
+
VOUCH
+

Connect to your knowledge base

+

+ {needsAuth + ? 'The endpoint rejected the credential — enter a valid bearer token.' + : 'Point the console at a running `vouch serve --transport http` endpoint.'} +

+ + + setEndpoint(e.target.value)} + placeholder="http://127.0.0.1:8731" + className="mb-4 w-full rounded-lg border border-rule bg-paper px-3 py-2 font-mono text-sm text-ink outline-none focus:border-accent" + /> + + + setToken(e.target.value)} + className="mb-4 w-full rounded-lg border border-rule bg-paper px-3 py-2 font-mono text-sm text-ink outline-none focus:border-accent" + /> + + {error && ( +

+ {error} +

+ )} + + +
+
+ ) +} +``` + +- [ ] **Step 8: Write `src/test/utils.tsx`** (helpers for view tests in later tasks) + +```tsx +import { render } from '@testing-library/react' +import type { RenderResult } from '@testing-library/react' +import type { ReactElement } from 'react' +import { MemoryRouter } from 'react-router-dom' +import { ConnectionProvider, STORAGE_KEY } from '../connection/ConnectionContext' +import type { VouchConnectionInfo } from '../lib/types' + +export const TEST_ENDPOINT = 'http://127.0.0.1:8731' + +export function seedConnection(info: Partial = {}): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ endpoint: TEST_ENDPOINT, ...info })) +} + +/** + * Wraps a component in router + connection providers. The calling test file + * must vi.mock('../lib/rpc') (fetchHealth → true, fetchCapabilities → caps + * with the methods the view needs) BEFORE importing this helper's render. + */ +export function renderWithProviders(ui: ReactElement, { route = '/' } = {}): RenderResult { + return render( + + {ui} + , + ) +} +``` + +(Task 6 adds `ToastProvider` around `ConnectionProvider` in this same helper.) + +- [ ] **Step 9: Run the full suite** + +Run: `npm test` +Expected: all files pass (App, proxy, rpc, citations, ConnectionContext, ConnectDialog) + +- [ ] **Step 10: Commit** + +```bash +git add src/connection src/test/utils.tsx +git commit -m "feat: connection provider (validate/persist/poll, 401 gate) + connect dialog" +``` + +--- + +### Task 6: App shell — sidebar, top bar, toasts, theme, routes + +**Files:** +- Create: `src/components/Toast.tsx`, `src/components/Shell.tsx`, `src/components/Shell.test.tsx`, `src/components/EmptyState.tsx`, `src/components/ErrorCard.tsx`, `src/views/ChatView.tsx` (stub), `src/views/ReviewView.tsx` (stub), `src/views/BrowseView.tsx` (stub), `src/views/StatsView.tsx` (stub) +- Modify: `src/App.tsx`, `src/App.test.tsx`, `src/test/utils.tsx` + +**Interfaces:** +- Consumes: `useConnection`, `ConnectDialog` (Task 5); Tailwind tokens (Task 1). +- Produces: + - `ToastProvider`, `useToast(): { toast(kind: 'info'|'success'|'error', text: string): void }`, and `useErrorToast(isError: boolean, error: unknown)` — a one-line hook views call to satisfy the spec's "inline **plus** a transient toast, every error shows code: message" rule. + - `Shell` — layout route element rendering sidebar + topbar + ``; overlays `ConnectDialog` when `conn === null || needsAuth`. + - `EmptyState({title, hint}: {title: string; hint?: string})` — centered muted block. + - `ErrorCard({code, message}: {code?: string; message: string})` — inline error block; views use it for envelope errors. + - View stubs each render an `

` with their name; Tasks 7–12 replace file contents entirely. + - Final `App.tsx` route tree (stable from here on). + +- [ ] **Step 0: Load the `frontend-design` skill** (required by the spec §3) before writing any Shell/view markup in this task and Tasks 7–12 — the brand tokens are fixed (Task 1), but layout, spacing, and hierarchy decisions should follow that skill's guidance so the result reads as intentional design rather than a component-library default. + +- [ ] **Step 1: Write the failing shell test** + +`src/components/Shell.test.tsx`: + +```tsx +import { screen, waitFor } from '@testing-library/react' +import { Route, Routes } from 'react-router-dom' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn().mockResolvedValue([]), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { Shell } from './Shell' + +const CAPS = { name: 'vouch', level: 3, methods: ['kb.list_pending'], review_gated: true } + +function app() { + return ( + + }> + home content} /> + + + ) +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() +}) + +test('shows the connect dialog when disconnected', () => { + renderWithProviders(app()) + expect(screen.getByText(/connect to your knowledge base/i)).toBeInTheDocument() +}) + +test('shows nav, endpoint pill, and outlet content when connected', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + seedConnection() + renderWithProviders(app()) + expect(screen.getByRole('link', { name: /chat/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /review/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /browse/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /stats/i })).toBeInTheDocument() + expect(screen.getByText('home content')).toBeInTheDocument() + await waitFor(() => expect(screen.getByText('127.0.0.1:8731')).toBeInTheDocument()) + expect(screen.queryByText(/connect to your knowledge base/i)).not.toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/components/Shell.test.tsx` +Expected: FAIL — `Cannot find module './Shell'` + +- [ ] **Step 3: Implement Toast, EmptyState, ErrorCard, Shell, stubs, App** + +`src/components/Toast.tsx`: + +```tsx +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' + +type Kind = 'info' | 'success' | 'error' +interface ToastItem { + id: number + kind: Kind + text: string +} + +const ToastCtx = createContext<{ toast: (kind: Kind, text: string) => void } | null>(null) + +export function useToast() { + const v = useContext(ToastCtx) + if (!v) throw new Error('useToast outside ToastProvider') + return v +} + +/** Fire an error toast (code: message) whenever a query flips to error. */ +export function useErrorToast(isError: boolean, error: unknown) { + const { toast } = useToast() + useEffect(() => { + if (!isError) return + const code = (error as { code?: string } | null)?.code + const msg = error instanceof Error ? error.message : String(error) + toast('error', code ? `${code}: ${msg}` : msg) + }, [isError, error, toast]) +} + +const KIND_CLASS: Record = { + info: 'border-rule text-ink-2', + success: 'border-ok/50 text-ok', + error: 'border-accent/50 text-accent-2', +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]) + const nextId = useRef(1) + + const toast = useCallback((kind: Kind, text: string) => { + const id = nextId.current++ + setItems((prev) => [...prev, { id, kind, text }]) + setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 5000) + }, []) + + return ( + + {children} +
+ {items.map((t) => ( +
+ {t.text} +
+ ))} +
+
+ ) +} +``` + +`src/components/EmptyState.tsx`: + +```tsx +export function EmptyState({ title, hint }: { title: string; hint?: string }) { + return ( +
+

{title}

+ {hint &&

{hint}

} +
+ ) +} +``` + +`src/components/ErrorCard.tsx`: + +```tsx +export function ErrorCard({ code, message }: { code?: string; message: string }) { + return ( +
+ {code && {code}} + {message} +
+ ) +} +``` + +`src/components/Shell.tsx`: + +```tsx +import { useQuery } from '@tanstack/react-query' +import { Activity, Inbox, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' +import { useEffect, useState } from 'react' +import { NavLink, Outlet, useLocation } from 'react-router-dom' +import { ConnectDialog } from '../connection/ConnectDialog' +import { useConnection } from '../connection/ConnectionContext' +import { rpc } from '../lib/rpc' +import type { Proposal } from '../lib/types' + +const THEME_KEY = 'vouch-ui.theme' + +const NAV = [ + { to: '/chat', label: 'Chat', icon: MessageSquare }, + { to: '/review', label: 'Review', icon: Inbox }, + { to: '/browse', label: 'Browse', icon: Library }, + { to: '/stats', label: 'Stats', icon: Activity }, +] + +const TITLES: Record = { + '/chat': 'Chat', + '/review': 'Review queue', + '/browse': 'Knowledge', + '/stats': 'Stats & health', +} + +function endpointHost(endpoint: string): string { + try { + const u = new URL(endpoint) + return u.host + } catch { + return endpoint + } +} + +export function Shell() { + const { conn, connect, disconnect, health, needsAuth, hasMethod } = useConnection() + const location = useLocation() + const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) ?? 'dark') + + useEffect(() => { + document.documentElement.dataset.theme = theme + localStorage.setItem(THEME_KEY, theme) + }, [theme]) + + const pending = useQuery({ + queryKey: ['pending'], + queryFn: () => rpc(conn!, 'kb.list_pending'), + enabled: !!conn && hasMethod('kb.list_pending'), + refetchInterval: 10_000, + }) + const pendingCount = pending.data?.length ?? 0 + + const dot = + health === 'ok' ? 'bg-ok' : health === 'down' ? 'bg-accent' : 'bg-sepia animate-pulse' + + return ( +
+ + +
+
+

+ {Object.entries(TITLES).find(([p]) => location.pathname.startsWith(p))?.[1] ?? 'vouch console'} +

+
+ + {conn && ( + + )} +
+
+ {conn && health === 'down' && ( +
+ Endpoint unreachable — is `vouch serve --transport http` still running? + +
+ )} +
+ +
+
+ + {(!conn || needsAuth) && } +
+ ) +} +``` + +View stubs (each replaced wholesale in its own task) — `src/views/ChatView.tsx`: + +```tsx +export function ChatView() { + return

Chat

+} +``` + +`src/views/ReviewView.tsx`: + +```tsx +export function ReviewView() { + return

Review

+} +``` + +`src/views/BrowseView.tsx`: + +```tsx +export function BrowseView() { + return

Browse

+} +``` + +`src/views/StatsView.tsx`: + +```tsx +export function StatsView() { + return

Stats

+} +``` + +Replace `src/App.tsx`: + +```tsx +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { Shell } from './components/Shell' +import { ToastProvider } from './components/Toast' +import { ConnectionProvider } from './connection/ConnectionContext' +import { BrowseView } from './views/BrowseView' +import { ChatView } from './views/ChatView' +import { ReviewView } from './views/ReviewView' +import { StatsView } from './views/StatsView' + +export default function App() { + return ( + + + + + }> + } /> + } /> + } /> + } /> + } /> + + + + + + ) +} +``` + +Update `src/test/utils.tsx` — wrap with the real ToastProvider (replace the whole file): + +```tsx +import { render } from '@testing-library/react' +import type { RenderResult } from '@testing-library/react' +import type { ReactElement } from 'react' +import { MemoryRouter } from 'react-router-dom' +import { ToastProvider } from '../components/Toast' +import { ConnectionProvider, STORAGE_KEY } from '../connection/ConnectionContext' +import type { VouchConnectionInfo } from '../lib/types' + +export const TEST_ENDPOINT = 'http://127.0.0.1:8731' + +export function seedConnection(info: Partial = {}): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ endpoint: TEST_ENDPOINT, ...info })) +} + +export function renderWithProviders(ui: ReactElement, { route = '/' } = {}): RenderResult { + return render( + + + {ui} + + , + ) +} +``` + +Update `src/App.test.tsx` (App now shows the connect dialog when disconnected): + +```tsx +import { render, screen } from '@testing-library/react' +import { beforeEach, expect, test } from 'vitest' +import App from './App' + +beforeEach(() => localStorage.clear()) + +test('boots to the connect dialog when no endpoint is stored', () => { + render() + expect(screen.getByText(/connect to your knowledge base/i)).toBeInTheDocument() +}) +``` + +- [ ] **Step 4: Run the full suite** + +Run: `npm test` +Expected: all test files pass (Shell 2, App 1, plus earlier suites) + +- [ ] **Step 5: Eyeball it against a live KB (manual check)** + +```bash +cd /tmp && rm -rf vouch-ui-demo && mkdir vouch-ui-demo && cd vouch-ui-demo && vouch init \ + && sed -i 's/^review:/review:\n approver_role: trusted-agent/' .vouch/config.yaml \ + && echo "The vouch HTTP server binds 127.0.0.1:8731 by default." > note.txt \ + && SRC=$(vouch source add note.txt | tail -1) \ + && PROP=$(vouch propose-claim --text "The vouch HTTP server binds 127.0.0.1:8731 by default" --source "$SRC" | tail -1) \ + && vouch approve "$PROP" \ + && vouch serve --transport http --port 8731 +``` + +Then `npm run dev` in another terminal, open http://localhost:5173, connect with the default endpoint. Expect: sidebar, green pill `127.0.0.1:8731`, stub views navigable, theme toggle flips light/dark. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: app shell — sidebar nav, connection pill, toasts, theme toggle, routes" +``` + +--- + +### Task 7: Artifact drawer (claim / page / entity / relation detail) + +Ordering note: the drawer comes before ChatView so citation chips (Task 8) and Browse rows (Task 11) open a real component. + +**Files:** +- Create: `src/components/ArtifactDrawer.tsx`, `src/components/ArtifactDrawer.test.tsx` + +**Interfaces:** +- Consumes: `rpc`, types (Task 3), `useConnection().hasMethod` (Task 5), `ErrorCard` (Task 6). +- Produces (used by ChatView + BrowseView): + - `type DrawerTarget = { kind: 'claim' | 'page' | 'entity' | 'relation'; id: string } | null` + - `ArtifactDrawer({ target, onClose }: { target: DrawerTarget; onClose(): void })` — right-side overlay panel; renders nothing when `target === null`. For claims it also loads citations (`kb.cite`) and provenance (`kb.why`) when those methods are advertised. + +- [ ] **Step 1: Write the failing tests** + +`src/components/ArtifactDrawer.test.tsx`: + +```tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc, VouchRpcError } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { ArtifactDrawer } from './ArtifactDrawer' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.read_claim', 'kb.cite', 'kb.why', 'kb.read_page'], + review_gated: true, +} + +const CLAIM = { + id: 'the-vouch-http-server-binds-127-0-0-1-8731-by-default', + text: 'The vouch HTTP server binds 127.0.0.1:8731 by default', + type: 'observation', + status: 'working', + confidence: 0.7, + created_at: '2026-07-04T02:17:50+00:00', +} + +const WHY = { + root: CLAIM.id, + node_kind: 'claim', + depth: 3, + provenance: [ + { kind: 'approvedBy', target: '03f7', target_kind: 'event', event_ts: null, session_id: null, cycle: false, children: [] }, + { kind: 'cites', target: 'ea1cc580', target_kind: 'source', event_ts: null, session_id: null, cycle: false, children: [] }, + ], +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + seedConnection() +}) + +test('renders nothing for a null target', () => { + const { container } = renderWithProviders( {}} />) + expect(container.querySelector('[data-testid="drawer"]')).toBeNull() +}) + +test('loads and renders a claim with citations and provenance', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.read_claim') return CLAIM + if (method === 'kb.cite') return [{ id: 'ea1cc580', title: 'note.txt' }] + if (method === 'kb.why') return WHY + throw new Error(`unexpected ${method}`) + }) + renderWithProviders( {}} />) + expect(await screen.findByText(CLAIM.text)).toBeInTheDocument() + expect(screen.getByText(/observation/)).toBeInTheDocument() + await waitFor(() => expect(screen.getByText(/approvedBy/)).toBeInTheDocument()) + expect(screen.getByText(/cites/)).toBeInTheDocument() +}) + +test('close button fires onClose', async () => { + // Per-method mock: a blanket mockResolvedValue(CLAIM) would make kb.why + // return a Claim and crash the provenance render on `.provenance.length`. + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.read_claim') return CLAIM + if (method === 'kb.cite') return [] + if (method === 'kb.why') return { root: CLAIM.id, node_kind: 'claim', depth: 3, provenance: [] } + throw new Error(`unexpected ${method}`) + }) + const onClose = vi.fn() + renderWithProviders() + await userEvent.click(await screen.findByRole('button', { name: /close/i })) + expect(onClose).toHaveBeenCalled() +}) + +test('shows an ErrorCard when the artifact cannot be read', async () => { + vi.mocked(rpc).mockRejectedValue(new VouchRpcError('not_found', 'claim missing-id not found')) + renderWithProviders( {}} />) + expect(await screen.findByText(/claim missing-id not found/)).toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/components/ArtifactDrawer.test.tsx` +Expected: FAIL — `Cannot find module './ArtifactDrawer'` + +- [ ] **Step 3: Implement `src/components/ArtifactDrawer.tsx`** + +```tsx +import { useQuery } from '@tanstack/react-query' +import { X } from 'lucide-react' +import { useConnection } from '../connection/ConnectionContext' +import { rpc } from '../lib/rpc' +import type { Citation, Claim, Entity, Page, Relation, WhyEdge, WhyResult } from '../lib/types' +import { ErrorCard } from './ErrorCard' +import { useErrorToast } from './Toast' + +export type DrawerTarget = { kind: 'claim' | 'page' | 'entity' | 'relation'; id: string } | null + +const READ_METHOD: Record = { + claim: { method: 'kb.read_claim', param: 'claim_id' }, + page: { method: 'kb.read_page', param: 'page_id' }, + entity: { method: 'kb.read_entity', param: 'entity_id' }, + relation: { method: 'kb.read_relation', param: 'relation_id' }, +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function WhyTree({ edges, depth = 0 }: { edges: WhyEdge[]; depth?: number }) { + return ( +
    + {edges.map((e, i) => ( +
  • + {e.kind}{' '} + {e.target.slice(0, 24)}{' '} + ({e.target_kind}) + {e.children.length > 0 && } +
  • + ))} +
+ ) +} + +export function ArtifactDrawer({ target, onClose }: { target: DrawerTarget; onClose: () => void }) { + const { conn, hasMethod } = useConnection() + + const read = READ_METHOD[target?.kind ?? 'claim'] + const artifact = useQuery({ + queryKey: ['artifact', target?.kind, target?.id], + queryFn: () => rpc>(conn!, read.method, { [read.param]: target!.id }), + enabled: !!conn && !!target, + }) + + const isClaim = target?.kind === 'claim' + const cite = useQuery({ + queryKey: ['cite', target?.id], + queryFn: () => rpc(conn!, 'kb.cite', { claim_id: target!.id }), + enabled: !!conn && isClaim && hasMethod('kb.cite'), + }) + const why = useQuery({ + queryKey: ['why', target?.id], + queryFn: () => rpc(conn!, 'kb.why', { claim_id: target!.id }), + enabled: !!conn && isClaim && hasMethod('kb.why'), + }) + useErrorToast(artifact.isError, artifact.error) + + if (!target) return null + + const a = artifact.data + + return ( +
+
+
+ + {target.kind} + +

{target.id}

+
+ +
+ +
+ {artifact.isPending &&

loading…

} + {artifact.isError && ( + + )} + + {a && target.kind === 'claim' && ( + <> +

{(a as unknown as Claim).text}

+ {String(a.type)} + {String(a.status)} + {Math.round(Number(a.confidence) * 100)}% + {typeof a.created_at === 'string' && {a.created_at.slice(0, 19).replace('T', ' ')}} + + )} + {a && target.kind === 'page' && ( + <> +

{(a as unknown as Page).title}

+ {String(a.type)} + {String(a.status)} +
+              {(a as unknown as Page).body}
+            
+ + )} + {a && target.kind === 'entity' && ( + <> +

{(a as unknown as Entity).name}

+ {String(a.type)} + + )} + {a && target.kind === 'relation' && ( + <> + + {(a as unknown as Relation).source} + + {(a as unknown as Relation).relation} + + {(a as unknown as Relation).target} + + {Math.round(Number(a.confidence) * 100)}% + + )} + + {isClaim && cite.data && cite.data.length > 0 && ( +
+

Citations

+
    + {cite.data.map((c, i) => ( +
  • + {typeof c.title === 'string' && c.title !== '' ? c.title : null}{' '} + + {typeof c.id === 'string' ? c.id.slice(0, 16) : JSON.stringify(c).slice(0, 60)} + +
  • + ))} +
+
+ )} + + {isClaim && why.data && why.data.provenance.length > 0 && ( +
+

Why this claim exists

+ +
+ )} +
+
+ ) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/components/ArtifactDrawer.test.tsx` +Expected: 4 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/components/ArtifactDrawer.tsx src/components/ArtifactDrawer.test.tsx +git commit -m "feat: artifact drawer with claim citations + why provenance" +``` + +--- + +### Task 8: ChatView core — synthesize with citations, gaps, confidence, history + +**Files:** +- Create: `src/views/ChatView.test.tsx` +- Modify: `src/views/ChatView.tsx` (replace the stub wholesale) + +**Interfaces:** +- Consumes: `rpc` (Task 3), `parseAnswer` (Task 4), `useConnection` (Task 5), `EmptyState`/`ErrorCard` (Task 6), `ArtifactDrawer`, `DrawerTarget` (Task 7). +- Produces: `ChatView` (already routed at `/chat`); exports `chatStorageKey(endpoint: string): string` and `type ChatMessage` for Task 9 (search) which extends this same file. + +- [ ] **Step 1: Write the failing tests** + +`src/views/ChatView.test.tsx`: + +```tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc, VouchRpcError } from '../lib/rpc' +import { renderWithProviders, seedConnection, TEST_ENDPOINT } from '../test/utils' +import { chatStorageKey, ChatView } from './ChatView' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.synthesize', 'kb.search', 'kb.read_claim', 'kb.cite', 'kb.why'], + review_gated: true, +} + +const ANSWER = { + query: 'what does the vouch http server bind', + answer: + 'The vouch HTTP server binds 127.0.0.1:8731 by default [the-vouch-http-server-binds-127-0-0-1-8731-by-default].', + claims: ['the-vouch-http-server-binds-127-0-0-1-8731-by-default'], + gaps: [], + _meta: { synthesis_confidence: 'medium' as const }, +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + seedConnection() +}) + +async function ask(text: string) { + await userEvent.type(screen.getByPlaceholderText(/ask the kb/i), text) + await userEvent.keyboard('{Enter}') +} + +test('shows the empty state before any messages', () => { + renderWithProviders() + expect(screen.getByText(/ask your knowledge base/i)).toBeInTheDocument() +}) + +test('submits a query to kb.synthesize and renders the cited answer', async () => { + vi.mocked(rpc).mockResolvedValue(ANSWER) + renderWithProviders() + await ask('what does the vouch http server bind') + expect(await screen.findByText(/binds 127\.0\.0\.1:8731 by default/)).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'the-vouch-http-server-binds-127-0-0-1-8731-by-default' }), + ).toBeInTheDocument() + expect(screen.getByText(/medium/i)).toBeInTheDocument() + expect(rpc).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: TEST_ENDPOINT }), + 'kb.synthesize', + { query: 'what does the vouch http server bind' }, + ) +}) + +test('clicking a citation chip opens the claim drawer', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.synthesize') return ANSWER + if (method === 'kb.read_claim') + return { + id: ANSWER.claims[0], + text: 'The vouch HTTP server binds 127.0.0.1:8731 by default', + type: 'observation', + status: 'working', + confidence: 0.7, + } + if (method === 'kb.cite') return [] + if (method === 'kb.why') return { root: ANSWER.claims[0], node_kind: 'claim', depth: 3, provenance: [] } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await ask('bind') + await userEvent.click(await screen.findByRole('button', { name: ANSWER.claims[0] })) + expect(await screen.findByTestId('drawer')).toBeInTheDocument() +}) + +test('renders gaps and the no-answer note when nothing matches', async () => { + vi.mocked(rpc).mockResolvedValue({ + query: 'kubernetes', + answer: '', + claims: [], + gaps: ['kubernetes'], + _meta: { synthesis_confidence: 'medium' as const }, + }) + renderWithProviders() + await ask('kubernetes') + expect(await screen.findByText(/no approved claims matched/i)).toBeInTheDocument() + expect(screen.getByText(/kubernetes/, { selector: 'span' })).toBeInTheDocument() +}) + +test('renders an error bubble plus an error toast on rpc failure', async () => { + vi.mocked(rpc).mockRejectedValue(new VouchRpcError('invalid_request', 'query is required')) + renderWithProviders() + await ask('boom') + expect(await screen.findByText(/query is required/)).toBeInTheDocument() + expect(screen.getByText('invalid_request')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('invalid_request: query is required') +}) + +test('disables the input when kb.synthesize is not advertised', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue({ ...CAPS, methods: ['kb.search'] }) + renderWithProviders() + await waitFor(() => + expect(screen.getByPlaceholderText(/kb\.synthesize is not advertised/i)).toBeDisabled(), + ) +}) + +test('persists the thread per endpoint and restores it', async () => { + vi.mocked(rpc).mockResolvedValue(ANSWER) + const first = renderWithProviders() + await ask('what does the vouch http server bind') + await screen.findByText(/binds 127\.0\.0\.1:8731/) + await waitFor(() => expect(localStorage.getItem(chatStorageKey(TEST_ENDPOINT))).toContain('binds 127.0.0.1:8731')) + first.unmount() + renderWithProviders() + expect(await screen.findByText(/binds 127\.0\.0\.1:8731/)).toBeInTheDocument() +}) + +test('clear thread wipes messages and storage', async () => { + vi.mocked(rpc).mockResolvedValue(ANSWER) + renderWithProviders() + await ask('bind') + await screen.findByText(/binds 127\.0\.0\.1:8731/) + await userEvent.click(screen.getByRole('button', { name: /clear/i })) + expect(screen.queryByText(/binds 127\.0\.0\.1:8731/)).not.toBeInTheDocument() + expect(localStorage.getItem(chatStorageKey(TEST_ENDPOINT))).toBeNull() +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/views/ChatView.test.tsx` +Expected: FAIL — `chatStorageKey` not exported / placeholder markup + +- [ ] **Step 3: Replace `src/views/ChatView.tsx`** + +```tsx +import { SendHorizontal, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import type { FormEvent } from 'react' +import { ArtifactDrawer } from '../components/ArtifactDrawer' +import type { DrawerTarget } from '../components/ArtifactDrawer' +import { EmptyState } from '../components/EmptyState' +import { useToast } from '../components/Toast' +import { useConnection } from '../connection/ConnectionContext' +import { parseAnswer } from '../lib/citations' +import { rpc, VouchRpcError } from '../lib/rpc' +import type { Confidence, SearchResult, SynthesizeResult } from '../lib/types' + +export type ChatMessage = + | { role: 'user'; text: string } + | { role: 'vouch'; kind: 'answer'; result: SynthesizeResult } + | { role: 'vouch'; kind: 'search'; query: string; result: SearchResult } + | { role: 'vouch'; kind: 'error'; code?: string; message: string } + +export function chatStorageKey(endpoint: string): string { + return `vouch-ui.chat.${endpoint}` +} + +const MAX_MESSAGES = 100 + +const CONFIDENCE_CLASS: Record = { + high: 'text-ok border-ok/40', + medium: 'text-accent-2 border-accent/40', + low: 'text-sepia border-rule', +} + +function ConfidenceBadge({ level }: { level?: Confidence }) { + if (!level) return null + return ( + + {level} + + ) +} + +function AnswerBody({ result, onCite }: { result: SynthesizeResult; onCite: (id: string) => void }) { + if (result.answer === '') { + return ( +
+

+ No approved claims matched this query — the KB stays silent rather than guessing. +

+ {result.gaps.length > 0 && ( +

+ not covered:{' '} + {result.gaps.map((g) => ( + {g} + ))} +

+ )} +
+ ) + } + return ( +
+

+ {parseAnswer(result.answer).map((seg, i) => + seg.kind === 'text' ? ( + {seg.text} + ) : ( + + ), + )} +

+
+ + {result.gaps.length > 0 && ( + + gaps: {result.gaps.map((g) => ( + {g} + ))} + + )} +
+
+ ) +} + +export function ChatView() { + const { conn, caps, hasMethod, reportError } = useConnection() + const { toast } = useToast() + // Gate only once capabilities have loaded; a null caps means "still checking". + const canAsk = !caps || hasMethod('kb.synthesize') + const endpoint = conn?.endpoint ?? '' + const [messages, setMessages] = useState(() => { + try { + const raw = endpoint ? localStorage.getItem(chatStorageKey(endpoint)) : null + return raw ? (JSON.parse(raw) as ChatMessage[]) : [] + } catch { + return [] + } + }) + const [input, setInput] = useState('') + const [busy, setBusy] = useState(false) + const [drawer, setDrawer] = useState(null) + const bottomRef = useRef(null) + + useEffect(() => { + if (!endpoint) return + if (messages.length === 0) { + localStorage.removeItem(chatStorageKey(endpoint)) + } else { + localStorage.setItem(chatStorageKey(endpoint), JSON.stringify(messages.slice(-MAX_MESSAGES))) + } + }, [messages, endpoint]) + + useEffect(() => { + bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) + }, [messages.length]) + + async function submit(e: FormEvent) { + e.preventDefault() + const text = input.trim() + if (text === '' || busy || !conn) return + setInput('') + setBusy(true) + setMessages((m) => [...m, { role: 'user', text }]) + try { + // Task 9 extends this with the /search command. + const result = await rpc(conn, 'kb.synthesize', { query: text }) + setMessages((m) => [...m, { role: 'vouch', kind: 'answer', result }]) + } catch (err) { + reportError(err) // 401s must reopen the connect dialog (spec §4) + const code = err instanceof VouchRpcError ? err.code : undefined + const message = err instanceof Error ? err.message : String(err) + toast('error', code ? `${code}: ${message}` : message) + setMessages((m) => [...m, { role: 'vouch', kind: 'error', code, message }]) + } finally { + setBusy(false) + } + } + + function clear() { + setMessages([]) + if (endpoint) localStorage.removeItem(chatStorageKey(endpoint)) + } + + return ( +
+
+ {messages.length === 0 ? ( + + ) : ( +
+ {messages.map((msg, i) => { + if (msg.role === 'user') { + return ( +
+
+ {msg.text} +
+
+ ) + } + if (msg.kind === 'error') { + return ( +
+
+ {msg.code && {msg.code}} + {msg.message} +
+
+ ) + } + if (msg.kind === 'answer') { + return ( +
+
+ setDrawer({ kind: 'claim', id })} /> +
+
+ ) + } + return null // search results render in Task 9 + })} +
+
+ )} +
+ +
+ setInput(e.target.value)} + placeholder={canAsk ? 'Ask the KB — or /search ' : 'kb.synthesize is not advertised by this endpoint'} + disabled={busy || !canAsk} + className="min-w-0 flex-1 rounded-xl border border-rule bg-paper-2 px-4 py-2.5 text-sm text-ink outline-none placeholder:text-sepia focus:border-accent disabled:opacity-60" + /> + + + + + setDrawer(null)} /> +
+ ) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/views/ChatView.test.tsx` +Expected: 9 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/views/ChatView.tsx src/views/ChatView.test.tsx +git commit -m "feat: chat view — synthesize with citation chips, confidence, gaps, persistence" +``` + +--- + +### Task 9: Chat `/search` command + hit cards + +**Files:** +- Modify: `src/views/ChatView.tsx`, `src/views/ChatView.test.tsx` (append tests) + +**Interfaces:** +- Consumes: `parseSnippet` (Task 4), `SearchResult`/`SearchHit` types (Task 3), existing `ChatMessage` search variant (Task 8). +- Produces: input beginning `/search ` runs `kb.search {query, limit: 10}`; a **mode toggle** beside the input (spec §2) routes plain submissions to search while active; hits render as cards; claim/page/entity/relation hits open the drawer, other kinds (e.g. `source`) are inert. + +- [ ] **Step 1: Append the failing tests** + +Append to `src/views/ChatView.test.tsx`: + +```tsx +const SEARCH = { + backend: 'fts5', + hits: [ + { + kind: 'claim', + id: 'the-vouch-http-server-binds-127-0-0-1-8731-by-default', + snippet: 'The vouch «HTTP» «server» binds 127.0.0.1:8731 by default', + score: 2.2e-6, + backend: 'fts5', + }, + { kind: 'source', id: 'ea1cc5801740a467', snippet: 'a «server» note', score: 1.1e-6, backend: 'fts5' }, + ], +} + +test('/search routes to kb.search and renders highlighted hit cards', async () => { + vi.mocked(rpc).mockResolvedValue(SEARCH) + renderWithProviders() + await ask('/search http server') + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.search', { query: 'http server', limit: 10 }) + const marks = await screen.findAllByText('HTTP', { selector: 'mark' }) + expect(marks.length).toBeGreaterThan(0) + expect(screen.getByText('fts5', { selector: '[data-testid="search-backend"]' })).toBeInTheDocument() +}) + +test('search mode toggle routes plain input to kb.search', async () => { + vi.mocked(rpc).mockResolvedValue(SEARCH) + renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: /search mode/i })) + await ask('http server') + await waitFor(() => + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.search', { query: 'http server', limit: 10 }), + ) +}) + +test('claim hits open the drawer; source hits are inert', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.search') return SEARCH + if (method === 'kb.read_claim') + return { id: SEARCH.hits[0].id, text: 'The vouch HTTP server binds…', type: 'observation', status: 'working', confidence: 0.7 } + if (method === 'kb.cite') return [] + if (method === 'kb.why') return { root: SEARCH.hits[0].id, node_kind: 'claim', depth: 3, provenance: [] } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await ask('/search http server') + const claimHit = await screen.findByRole('button', { name: /the-vouch-http-server/i }) + await userEvent.click(claimHit) + expect(await screen.findByTestId('drawer')).toBeInTheDocument() + // source hit renders as a non-button card + expect(screen.queryByRole('button', { name: /ea1cc5801740a467/i })).not.toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run to verify the new tests fail** + +Run: `npx vitest run src/views/ChatView.test.tsx` +Expected: the three new tests FAIL (search message renders `null`, no toggle button); the earlier 9 still pass + +- [ ] **Step 3: Implement — three edits to `src/views/ChatView.tsx`** + +(1) Extend imports: + +```tsx +import { parseAnswer, parseSnippet } from '../lib/citations' +import type { Confidence, SearchHit, SearchResult, SynthesizeResult } from '../lib/types' +``` + +(2) Add a search-mode state next to the other `useState` calls, and a toggle button between the input and the send button: + +```tsx + const [searchMode, setSearchMode] = useState(false) +``` + +```tsx + +``` + +(add `Search as SearchIcon` to the lucide-react import). Then in `submit()`, replace the single-line `const result = await rpc…` block body of the `try` with: + +```tsx + if (searchMode || text.startsWith('/search ')) { + const query = (text.startsWith('/search ') ? text.slice('/search '.length) : text).trim() + const result = await rpc(conn, 'kb.search', { query, limit: 10 }) + setMessages((m) => [...m, { role: 'vouch', kind: 'search', query, result }]) + } else { + const result = await rpc(conn, 'kb.synthesize', { query: text }) + setMessages((m) => [...m, { role: 'vouch', kind: 'answer', result }]) + } +``` + +(with the toggle active the input stays enabled even when `kb.synthesize` is missing — change the input/send `disabled` conditions from `!canAsk` to `!(canAsk || searchMode)`.) + +(3) Add a `SearchHits` component above `ChatView`, and replace the `return null // search results render in Task 9` branch: + +```tsx +const DRAWER_KINDS = new Set(['claim', 'page', 'entity', 'relation']) + +function HitSnippet({ snippet }: { snippet: string }) { + return ( + + {parseSnippet(snippet).map((seg, i) => + seg.kind === 'match' ? ( + {seg.text} + ) : ( + {seg.text} + ), + )} + + ) +} + +function SearchHits({ + result, + onOpen, +}: { + result: SearchResult + onOpen: (kind: 'claim' | 'page' | 'entity' | 'relation', id: string) => void +}) { + if (result.hits.length === 0) { + return

No hits.

+ } + return ( +
+

+ {result.hits.length} hit{result.hits.length === 1 ? '' : 's'} ·{' '} + {result.backend} +

+ {result.hits.map((hit: SearchHit) => { + const clickable = DRAWER_KINDS.has(hit.kind) + const body = ( + <> +
+ + {hit.kind} + + {hit.id} +
+ + + ) + return clickable ? ( + + ) : ( +
{body}
+ ) + })} +
+ ) +} +``` + +Replace the `return null` branch in the message map with: + +```tsx + if (msg.kind === 'search') { + return ( +
+
+ setDrawer({ kind, id })} /> +
+
+ ) + } + return null +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/views/ChatView.test.tsx` +Expected: 12 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/views/ChatView.tsx src/views/ChatView.test.tsx +git commit -m "feat: /search chat command + search mode toggle with highlighted hit cards" +``` + +--- + +### Task 10: Review queue — approve / reject the gate + +**Files:** +- Create: `src/views/ReviewView.test.tsx` +- Modify: `src/views/ReviewView.tsx` (replace the stub wholesale) + +**Interfaces:** +- Consumes: `rpc`, `Proposal` type (Task 3), `useConnection` (Task 5), `useToast`, `EmptyState`, `ErrorCard` (Task 6). +- Produces: `ReviewView` at `/review`. Mutations: `kb.approve {proposal_id}` and `kb.reject {proposal_id, reason}` (reason is REQUIRED by the server — the UI enforces non-empty). Decisions remove the proposal from the `['pending']` cache **optimistically** in `onMutate` (rolled back on error); success additionally invalidates `['pending']`, `['status']`, `['stats']`, and `['list']`-prefixed caches. Decision failures render inline AND toast. + +- [ ] **Step 1: Write the failing tests** + +`src/views/ReviewView.test.tsx`: + +```tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc, VouchRpcError } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { ReviewView } from './ReviewView' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.list_pending', 'kb.approve', 'kb.reject'], + review_gated: true, +} + +const PROPOSAL = { + id: '20260704-021728-c4b86871', + kind: 'claim', + proposed_by: 'agent-a', + session_id: null, + payload: { text: 'The vouch HTTP server binds 127.0.0.1:8731 by default', confidence: 0.7 }, + status: 'pending', + proposed_at: '2026-07-04T02:17:28+00:00', +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + seedConnection() +}) + +test('shows the empty state when the queue is clear', async () => { + vi.mocked(rpc).mockResolvedValue([]) + renderWithProviders() + expect(await screen.findByText(/queue is clear/i)).toBeInTheDocument() +}) + +test('lists pending proposals and shows the payload on select', async () => { + vi.mocked(rpc).mockResolvedValue([PROPOSAL]) + renderWithProviders() + const row = await screen.findByText(/20260704-021728-c4b86871/) + await userEvent.click(row) + // The text appears twice: queue-row preview + detail
— assert both. + expect(screen.getAllByText(/binds 127\.0\.0\.1:8731 by default/)).toHaveLength(2) + expect(screen.getByText('agent-a')).toBeInTheDocument() +}) + +test('approve removes the proposal from the queue before the server responds (optimistic)', async () => { + let resolveApprove: (v: unknown) => void = () => {} + vi.mocked(rpc).mockImplementation((_c, method) => { + if (method === 'kb.list_pending') return Promise.resolve([PROPOSAL]) + if (method === 'kb.approve') return new Promise((r) => (resolveApprove = r)) + return Promise.reject(new Error(`unexpected ${method}`)) + }) + renderWithProviders() + await userEvent.click(await screen.findByText(/20260704-021728-c4b86871/)) + await userEvent.click(screen.getByRole('button', { name: /approve/i })) + await waitFor(() => expect(screen.queryByText(/20260704-021728-c4b86871/)).not.toBeInTheDocument()) + resolveApprove({ kind: 'claim', id: 'x' }) +}) + +test('approve calls kb.approve and reports success', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_pending') return [PROPOSAL] + if (method === 'kb.approve') return { kind: 'claim', id: 'the-vouch-http-server-binds' } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await userEvent.click(await screen.findByText(/20260704-021728-c4b86871/)) + await userEvent.click(screen.getByRole('button', { name: /approve/i })) + await waitFor(() => + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.approve', { proposal_id: PROPOSAL.id }), + ) + expect(await screen.findByText(/approved → claim\/the-vouch-http-server-binds/i)).toBeInTheDocument() +}) + +test('reject requires a reason and sends it', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_pending') return [PROPOSAL] + if (method === 'kb.reject') return { proposal_id: PROPOSAL.id, status: 'rejected' } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await userEvent.click(await screen.findByText(/20260704-021728-c4b86871/)) + await userEvent.click(screen.getByRole('button', { name: /^reject$/i })) + // reason input appears; confirm is disabled until non-empty + const confirm = screen.getByRole('button', { name: /confirm reject/i }) + expect(confirm).toBeDisabled() + await userEvent.type(screen.getByPlaceholderText(/why is this rejected/i), 'unsupported claim') + await userEvent.click(confirm) + await waitFor(() => + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.reject', { + proposal_id: PROPOSAL.id, + reason: 'unsupported claim', + }), + ) +}) + +test('surfaces forbidden_self_approval errors inline', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_pending') return [PROPOSAL] + if (method === 'kb.approve') + throw new VouchRpcError('invalid_request', 'forbidden_self_approval: a cannot approve their own proposal') + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await userEvent.click(await screen.findByText(/20260704-021728-c4b86871/)) + await userEvent.click(screen.getByRole('button', { name: /approve/i })) + expect(await screen.findByText(/forbidden_self_approval/)).toBeInTheDocument() +}) + +test('hides decision buttons when kb.approve is not advertised', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue({ ...CAPS, methods: ['kb.list_pending'] }) + vi.mocked(rpc).mockResolvedValue([PROPOSAL]) + renderWithProviders() + await userEvent.click(await screen.findByText(/20260704-021728-c4b86871/)) + expect(screen.queryByRole('button', { name: /approve/i })).not.toBeInTheDocument() + expect(screen.getByText(/read-only/i)).toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/views/ReviewView.test.tsx` +Expected: FAIL against the stub + +- [ ] **Step 3: Replace `src/views/ReviewView.tsx`** + +```tsx +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, X } from 'lucide-react' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { useErrorToast, useToast } from '../components/Toast' +import { useConnection } from '../connection/ConnectionContext' +import { rpc, VouchRpcError } from '../lib/rpc' +import type { Proposal } from '../lib/types' + +function payloadPreview(p: Proposal): string { + const pl = p.payload + if (typeof pl.text === 'string') return pl.text + if (typeof pl.title === 'string') return pl.title + if (typeof pl.name === 'string') return pl.name + return JSON.stringify(pl).slice(0, 120) +} + +export function ReviewView() { + const { conn, caps, hasMethod } = useConnection() + const { toast } = useToast() + const qc = useQueryClient() + const [selectedId, setSelectedId] = useState(null) + const [rejecting, setRejecting] = useState(false) + const [reason, setReason] = useState('') + const [decisionError, setDecisionError] = useState<{ code?: string; message: string } | null>(null) + + const available = !caps || hasMethod('kb.list_pending') + const pending = useQuery({ + queryKey: ['pending'], + queryFn: () => rpc(conn!, 'kb.list_pending'), + enabled: !!conn && available, + refetchInterval: 10_000, + }) + useErrorToast(pending.isError, pending.error) + + const proposals = pending.data ?? [] + // Derived: an optimistic cache removal hides the detail pane in flight; + // a rollback (onError restoring the cache) brings it back, error card intact. + const selected = proposals.find((p) => p.id === selectedId) ?? null + const canDecide = hasMethod('kb.approve') && hasMethod('kb.reject') + + function afterDecision() { + setSelectedId(null) + setRejecting(false) + setReason('') + void qc.invalidateQueries({ queryKey: ['pending'] }) + void qc.invalidateQueries({ queryKey: ['status'] }) + void qc.invalidateQueries({ queryKey: ['stats'] }) + void qc.invalidateQueries({ queryKey: ['list'] }) + } + + function decisionFailed(err: unknown) { + const code = err instanceof VouchRpcError ? err.code : undefined + const message = err instanceof Error ? err.message : String(err) + toast('error', code ? `${code}: ${message}` : message) + setDecisionError({ code, message }) + } + + async function removeOptimistically(id: string) { + await qc.cancelQueries({ queryKey: ['pending'] }) + const previous = qc.getQueryData(['pending']) + qc.setQueryData(['pending'], (old) => (old ?? []).filter((p) => p.id !== id)) + return { previous } + } + + function rollback(ctx: { previous?: Proposal[] } | undefined) { + if (ctx?.previous) qc.setQueryData(['pending'], ctx.previous) + } + + const approve = useMutation({ + mutationFn: (id: string) => rpc<{ kind: string; id: string }>(conn!, 'kb.approve', { proposal_id: id }), + onMutate: removeOptimistically, + onError: (err, _id, ctx) => { + rollback(ctx) + decisionFailed(err) + }, + onSuccess: (res) => { + toast('success', `Approved → ${res.kind}/${res.id}`) + afterDecision() + }, + }) + + const reject = useMutation({ + mutationFn: (vars: { id: string; reason: string }) => + rpc<{ proposal_id: string; status: string }>(conn!, 'kb.reject', { + proposal_id: vars.id, + reason: vars.reason, + }), + onMutate: (vars) => removeOptimistically(vars.id), + onError: (err, _vars, ctx) => { + rollback(ctx) + decisionFailed(err) + }, + onSuccess: (res) => { + toast('info', `Rejected ${res.proposal_id}`) + afterDecision() + }, + }) + + if (!available) { + return ( + + ) + } + if (pending.isPending) return

loading queue…

+ if (pending.isError) + return ( +
+ +
+ ) + + if (proposals.length === 0) { + return ( + + ) + } + + return ( +
+
    + {proposals.map((p) => ( +
  • + +
  • + ))} +
+ +
+ {!selected ? ( + + ) : ( +
+
+ + {selected.kind} + + {selected.id} +
+ +
+ {Object.entries(selected.payload).map(([k, v]) => ( +
+
{k}
+
+ {typeof v === 'string' ? v : JSON.stringify(v)} +
+
+ ))} +
+
proposed by
+
{selected.proposed_by}
+
+
+ + {decisionError && ( +
+ +
+ )} + + {!canDecide ? ( +

+ This endpoint is read-only for you — kb.approve / kb.reject are not advertised. +

+ ) : rejecting ? ( +
+ + + + + + + + + + + + + diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 00000000..12058f97 --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "plugins", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"] +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts new file mode 100644 index 00000000..de6acf2b --- /dev/null +++ b/webapp/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { vouchProxy } from './plugins/vouch-proxy' +import { claudeBridge } from './plugins/claude-bridge' + +export default defineConfig({ + plugins: [react(), tailwindcss(), vouchProxy(), claudeBridge()], +}) diff --git a/webapp/vitest.config.ts b/webapp/vitest.config.ts new file mode 100644 index 00000000..75196c01 --- /dev/null +++ b/webapp/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}', 'plugins/**/*.test.ts'], + }, +})