Merge milestone/v0.1-prototype: approved prototype (v0.1.8)

Milestone v0.1 — Prototype — COMPLETE.

Owner approval recorded 2026-09-27 (decision commit 3fd0e10) on the
extended prototype: v0.1.8. Phases P0–P8 (two owner-feedback
extension cycles), requirements REQ-52..62 + REQ-63..68 complete.

Delivered: Ask/Choose/Plan & Run/Reading Room inside a persistent app
shell (sidebar + top bar + avatar), folder-structured research library
with cross-report connections, settings surface, open-question
suggestions, refine dialogue — all behind the typed ApiClient
boundary, 90/90 tests, mock-backed (zero throwaway for v0.2).

Next: milestone v0.2 — MVP (FastAPI backend, engine, adapters,
trust loop, OpenAPI client swap).
This commit is contained in:
coreci
2026-09-27 10:03:20 +00:00
72 changed files with 13728 additions and 82 deletions
+12 -7
View File
@@ -1,7 +1,7 @@
# Architecture
## Overview
Three-tier local-first web application: Next.js frontend → FastAPI backend → custom async research engine, with all external capabilities (model, search, storage, memory, secrets) isolated behind hot-swappable adapter interfaces. The engine is a hand-rolled asyncio state machine; every run persists its resolved 12-dial configuration and full event trace for reproducibility and auditability.
Two-milestone build (owner-directed 2026-09-27). Milestone v0.1 builds the UI/UX prototype as the production Next.js frontend with all data access behind a typed `ApiClient` interface backed by `MockApiClient` — no backend, zero throwaway work. Milestone v0.2 (gated on prototype approval) adds the FastAPI backend, the custom async research engine, and the adapter layer, then swaps `MockApiClient` for the OpenAPI-generated client. The engine is a hand-rolled asyncio state machine; every run persists its resolved 12-dial configuration and full event trace for reproducibility and auditability.
## Components
@@ -47,22 +47,27 @@ FastAPI routes: POST /api/runs, GET /api/methodologies, GET /api/runs/{id}, appr
- **DependsOn:** engine, methodologies, config
### 7. Frontend (Next.js App Router, TypeScript strict)
Screens: Ask (question + optional context), Choose (methodology cards with ETA/cost/why-recommended; dials behind "advanced"), Plan & Run (approve/edit plan, live SSE trace, pause/cancel), Reading Room v0 (rendered report, hover-a-citation → exact quoted passage, ledger panel, Markdown/JSON export).
App shell (root layout, P5): persistent sidebar (primary nav — New research / Library / Settings; adapter connection-status dots as a v2 preview) + top bar (⌘K navigation palette — screens + stored reports; avatar button → user menu with the local single-user profile). Screens: Ask (question + optional context; open-question suggestions below the form — follow-ups from unanswered questions of past runs, click-fills-the-box, REQ-67; opt-in Refine dialogue — inline drawer, scripted clarifying exchange, composed refined question + suggested context, REQ-68), Choose (methodology cards with ETA/cost/why-recommended; dials behind "advanced"), Plan & Run (approve/edit plan, live SSE trace, pause/cancel), Reading Room v0 (rendered report, hover-a-citation → exact quoted passage, ledger panel, Markdown/JSON export), Library (P5: folder-structure report storage — auto-categorized folders, recency/title sorting, report cards into the Reading Room; cross-report Connections panel — shared-sources ⇄ / related-topic → edges; project graph + task inbox as v2-preview panels), Settings (P5: masked API-key status, storage directory, default methodology + resolved 12-dial display, appearance preview, about/version).
- **Boundaries:** consumes typed API client; no direct engine access.
- **DependsOn:** API layer (via generated client)
- **Boundaries:** consumes the typed `ApiClient` interface only — components never import the mock client directly. In v0.1 the interface is bound to `MockApiClient` (fixture data + scripted event replay); in v0.2 phase 3 the binding swaps to the OpenAPI-generated client with no component changes.
- **DependsOn:** ApiClient contract (v0.1: mock; v0.2: API layer)
## Data Flow
Ask (question + context) → Choose (methodology slug + dial overrides) → POST /api/runs → engine PLANNING (planner model via model adapter, dial bundle resolved) → AWAITING_APPROVAL (plan persisted; SSE events; user approves/edits) → SEARCHING (search adapter, routing per dials) → EXTRACTING (fetch pages → evidence store, stable passage IDs) → REFLECTING (planner model; depth loop per dials) → SYNTHESIZING (writer model; context = evidence passages only, per-claim citation markers) → VERIFYING (liveness pass, quote anchoring, confidence labels) → DONE (report + ledger + checklist persisted to storage; Reading Room renders; export).
v0.1 (prototype): Ask (question + context) → Choose (methodology slug + dial overrides) → MockApiClient returns scripted plan → Plan & Run (scripted live trace replay, pause/cancel) → Reading Room (mock report with anchored citations, ledger, real file exports). All flows exercise the exact component paths the MVP will use.
v0.2 (MVP): Ask (question + context) → Choose (methodology slug + dial overrides) → POST /api/runs → engine PLANNING (planner model via model adapter, dial bundle resolved) → AWAITING_APPROVAL (plan persisted; SSE events; user approves/edits) → SEARCHING (search adapter, routing per dials) → EXTRACTING (fetch pages → evidence store, stable passage IDs) → REFLECTING (planner model; depth loop per dials) → SYNTHESIZING (writer model; context = evidence passages only, per-claim citation markers) → VERIFYING (liveness pass, quote anchoring, confidence labels) → DONE (report + ledger + checklist persisted to storage; Reading Room renders; export).
## Build Order
Milestone v0.1 (prototype): 1. Frontend scaffold + ApiClient/MockApiClient boundary → 2. Ask + Choose → 3. Plan & Run + Reading Room v0 → 4. v2 mockups + polish + 3-click validation → 5. review + owner approval gate (owner returned gate feedback → extension cycle: 5. app shell + research library + settings + avatar → 6. final review + approval re-presentation (2nd owner gate feedback: 7. ask suggestions + refine dialogue → 8. final re-review + ship)).
Milestone v0.2 (MVP, gated on v0.1 approval):
1. Methodology framework (dial schema + 2 seed methodologies + registry)
2. Adapter layer (model/search/storage contracts + local implementations; fake adapters for tests)
3. Evidence store (visited pages, passage extraction)
4. Trust loop (liveness, anchoring, ledger)
5. Engine (state machine, event bus, durable state, pause/resume)
6. API layer (routes + SSE)
7. Frontend (Ask → Choose → Plan & Run → Reading Room v0)
7. Integration (swap MockApiClient → OpenAPI-generated client)
8. Adversarial test suite (hallucinated-URL detection, quote-mismatch detection, pause/resume mid-depth-loop) + E2E happy path
Build order is dependency-driven: trust verification requires evidence; engine requires adapters + trust; API requires engine; frontend requires API.
Build order is dependency-driven: trust verification requires evidence; engine requires adapters + trust; API requires engine; integration requires API; frontend components carry over unchanged from v0.1.
+6 -4
View File
@@ -1,6 +1,8 @@
{
"phase": 0,
"stage": "specify",
"milestone": "v1.0",
"phase_role": "pre_execution"
"phase": 8,
"stage": "complete",
"milestone": "v0.1",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-09-27T05:50:00Z"
}
+46
View File
@@ -0,0 +1,46 @@
# Personas — Milestone v0.1 (Prototype)
---
active:
- name: frontend-engineer
- name: ci-lead-developer
phase_specific: []
reason: "v0.1 is a pure frontend prototype (Next.js, mock data). Only the frontend domain has work; backend/data personas activate in v0.2. Lead-developer coordinates decomposition and enforces the ApiClient boundary."
---
## Roster
### frontend-engineer
- **Domain**: frontend
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest
- **Constraints**: component-first; server components by default, client components only where interactivity demands; all data access through the ApiClient interface — components never import MockApiClient directly; no secrets or env keys in the frontend; typography care per vision §4
- **Territory**: `frontend/**`
### ci-lead-developer
- **Domain**: coordination
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: ciagent workflows, git discipline
- **Constraints**: enforce persona territories; ApiClient boundary review; commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution)
- **Territory**: `.ciagent/**`, repo root config files
### Deactivated (v0.1)
#### backend-engineer
- **Domain**: backend
- **Active**: false
- **Reason**: No backend in the prototype milestone; the FastAPI backend is milestone v0.2 (blocked until prototype approval)
- **Frameworks**: fastapi, pydantic, uv (activates in v0.2)
- **Territory**: `backend/**` (does not exist yet)
#### data-engineer
- **Domain**: data
- **Active**: false
- **Reason**: No database/schema work in v0.1; evidence store and citation ledger models arrive in v0.2; prototype uses typed fixtures
- **Frameworks**: pydantic schemas (activates in v0.2)
- **Territory**: `backend/src/researchx/evidence/**` (does not exist yet)
### Custom personas
None required for v0.1. The security-auditor persona activates in v0.2 phase 2 (trust loop: secrets handling, liveness verification). No CLI surface exists in this milestone, so no cli-engineer.
+54
View File
@@ -0,0 +1,54 @@
# PLAN — Milestone v0.1 Phase 8 (FINAL): Review + Approval Re-presentation + Ship
---
phase: 8
milestone: v0.1
status: executed
must_haves:
- "Full-milestone 2nd final review: PASS-WITH-FIXES — double-compose race + dead drawer-keyboard P1s fixed with regression tests (90/90); P6/P7 fixes verified holding"
- "Audit: PASS-WITH-NOTES — reconstruction, tags, file/branch/commit discipline, secrets sweep green; notes applied (ARCHITECTURE §7 + Build Order, root README /ask row, VERSIONING table, About string, package.json version)"
- "Final phase patch v0.1.8 shipped — milestone release candidate, presented at the REQ-62 owner approval gate with UAT staged per owner cadence"
personas:
- ci-lead-developer
requirements: [REQ-62]
---
## User-Facing Surface
The extended prototype presented for owner approval: everything from v0.1.6 (shell, library, settings, avatar) plus the v0.1.7 ask-intelligence layer — open-question suggestions and the refine dialogue on the New Research view. Releases v0.1.0–v0.1.8; v0.1.8 is the extended-prototype release candidate.
## Happy Path (owner review script)
1. `cd frontend && pnpm install && pnpm dev` → http://localhost:3000 → shell renders on /ask
2. Ask → question → Research this (click 1)
3. Choose → Deep Report card → Start research (click 2)
4. Plan & Run → Approve plan (click 3) → live trace → pause/resume/cancel
5. Read the report → hover [n] → exact quote; ledger; export Markdown/JSON
6. New research → click an open-question suggestion → box fills (no auto-start) → edit → Research this
7. New research → type a draft → Refine question → answer the 3 clarifying questions (skip the optional two) → Use this → question + context filled → Research this
8. Library → folder tree → stored report opens its own report; Connections panel; ⌘K palette
9. Avatar → user menu → Settings → API keys masked, methodology defaults live preview
10. Owner decision: approve → v0.1.8 published + milestone merge to main; or request changes
## What this phase did
1. **Review** (multi-persona, P7-weighted, probe-verified): verdict PASS-WITH-FIXES. P1s: Enter at the last exchange question composed twice (stale section-level keydown guard re-ran submitAnswer after the input guard advanced — invisible with the deterministic mock, a doubled paid POST the day v0.2 wires the endpoint) and Enter on focused drawer buttons was dead (section preventDefault swallowed the click). Fixed: single Enter path (input guard owns Enter; buttons self-activate); error state gained a Keep original button (mouse-user trap). 2 regression tests (single-compose spy, Enter-on-button); suite 88 → 90. Adversarial probe: no drawer control can submit the outer form. P6/P7 fixes verified holding.
2. **Audit**: PASS-WITH-NOTES — tags v0.1.0–v0.1.7 verified (annotated, on-branch, mapped to ship records); DEPLOY-RECORD.md tracked + accurate; branch hygiene exact; commit discipline clean (---/ci--- closer holding since the P6 regression); secrets clean across history; REQ-67/68 spot-checked as genuinely delivered; gates re-run live (90/90). Notes applied: ARCHITECTURE.md §7 + Build Order extended with the ask-intelligence layer; root README /ask row updated; VERSIONING table → v0.1.8; settings About + package.json → v0.1.8.
3. **Ship (this commit sequence)**: PR #8 (phase/08 → milestone), squash-merge, tag v0.1.8, release with full milestone summary, v0.1.8-uat staged on the LXC host per owner cadence. Per REQ-62 and the prototype_approval escalation hook, the milestone merge to main does NOT proceed autonomously — the pipeline halts at the gate for the recorded owner decision.
## UX Acceptance Criteria
- Owner demo script runs clean on the shipped code (probe- and test-verified)
- Suggestions + refine never auto-submit; drafts stay untouched on dismiss
- The 3-click criterion still pinned green on AskView (affordances opt-in)
- Honest captions everywhere; keyboard accessible throughout
## Verification
- Gates: typecheck ✓ lint ✓ tests 90/90 ✓ build ✓ (re-run after review fixes, plus by audit)
- Regression tests pin: single-compose, Enter-on-button, focus return, Enter-guard, no-auto-submit
- Post-hoc v0.2 log: compose-endpoint idempotency key; freeze draft at drawer-open; confirm-overwrite when context non-empty; Try-again from error state; textarea answers; aria-live progress
## Scope Guard
Milestone merge to main, v0.1.8 publication, v0.2 unblocking, and all v0.2 P1 work wait on the owner approval gate (REQ-62, prototype_approval escalation hook). The pipeline does not proceed autonomously past this commit.
+13 -6
View File
@@ -6,14 +6,15 @@ ResearchX (working name; rename deferred — naming collision audit on file, see
## Core Value
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source. Simple enough to run in three clicks, transparent enough to audit line by line.
## Objective (MVP milestone v1.0)
Build the 3-click happy path end-to-end: Ask → Choose (methodology cards) → Plan & Run (approved plan + live interruptible trace), with a custom async engine, a hot-swappable adapter layer, the trust loop (visited-only citations, URL liveness verification, quote anchoring, citation ledger), and the two seed methodologies (Rapid Answer, Deep Report).
## Objective (two-milestone plan, owner-directed 2026-09-27)
1. **Milestone v0.1 — Prototype**: UI/UX only, mock data, the production Next.js frontend with all data behind a typed `ApiClient` interface backed by `MockApiClient`. Surfaces: Ask, Choose (all 8 methodology cards), Plan & Run, Reading Room v0, v2 surface mockups (project graph, task inbox). **Ships only after owner approval — no MVP work begins until approved.**
2. **Milestone v0.2 — MVP**: gated on prototype approval. FastAPI backend, custom async engine, adapter layer, trust loop, and integration (MockApiClient swapped for the OpenAPI-generated client).
## Requirements (validated)
1. Five pluggable adapter slots: model (OpenAI-compatible, planner/writer split), storage (local FS), search (Tavily default, DuckDuckGo keyless fallback), memory (off — stubbed), secrets (env — OpenBao later)
2. Hardware-aware defaults: probe backend, cap requests conservatively (8192 context default, embedding batch 8), surface overrides in UI, degrade gracefully
3. 12-dial methodology framework (typed schema, every run persists resolved dial values for reproducibility/auditability)
4. Methodology registry shipping Rapid Answer (depth 1/breadth 1/memo/per-claim citations) and Deep Report (depth 3/breadth 2/executive report/full verification); 8-methodology catalog locked for v1, phased rollout
4. Methodology registry shipping Rapid Answer (depth 1/breadth 1/memo/per-claim citations) and Deep Report (depth 3/breadth 2/executive report/full verification); 8-methodology catalog locked, phased rollout
5. Custom async engine: state machine PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → SYNTHESIZING → VERIFYING → DONE; durable JSON state; pause/cancel/resume; typed event bus → SSE live trace
6. Trust loop in every methodology: visited-only constraint at generation time; URL liveness pass with live/stale/dead classification; claim-to-passage quote anchoring; citation ledger JSON per run; exportable verification checklist
7. API surface: POST /api/runs, GET /api/methodologies, run lifecycle (approve/pause/cancel/resume), SSE events, config status; OpenAPI-generated typed frontend client
@@ -21,12 +22,14 @@ Build the 3-click happy path end-to-end: Ask → Choose (methodology cards) →
9. Testing: fake model/search adapters enabling zero-API-key engine tests; adversarial hallucinated-URL and quote-mismatch detection tests targeting ≥99% citation liveness
10. Tech stack: FastAPI (Python 3.12, uv, pydantic) + Next.js App Router (TypeScript strict)
## Requirements (active, later phases)
## Requirements (active, later milestones)
- Remaining 6 methodologies: SOTA Survey, Systematic Review (PRISMA-like), Fact-Check Dossier, Multi-Perspective Dossier, Competitive Landscape, Corpus Interrogation
- Reading Room full: project graph, global search, task inbox, confidence labels, BibTeX/PDF export
- Memory scopes (semantic caching), S3/git storage adapters, Exa/Perplexity/Google search, OpenBao secrets, Docker packaging
## Constraints
- Prototype must be approved before MVP work begins (owner directive)
- Versioning starts at v0.1; MAJOR remains 0 until an owner-approved v1 release; all pre-v1 ships are MINOR/PATCH only (owner directive)
- Not a general-purpose chatbot; not a model host; not a closed content index (v1)
- Paywalled academic access out of scope v1 — open APIs (ArXiv/PubMed) only
- Single-user, local-first tool — no auth/multi-tenancy in MVP
@@ -37,7 +40,7 @@ Build the 3-click happy path end-to-end: Ask → Choose (methodology cards) →
- Paywalled academic sources; model hosting; multi-tenant/auth; native Ollama API adapter; domain registration/rename decision
## Context
Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 core), product named ResearchX, OpenBao retained as architectural secrets component, criticism matrix and evidence base [E1]–[E15] on file. MVP plan approved this session: phased slices — slice 0 scaffolding, slice 1 core loop (this milestone), slices 2–4 extend catalog/adapters/infrastructure.
Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 core), product named ResearchX, OpenBao retained as architectural secrets component, criticism matrix and evidence base [E1]–[E15] on file. Owner revisions 2026-09-27: (1) prototype-first — UI/UX prototype with mock data must be approved before MVP work begins; (2) version starts at v0.1, MAJOR remains 0 until MVP released; (3) the prototype is its own milestone. Milestone plan: v0.1 prototype (this milestone) → v0.2 MVP (gated).
## Key Decisions
- Methodology-first selection is the core differentiator, not raw model quality — converts ad-hoc prompt-crafting into structured, reproducible selection
@@ -45,4 +48,8 @@ Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 cor
- Progressive disclosure resolves granular-vs-simple tension — defaults on the surface, dials on demand
- Custom async engine (hand-rolled asyncio state machine) over LangGraph — full control over pause/resume, durable state, trace streaming; framework lock-in avoided
- NotebookLM-style source grounding kept; source-blindness removed via quality annotation
- Model adapter default: Ollama cloud-first (ollama.com/v1) with keyless localhost:11434/v1 fallback on auth/connection failure
- Model adapter default: Ollama cloud-first (ollama.com/v1) with keyless localhost:11434/v1 fallback on auth/connection failure
- Prototype-first gate (owner): UI/UX prototype with mock data, approved before MVP work; prototype is its own milestone (v0.1)
- Versioning (owner): starts at v0.1; MAJOR locked at 0 until owner-approved v1 release; MVP may span multiple 0.x minor milestones
- Prototype = production Next.js frontend with typed ApiClient/MockApiClient split — zero throwaway work; phase 4 of v0.2 swaps the mock for the OpenAPI client
- Tag-line mapping: 0.x milestones run patches on their own minor line (v0.1.x); supersedes the greenfield deviation noted at init
+52 -24
View File
@@ -1,6 +1,35 @@
# Requirements
## V1 (Milestone v1.0 — Core Loop)
## Prototype (Milestone v0.1 — UI/UX with mock data)
### Scaffold & Mock Boundary
- **REQ-52**: Next.js App Router scaffold, TypeScript strict, Tailwind, Vitest; typecheck/lint/test wired from phase 1
- **REQ-53**: Typed `ApiClient` interface (all frontend data access behind it) + `MockApiClient` implementation with fixture data and scripted event replay; no backend, no API keys, runs standalone
- **REQ-54**: Prototype = production frontend, zero throwaway: v0.2 phase 3 swaps `MockApiClient` for the OpenAPI-generated client with no component rewrites
### Core Surfaces
- **REQ-55**: Ask screen: one question box + one optional context box
- **REQ-56**: Choose screen: all 8 locked methodology cards (Rapid Answer, Deep Report, SOTA Survey, Systematic Review, Fact-Check Dossier, Multi-Perspective Dossier, Competitive Landscape, Corpus Interrogation) with mock ETA, cost estimate, why-recommended; advanced dials drawer behind "advanced" (progressive disclosure)
- **REQ-57**: Plan & Run screen: plan approval UI (sub-questions, source universe, outline), live scripted trace stream, pause/cancel controls
- **REQ-58**: Reading Room v0: report reader with page-anchored citations (hover shows the exact quoted passage), citation ledger panel
- **REQ-59**: Functional exports: real downloadable Markdown/JSON files generated from mock data; citations survive as real links
### Vision Validation & Gate
- **REQ-60**: v2 surface mockups: project graph and task inbox panels rendered from mock data
- **REQ-61**: ≤3-click happy path on defaults (Ask → Choose → Run); typography care pass per vision §4
- **REQ-62**: Owner approval gate: prototype ships only after recorded owner approval (prototype_approval escalation hook); MVP milestone v0.2 begins only after v0.1.4 is tagged — **APPROVED 2026-09-27: owner approval recorded on v0.1.8 (extended prototype); v0.2 unblocked**
### App Shell Extension (P5 — owner gate feedback, directed 2026-09-27)
- **REQ-63**: Persistent app shell on all screens: sidebar (primary nav + future-option slots) and top bar (search/command-palette slot + avatar button); shell adds zero required clicks to the ≤3-click happy path
- **REQ-64**: Research library: reports stored in a folder-structure view, auto-categorized (topic + methodology), sortable by recency/title; cross-report connection drawing — project graph links reports to each other (shared-source and topical edges)
- **REQ-65**: Settings surface behind the ApiClient boundary: API keys (model/search, masked), storage endpoint (local directory), methodology defaults (resolved dial display), appearance slot, about/version
- **REQ-66**: Avatar button (top bar) opening a user menu: local single-user profile (no auth), links to settings and about; future slots visible but honest (preview-marked)
### Ask Intelligence Extension (P7 — owner gate feedback, directed 2026-09-27)
- **REQ-67**: New Research view surfaces follow-up query suggestions derived from unanswered questions (open task-inbox items from past runs); one click loads a suggestion into the question box for editing — never auto-starts a run
- **REQ-68**: Refine option on the question: an opt-in back-and-forth dialogue (clarifying questions → answers → composed refined question + suggested context); accepting fills the form, or the original is kept
## MVP (Milestone v0.2 — core loop, gated on prototype approval)
### Methodology Framework
- **REQ-01**: Typed pydantic schema for the 12 methodology dials (source universe, search routing, query strategy, breadth, depth, quality gates, citation rigor, verification passes, synthesis style, output structure, memory scope, checkpoints), each with a safe default value
@@ -23,7 +52,7 @@
- **REQ-14**: Plan = sub-questions + source universe + outline; run halts at AWAITING_APPROVAL until approved or edited
- **REQ-15**: Durable run state persisted as JSON after each state transition; pause/cancel/resume works across process restarts
- **REQ-16**: Typed event bus → SSE live trace (query fired, source visited, passage extracted); every run records a trace file
- **REQ-17**: New user → research running in ≤3 clicks using defaults only
- **REQ-17**: New user → research running in ≤3 clicks using defaults only (validated on the real stack in v0.2 after prototype REQ-61)
### Trust Loop
- **REQ-18**: Generation-time visited-only constraint: writer context contains only stored evidence passages with stable IDs [E1]
@@ -34,13 +63,9 @@
- **REQ-23**: ≥99% of citations in shipped reports pass the liveness check
- **REQ-24**: Confidence labels: single-source vs corroborated claims
### API & Frontend
### API & Integration
- **REQ-25**: API: POST /api/runs; GET /api/methodologies (cards with ETA, cost estimate, why-recommended); GET /api/runs/{id}; approve/pause/cancel/resume; GET /api/runs/{id}/events (SSE); GET /api/config (adapter status + probe results)
- **REQ-26**: OpenAPI schema generates a typed frontend client
- **REQ-27**: Ask screen: one question box + one optional context box
- **REQ-28**: Choose screen: 3–5 ranked methodology cards with ETA/cost/why; dials behind "advanced" (progressive disclosure)
- **REQ-29**: Plan & Run screen: approved plan display, live interruptible trace, pause/cancel controls
- **REQ-30**: Reading Room v0: page-anchored citations (hover shows exact quoted passage), ledger panel, Markdown/JSON export with citations surviving as real links
- **REQ-26**: OpenAPI schema generates a typed frontend client; v0.2 P3 swaps MockApiClient for it behind the unchanged ApiClient interface
### Testing & Infrastructure
- **REQ-31**: Fake model + fake search adapters (canned planner/writer, fixture corpus) → full engine tests with zero API keys
@@ -48,7 +73,7 @@
- **REQ-33**: Monorepo: uv-managed backend (ruff, mypy, pytest) + Next.js frontend (ESLint, strict TS, Vitest); one-command install/dev
- **REQ-34**: Full export round-trip: report + ledger + citations survive as real links
## V2 (Post-v1.0)
## V2 (Post-MVP backlog)
### Methodology Catalog Extension
- **REQ-35**: SOTA Survey (academic+web routing, outline-first encyclopedic synthesis)
@@ -83,20 +108,23 @@
| Auth / multi-tenancy | Single-user local-first MVP |
| Native Ollama API adapter | Ollama supported via OpenAI-compatible endpoint only |
| Domain registration / product rename | Deferred by owner (vision §11) |
| v1.0.0 release | Owner-approved release only; not created autonomously by the pipeline |
## Traceability
| Requirement | Phase | Status |
|---|---|---|
| REQ-01..06, REQ-08..11 (framework, adapters) | P1 | pending |
| REQ-31 (fake adapters) | P1 | pending |
| REQ-07, REQ-12 (probing, warnings) | P2 | pending |
| REQ-13..17 (engine) | P2 | pending |
| REQ-18..24 (trust loop) | P2 | pending |
| REQ-25 (API) | P3 | pending |
| REQ-26 (typed client) | P3 | pending |
| REQ-33 (monorepo tooling) | P3 | pending |
| REQ-27..30 (frontend) | P4 | pending |
| REQ-17 (≤3 clicks) | P4 | pending |
| REQ-32, REQ-34 (adversarial, round-trip) | P5 | pending |
| All REQ-01..34 (milestone gate) | P6 | pending |
| REQ-35..51 | v2 backlog (next milestone) | pending |
| Requirement | Milestone | Phase | Status |
|---|---|---|---|
| REQ-52 (scaffold) | v0.1 | P1 | complete |
| REQ-53, REQ-54 (mock boundary) | v0.1 | P1 | complete |
| REQ-55, REQ-56 (Ask, Choose) | v0.1 | P1 | complete |
| REQ-57 (Plan & Run) | v0.1 | P2 | complete |
| REQ-58, REQ-59 (Reading Room, exports) | v0.1 | P2 | complete |
| REQ-60, REQ-61 (v2 mockups, 3-click) | v0.1 | P3 | complete |
| REQ-62 (approval gate) | v0.1 | P8 | complete (owner approval recorded 2026-09-27) |
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | complete |
| REQ-67, REQ-68 (ask suggestions, refine dialogue) | v0.1 | P7 | complete |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | blocked |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | blocked |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | blocked |
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | blocked |
| All REQ-01..34 (milestone gate) | v0.2 | P5 | blocked |
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
+100 -39
View File
@@ -1,58 +1,119 @@
# Roadmap — Milestone v1.0 (MVP Core Loop)
# Roadmap — Milestone v0.1 (Prototype) and v0.2 (MVP)
## Overview
Milestone v1.0 delivers the ResearchX 3-click happy path: Ask → Choose methodology → Plan & Run, powered by a custom async engine, hot-swappable adapters, the trust loop, and the two seed methodologies (Rapid Answer, Deep Report). Five execution phases follow phase 0. Per branch-strategy tag rules, phase tags run on the previous minor's patch line: phase 0 ships as v0.9.0, execution phases as v0.9.1–v0.9.5, and the final phase's patch tag (v0.9.6) IS the milestone release — no separate minor tag is created. Post-v1.0 methodology catalog and infrastructure extensions (REQ-35..51) are tracked as v2 backlog.
Two milestones, owner-directed 2026-09-27. **Milestone v0.1 — Prototype**: the UI/UX with mock data, built as the production Next.js frontend behind a typed `ApiClient`/`MockApiClient` boundary. It ships only after owner approval (prototype_approval escalation hook — the HITL gate that overrides full autonomy for this one checkpoint). **Milestone v0.2 — MVP**: the FastAPI backend, custom async engine, adapter layer, and trust loop, gated on prototype approval. Versioning: tags run on each 0.x milestone's own minor line (v0.1.x for milestone v0.1 — greenfield deviation from the "previous minor's patch line" rule, which has no previous line to run on; the convention applies unmodified from milestone v0.3 onward). MAJOR remains 0 until an owner-approved v1 release; the MVP may span multiple 0.x minors if scope requires. Feature milestone: each phase ships a patch; the final phase's patch IS the milestone release; no separate minor tag.
## Phases
## Milestone v0.1 — Prototype (feature)
### Phase 0 — Pre-Execution (in progress)
- **Description**: Specification parsing, clarification, .ciagent/ reference files (PROJECT, ARCHITECTURE, REQUIREMENTS, ROADMAP), initial commit. Vision locked: 8-methodology catalog (composition frozen), MVP plan approved (custom async engine, FastAPI + Next.js).
### Phase 0 — Pre-Execution (complete — shipped v0.1.0)
- **Description**: Specification parsing, clarification, research, planning. Bootstrap revision correcting v1.0 bookkeeping to the owner-directed two-milestone v0.1/v0.2 structure. Vision locked: 8-methodology catalog (composition frozen); prototype-first gate; v0.1 versioning.
- **Requirements covered**: none (planning artifacts)
- **Success criteria**: .ciagent/ files committed on phase/00-pre-execution; milestone v1.0 established in config.json and PROJECT.md; decisions logged in init commit
- **Success criteria**: .ciagent/ files reflect the two-milestone structure; prototype requirements (REQ-52..61) defined and traceable; milestone v0.1 established in config.json and PROJECT.md; PLAN.md + PERSONAS.md committed; phases tagged v0.1.0
- **Depends on**: —
- **Status**: in_progress
- **Status**: complete (squash-merged to milestone/v0.1-prototype, tag v0.1.0, release created)
### Phase 1 — Frontend Scaffold + Ask + Choose (complete — shipped v0.1.1)
- **Description**: Next.js App Router scaffold (TypeScript strict, Tailwind, Vitest); typed `ApiClient` interface + `MockApiClient` (fixture data, scripted event replay); Ask screen (question + optional context); Choose screen with all 8 locked methodology cards (mock ETA/cost/why-recommended, dial summary) and the advanced dials drawer (progressive disclosure).
- **Requirements covered**: REQ-52, REQ-53, REQ-54, REQ-55, REQ-56
- **Success criteria**: `pnpm dev` renders Ask → Choose with 8 cards from mock data; dials drawer opens/closes; typecheck + lint + tests green
- **Depends on**: 0
- **Status**: complete (PR #1 squash-merged to milestone/v0.1-prototype, tag v0.1.1, release created)
### Phase 2 — Plan & Run + Reading Room v0 (complete — shipped v0.1.2)
- **Description**: Plan & Run screen (plan approval UI, scripted live trace stream, pause/cancel controls); Reading Room v0 (report reader, page-anchored citations with hover-to-quote, citation ledger panel, functional Markdown/JSON exports from mock data).
- **Requirements covered**: REQ-57, REQ-58, REQ-59
- **Success criteria**: scripted run replays end-to-end with live trace; hover shows exact quoted passage; exports download real files with citations as links
- **Depends on**: 1
- **Status**: complete (PR #2 squash-merged, tag v0.1.2, release created; P1-3/P1-4 review flags closed, four new P1s fixed in-phase)
### Phase 3 — v2 Surface Mockups + Polish + 3-Click Validation (complete — shipped v0.1.3)
- **Description**: Project graph and task inbox panels as visual mockups (v2 vision validation); typography care pass (reader experience per vision §4); ≤3-click happy-path validation on defaults.
- **Requirements covered**: REQ-60, REQ-61
- **Success criteria**: graph + inbox render from mock data; 3-click path verified; typography pass done
- **Depends on**: 2
- **Status**: complete (PR #3 squash-merged, tag v0.1.3, release created; two P1 fixed in-phase)
### Phase 4 — Review + Owner Approval Gate + Ship (shipped v0.1.4 — superseded by extension)
- **Description**: Review + audit, then the **owner approval gate**: prototype presented for owner review; approval recorded as a binding decision commit. Only after approval: milestone merge to main, tag v0.1.4 — the prototype release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: milestone gate (REQ-52..61 approved)
- **Success criteria**: owner approval recorded; v0.1.4 tagged; milestone branches deleted per ship workflow
- **Depends on**: 3
- **Status**: shipped (PR #4 squash-merged, tag v0.1.4 + release created; review 5 P0s fixed; audit PASS; owner returned gate feedback instead of approve/reject → binding extension cycle P5/P6 before the merge to main)
### Phase 5 — App Shell + Research Library + Settings + Avatar (complete — shipped v0.1.5)
- **Description**: Persistent app shell on every screen: sidebar (nav + future-option slots: connection status, methodology-defaults preview) + top bar (⌘K navigation palette slot + avatar button with user menu). Research library upgraded: folder-structure report storage with auto-categorization, sorting, and cross-report connection drawing in the project graph. Settings screen: API keys (masked), storage endpoint, methodology defaults, appearance slot, about/version. All data through the ApiClient boundary (mock-backed); zero added clicks to the ≤3-click happy path.
- **Requirements covered**: REQ-63, REQ-64, REQ-65, REQ-66
- **Success criteria**: shell renders on all screens with sidebar + top bar + avatar menu; library shows folder tree + sortable report cards + cross-report graph edges; settings sections all functional on mock data; 3-click path pinned green post-shell; gates green
- **Depends on**: 4
- **Status**: complete (PR #5 squash-merged to milestone/v0.1-prototype, tag v0.1.5, release created; verify PASS with one P1 fixed in-phase — main landmark restored)
### Phase 6 — Final Review + Approval Re-presentation + Ship (shipped v0.1.6 — superseded by 2nd extension)
- **Description**: Multi-persona review of the full milestone diff (now including P5), audit, then re-present the **extended prototype** at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.6 — the milestone release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..66
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.6 tagged + release; pipeline halts at the approval gate for the owner's recorded decision
- **Depends on**: 5
- **Status**: shipped (PR #6 squash-merged, tag v0.1.6 + release created; review PASS-WITH-FIXES — deep-link P0 + 5 P1s fixed; audit PASS; owner directed UAT deploy then returned 2nd gate feedback → binding extension cycle P7/P8; milestone merge to main stays halted)
### Phase 7 — Ask Screen Intelligence: Suggestions + Refine (complete — shipped v0.1.7)
- **Description**: New Research view gains follow-up query suggestions derived from unanswered questions (open task-inbox items joined with their source reports); one click fills the question box — editable, never auto-starts. An opt-in Refine dialogue (inline drawer, one clarifying question at a time, scripted in the prototype) composes a refined question + suggested context; accept fills the form, or keep the original. Both are opt-in affordances — zero required clicks added to the ≤3-click happy path. All data through the ApiClient boundary.
- **Requirements covered**: REQ-67, REQ-68
- **Success criteria**: suggestions render from open inbox items with source attribution; click fills the box (no auto-submit); refine drawer completes a scripted exchange and composes a refined question; both flows keyboard-accessible; 3-click path pinned green; gates green
- **Depends on**: 6
- **Status**: complete (PR #7 squash-merged to milestone/v0.1-prototype, tag v0.1.7, release created; verify PASS — 2 P1 fixes with regression tests: Enter-guard defense in depth, focus return to trigger)
### Phase 8 — Final Review + Approval Re-presentation + Ship (FINAL — complete)
- **Description**: Multi-persona review of the full milestone diff (now including P7), audit, then re-present the extended prototype at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.8 — the milestone release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..68
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.8 tagged + release; UAT staged per owner directive; pipeline halts at the approval gate
- **Depends on**: 7
- **Status**: complete (PR #8 squash-merged, tag v0.1.8 + release; review PASS-WITH-FIXES, audit PASS-WITH-NOTES; **owner approval RECORDED 2026-09-27** — decision commit 3fd0e10; milestone merged to main; v0.2 unblocked)
## Milestone v0.1 — COMPLETE (approved 2026-09-27)
All requirements complete: REQ-52..62, REQ-63..68. The approved
prototype release is **v0.1.8** (final phase patch). Milestone branch
merged to main and deleted — tags v0.1.0–v0.1.8 preserve the full
history. Next milestone: v0.2 MVP (unblocked).
## Milestone v0.2 — MVP (feature) — BLOCKED until prototype approval
Phases renumbered from the original v1.0 plan (1-based within the milestone):
### Phase 0 — Pre-Execution (v0.2)
- **Description**: Milestone pre-execution re-run: PLAN.md refresh against approved prototype decisions (layout learnings, API shape implied by the ApiClient interface), research on any gaps surfaced by approval feedback.
- **Status**: blocked (gate: v0.1.4)
### Phase 1 — Methodology Framework + Adapter Layer
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters for tests.
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters.
- **Requirements covered**: REQ-01..06, REQ-08..11, REQ-31
- **Success criteria**: registry resolves both methodologies from dial bundles; fake adapters drive contract tests; model probe caps requests and surfaces overrides; zero-API-key test suite green
- **Depends on**: 0
- **Status**: not_started
- **Status**: blocked
### Phase 2 — Research Engine + Trust Loop
- **Description**: Asyncio state machine with durable JSON state and pause/cancel/resume; plan approval gate; typed event bus → SSE; evidence store with stable passage IDs; trust loop (visited-only constraint, URL liveness pass, quote anchoring, citation ledger, verification checklist, confidence labels).
- **Requirements covered**: REQ-07, REQ-12, REQ-13..24
- **Success criteria**: engine completes depth-loop runs on fake adapters; run state survives process restart mid-run; hallucinated-URL and quote-mismatch adversarial tests pass; ≥99% citation liveness on fixture corpus
- **Depends on**: 1
- **Status**: not_started
- **Status**: blocked
### Phase 3 — API Layer + Typed Client
- **Description**: FastAPI routes (runs, methodologies, lifecycle, SSE events, config status); OpenAPI-generated typed frontend client; ETA/cost estimation heuristics for methodology cards.
### Phase 3 — API Layer + Integration
- **Description**: FastAPI routes (runs, methodologies, lifecycle, SSE events, config status); OpenAPI-generated typed client; **swap MockApiClient → generated client** (the ApiClient interface absorbs any prototype-era API drift); ETA/cost heuristics for methodology cards.
- **Requirements covered**: REQ-25, REQ-26
- **Success criteria**: full run lifecycle drivable over HTTP; SSE trace streams live; generated client typechecks strict
- **Depends on**: 2
- **Status**: not_started
- **Status**: blocked
### Phase 4 — Frontend (Ask / Choose / Plan & Run / Reading Room v0)
- **Description**: Next.js App Router screens: Ask, Choose (progressive disclosure dials), Plan & Run (live trace, pause/cancel), Reading Room v0 (page-anchored citations, ledger panel, Markdown/JSON export). ≤3-click happy path with defaults.
- **Requirements covered**: REQ-27..30, REQ-17
- **Success criteria**: new user reaches a running research session in ≤3 clicks on defaults; citations hover to exact quoted passages; export round-trips (REQ-34)
- **Depends on**: 3
- **Status**: not_started
### Phase 5 — Hardening + Adversarial Suite + E2E
### Phase 4 — Hardening + Adversarial Suite + E2E
- **Description**: Adversarial test suite (hallucinated-URL writer, quote-anchor mismatch, pause/resume mid-depth-loop), E2E happy-path (Playwright), monorepo tooling polish (one-command install/dev, lint/typecheck/test green).
- **Requirements covered**: REQ-32, REQ-33, REQ-34
- **Success criteria**: adversarial suite green; E2E green; README quickstart works from clean clone
- **Depends on**: 4
- **Status**: not_started
- **Requirements covered**: REQ-17, REQ-32, REQ-33, REQ-34
- **Status**: blocked
### Phase 6 — Final Review + Ship
- **Description**: REVIEW + SHIP + AUDIT of milestone v1.0: requirements traceability verification, milestone merge to main, release. (Final phase is numeric: 6 execution phases → this is the 6th; merge/tag per branch-strategy.)
- **Requirements covered**: milestone gate (all REQ-01..34 verified)
- **Success criteria**: all v1 requirements complete or explicitly deferred with rationale; milestone tag created; release notes summarize milestone
- **Depends on**: 5
- **Status**: not_started
### Phase 5 — Final Review + Ship (FINAL)
- **Description**: Review + audit + milestone ship; final patch tag IS the MVP release. MAJOR stays 0 — the MVP ships as a 0.x release. v1.0.0 comes only after an owner-approved release decision.
- **Status**: blocked
## Milestone Releases
- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
- **v0.1.5 = app shell + research library + settings + avatar** (P5 patch)
- **v0.1.6 = extended-prototype candidate** (superseded by the P7/P8 extension cycle)
- **v0.1.7 = ask suggestions + refine dialogue** (P7 patch)
- **v0.1.8 = milestone release** (P8 final phase patch — the approved extended prototype release, pending owner decision)
- **v0.2.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
- If MVP scope extends: additional 0.x minor milestones (v0.3, …), each a full milestone cycle
- **v1.0.0**: owner-approved release only — not created by the pipeline autonomously
## Milestone Release
Feature milestone (contains feat phases) → next minor tag on milestone completion, per branch-strategy tag rules. v2 backlog (REQ-35..51) starts the next milestone cycle.
+19 -2
View File
@@ -2,11 +2,18 @@
"ci_version": "0.7.0",
"projects": [],
"active_project": "researchx",
"milestone": "v1.0",
"milestone": "v0.2",
"milestone_name": "mvp",
"milestone_type": "feature",
"versioning": {
"major_locked": true,
"major_lock_note": "MAJOR remains 0 until an owner-approved v1 release; the MVP may span multiple 0.x minor milestones; all pre-v1 ships are MINOR/PATCH only",
"tag_line": "v0.2.x",
"next_milestone": "v0.3+ (post-MVP backlog milestones)"
},
"autonomy": {
"level": "full",
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
"escalation_hooks": ["deploy", "delete_data", "merge_to_main", "prototype_approval"],
"decision_confidence_threshold": 0.6,
"max_revision_iterations": 3,
"clarify_budget": 10
@@ -17,6 +24,16 @@
"owner": "coreci",
"repo": "researchx"
},
"ship": {
"per_phase": true,
"allow_skip": false,
"max_release_retries": 3,
"release_blocking": false
},
"personas": {
"enabled": true,
"territory_enforcement": "warn"
},
"secrets": {
"env_file": ".ciagent/.env.secrets",
"scopes": {
+26
View File
@@ -0,0 +1,26 @@
# Deploy Record — UAT stagings of the v0.1 prototype
Owner-directed UAT deployments, recorded per commit discipline. The
approval gate (REQ-62) stays closed; UAT variants stage review builds
without minting release tags.
| Date | Version | Commit | Directive | Result |
|---|---|---|---|---|
| 2026-09-27 | v0.1.4-uat | d15568f (P3 ship) | owner review staging (P4) | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | v0.1.6-uat | 600b49c (P6 ship) | owner: "push updates to lxc container" | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | v0.1.7-uat | 432d2c0 (P7 ship + boundary docs) | owner cadence: deploy after P7 and P8 | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | v0.1.8-uat | 93a7b57 (P8 ship — milestone release candidate) | owner cadence: deploy after P7 and P8 | healthy — http://10.0.0.114:3000 |
## v0.1.6-uat (extended prototype for owner review)
- Gates re-run at deploy time from the milestone HEAD: typecheck, lint,
test (75/75), build — all green before sync
- Image `researchx:v0.1.6-uat` built on the host; container swapped with
`researchx-prev` kept for rollback
- Health check passed (2 retries); smoke verified: /ask /library
/settings all 200; shell chrome (sidebar nav, ⌘K slot, avatar) present
in server-rendered HTML
- Rollback: `ssh root@10.0.0.114 'docker stop researchx; docker rename
researchx-prev researchx; docker start researchx'`
- The approval gate remains closed: milestone merge to main still halts
for the recorded owner decision (REQ-62)
+58
View File
@@ -0,0 +1,58 @@
# CIAgent Release System — Docker Deployment
Codified release workflow: builds the ResearchX frontend as a
production Next.js standalone image and deploys it to the Docker host
via SSH. Designed for repeatable, parameterized future releases.
## Components
| File | Purpose |
|---|---|
| `frontend/Dockerfile` | Multi-stage build: pnpm install → build → distroless-standalone runtime (node:22-alpine) |
| `.ciagent/release/deploy.sh` | One-command release: sync source → remote build → run container. Idempotent |
| `.ciagent/release/config.sh` | Non-secret parameters (host, port, image name). Secrets come ONLY from `.ciagent/.env.secrets` |
| `.ciagent/release/VERSIONING.md` | Release/version rules for future releases |
## Secrets discipline
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_PASSWORD` live ONLY in
`.ciagent/.env.secrets` (0600, gitignored). The deploy script reads
them from there — never from shell environment, never committed.
Non-secret host parameters (ports, paths) live in `config.sh`.
## Usage
```bash
bash .ciagent/release/deploy.sh <version>
```
Examples:
```bash
bash .ciagent/release/deploy.sh v0.1.5 # deploy tagged version
bash .ciagent/release/deploy.sh v0.2.1 # future releases
```
## What a release does
1. Validate version tag exists locally (or fetch from origin)
2. Verify gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build`
3. Export a build bundle of the frontend source at that tag
4. SSH (via `sshpass`) to the Docker host with credentials from `.env.secrets`
5. `rsync` the bundle up; remote `docker build` the standalone image
6. Stop any previous `researchx` container; run the new one on the configured port
7. Health-check the container; report the URL
## Rollback
```bash
ssh <host> docker stop researchx && docker start researchx-prev
```
The previous container is kept as `researchx-prev` (renamed on each
release) for one-step rollback.
## Versioning rules
See `VERSIONING.md`. Tags follow the milestone patch line (v0.1.x for
milestone v0.1). The `latest` tag may also be pushed for convenience
but is never the source of truth.
+52
View File
@@ -0,0 +1,52 @@
# Release Versioning — ResearchX
## Tags are the release source of truth
Every release deploys a **git tag**, never a branch head. Tags follow
the milestone patch line (v0.1.x for milestone v0.1, v0.2.x for v0.2),
as established by the CIAgent branch strategy and `config.json`.
| Milestone | Tags | Deployed by |
|---|---|---|
| v0.1 prototype | v0.1.0 … v0.1.8 | `bash .ciagent/release/deploy.sh v0.1.8` |
| v0.2 MVP | v0.2.0 … | `bash .ciagent/release/deploy.sh v0.2.x` |
## Rules
1. **A release = a tag.** No tag, no deploy. The script refuses
untagged versions.
2. **Gates run at the tag.** typecheck, lint, test, and build all run
against the tagged tree (in a temporary worktree if HEAD has moved
past the tag) — the same ship-workflow rule that no failing code
ships, applied to deployment.
3. **Rolling, idempotent swap.** Re-deploying a version replaces the
running container. The previous container is preserved as
`researchx-prev` for one-step rollback. Two consecutive deploys of
the same version keep the older one as rollback.
4. **Image tags.** Each deploy tags the image with the version AND
`latest`. The version tag is authoritative; `latest` is a pointer
of convenience only.
5. **Secrets never in git.** `DEPLOY_HOST`, `DEPLOY_USER`,
`DEPLOY_PASSWORD` live only in `.ciagent/.env.secrets` (0600,
gitignored). Host/port/path parameters are non-secret and live in
`deploy.sh`'s parameter block.
6. **Milestone gating applies to deployment too.** The v0.1 prototype
deploys for owner review. v0.2 releases deploy only after the
REQ-62 owner-approval gate is cleared (recorded on the extended
prototype, v0.1.7 → released as v0.1.8).
## Container conventions
- Image: `researchx:<version>`, container: `researchx`, rollback:
`researchx-prev`, host port: 3000, remote tree: `/opt/researchx/releases/<version>`.
- The container is `--restart unless-stopped`; a host reboot brings
the release back automatically.
## Adding a future release
```bash
# after a phase ships its tag:
bash .ciagent/release/deploy.sh v0.2.0
```
No script changes needed — parameters are at the top of `deploy.sh`.
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# ResearchX release deploy (ciagent release system)
#
# Builds the frontend at a given version tag on the Docker host and
# runs it as a container. Idempotent: re-deploying a version replaces
# the running container, keeping the previous one as rollback.
#
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
#
# Secrets are read ONLY from .ciagent/.env.secrets:
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_PASSWORD
# Never from the shell environment, never committed (v1.8 rule).
#
# Exit codes: 0 success · 1 usage · 2 missing deps/tag/secrets ·
# 3 gates failed · 4 sync/build failed · 5 run/health failed
set -euo pipefail
# ---- parameters (non-secret) ------------------------------------------
IMAGE_NAME="researchx"
CONTAINER_NAME="researchx"
CONTAINER_PREV="researchx-prev"
HOST_PORT="3000"
REMOTE_BASE="/opt/researchx"
HEALTH_PATH="/ask"
HEALTH_RETRIES=15
HEALTH_INTERVAL=2
# -----------------------------------------------------------------------
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SECRETS_FILE="$REPO_ROOT/.ciagent/.env.secrets"
log() { printf '[release] %s\n' "$*"; }
die() { printf '[release] ERROR: %s\n' "$*" >&2; exit "$2"; }
[ "$#" -eq 1 ] || die "usage: bash .ciagent/release/deploy.sh <version> (e.g. v0.1.4)" 1
VERSION="$1"
# ---- dependency checks --------------------------------------------------
command -v ssh >/dev/null || die "ssh not found" 2
# ---- secrets (env file ONLY) -------------------------------------------
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
DEPLOY_HOST=$(grep -E "^DEPLOY_HOST=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_USER=$(grep -E "^DEPLOY_USER=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_PASSWORD=$(grep -E "^DEPLOY_PASSWORD=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_KEY=$(grep -E "^DEPLOY_KEY=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
[ -n "$DEPLOY_HOST" ] && [ -n "$DEPLOY_USER" ] \
|| die "DEPLOY_HOST/DEPLOY_USER not set in $SECRETS_FILE" 2
# Auth: an SSH key (DEPLOY_KEY=path in .env.secrets) is preferred —
# it survives sshd 'PermitRootLogin prohibit-password' policies that
# reject root passwords. Password auth (DEPLOY_PASSWORD) is fallback.
SSH_AUTH_DIR=$(mktemp -d)
trap 'rm -rf "$SSH_AUTH_DIR"' EXIT
if [ -n "$DEPLOY_KEY" ] && [ -f "$DEPLOY_KEY" ]; then
SSH_TRANSPORT="ssh-key:${DEPLOY_KEY##*/}"
SSH() { ssh -i "$DEPLOY_KEY" -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
else
[ -n "$DEPLOY_PASSWORD" ] \
|| die "neither DEPLOY_KEY (existing file) nor DEPLOY_PASSWORD set in $SECRETS_FILE" 2
if command -v sshpass >/dev/null; then
SSH_TRANSPORT="sshpass"
SSH() { sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
else
SSH_TRANSPORT="ssh-askpass"
cat > "$SSH_AUTH_DIR/askpass.sh" <<ASKPASS
#!/usr/bin/env bash
printf '%s\n' "\$DEPLOY_PASSWORD"
ASKPASS
chmod 700 "$SSH_AUTH_DIR/askpass.sh"
SSH() {
DEPLOY_PASSWORD="$DEPLOY_PASSWORD" SSH_ASKPASS="$SSH_AUTH_DIR/askpass.sh" \
SSH_ASKPASS_REQUIRE=force DISPLAY=:0 \
ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"
}
fi
fi
log "ssh transport: $SSH_TRANSPORT"
if command -v rsync >/dev/null; then
SYNC_TRANSPORT="rsync"
else
SYNC_TRANSPORT="tar-ssh"
fi
# Sync the build context to the host (excludes live in the Dockerfile's
# .dockerignore; we only prune heavy dirs here). rsync uses the SSH
# function's transport implicitly via -e; tar-over-ssh uses SSH directly.
sync_source() {
local src="$1" dst="$2"
if [ "$SYNC_TRANSPORT" = "rsync" ]; then
if [[ "$SSH_TRANSPORT" == ssh-key:* ]]; then
rsync -az --delete --exclude node_modules --exclude .next --exclude .git \
-e "ssh -i '$DEPLOY_KEY' -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
"$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
elif [ "$SSH_TRANSPORT" = "sshpass" ]; then
sshpass -p "$DEPLOY_PASSWORD" rsync -az --delete \
--exclude node_modules --exclude .next --exclude .git \
-e "ssh -o StrictHostKeyChecking=accept-new" "$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
else
DEPLOY_PASSWORD="$DEPLOY_PASSWORD" SSH_ASKPASS="$SSH_AUTH_DIR/askpass.sh" \
SSH_ASKPASS_REQUIRE=force DISPLAY=:0 \
rsync -az --delete --exclude node_modules --exclude .next --exclude .git \
-e "ssh -o StrictHostKeyChecking=accept-new" "$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
fi
else
# tar-over-ssh: pipe a pruned tarball through the same SSH auth path
(cd "$src" && tar --exclude=node_modules --exclude=.next --exclude=.git -cf - .) \
| SSH "mkdir -p '$dst' && rm -rf '$dst'/* && tar -xf - -C '$dst'"
fi
}
# ---- tag validation ------------------------------------------------------
log "validating version $VERSION"
git -C "$REPO_ROOT" fetch origin --tags --prune 2>/dev/null || true
# A plain tag (v0.1.4) deploys exactly that commit. A "<tag>-uat"
# variant (v0.1.4-uat) deploys the milestone HEAD that tag lives on —
# used to stage review infra (e.g. the Dockerfile the tag predates)
# without minting a new release tag. UAT builds are clearly labeled.
UAT_BUILD=false
BASE_VERSION="$VERSION"
if [[ "$VERSION" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+)-uat$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
UAT_BUILD=true
fi
git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$BASE_VERSION" >/dev/null \
|| die "tag $BASE_VERSION not found locally or on origin" 2
if [ "$UAT_BUILD" = true ]; then
# Deploy the milestone branch HEAD carrying this tag (review staging).
# Branch labels use the MILESTONE number (v0.1), tags the full patch
# version (v0.1.4) — strip the patch segment for the branch lookup.
MILESTONE_VER="${BASE_VERSION%.*}" # v0.1.4 -> v0.1
MILESTONE_BRANCH=$(git -C "$REPO_ROOT" branch -r --list "origin/milestone/${MILESTONE_VER}-*" | head -1 | tr -d ' ')
[ -n "$MILESTONE_BRANCH" ] || die "no milestone branch found for $BASE_VERSION (looked for origin/milestone/${MILESTONE_VER}-*)" 2
TAG_COMMIT=$(git -C "$REPO_ROOT" rev-parse "${MILESTONE_BRANCH#origin/}" 2>/dev/null || git -C "$REPO_ROOT" rev-parse "$MILESTONE_BRANCH")
log "UAT build: milestone HEAD $TAG_COMMIT (tag $BASE_VERSION + review infra)"
else
TAG_COMMIT=$(git -C "$REPO_ROOT" rev-list -n1 "$BASE_VERSION")
log "tag $BASE_VERSION -> commit $TAG_COMMIT"
fi
# ---- gates (ship rule: never release failing code) ----------------------
log "running release gates at $VERSION"
export PATH="$HOME/.corepack/bin:$PATH"
cd "$REPO_ROOT/frontend"
git -C "$REPO_ROOT" worktree list >/dev/null 2>&1 || true
if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
# Build from the tagged tree in a temporary worktree — HEAD may be ahead.
log "HEAD differs from tag; building from a temporary worktree at $VERSION"
WORKTREE=$(mktemp -d)
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
BUILD_DIR="$WORKTREE/frontend"
else
BUILD_DIR="$REPO_ROOT/frontend"
fi
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
log "gates passed: typecheck, lint, test, build"
cd "$REPO_ROOT"
# ---- remote host prep ----------------------------------------------------
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
# ---- sync source ---------------------------------------------------------
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|| die "source sync failed" 4
# ---- remote build --------------------------------------------------------
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "docker build failed on host" 4
# ---- swap container ------------------------------------------------------
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
docker rename '$CONTAINER_NAME' '$CONTAINER_PREV'
docker stop '$CONTAINER_PREV' >/dev/null 2>&1 || true
fi
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
" || die "docker run failed" 5
# ---- health check ---------------------------------------------------------
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
log "healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
exit 0
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
+53
View File
@@ -0,0 +1,53 @@
# ResearchX
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source.
**Status: prototype (milestone v0.1)** — UI/UX with mock data. No backend, no API keys, no network calls.
## Quickstart
```bash
cd frontend
pnpm install
pnpm dev
```
Open http://localhost:3000 — you land on **Ask**, inside the app shell (left sidebar, top bar with ⌘K search and your avatar). Type a question, click **Research this**, pick a methodology on **Choose** (Deep Report is the recommended default), and press **Start research**.
Three clicks from cold start to initiated research.
## What's in the prototype
| Screen | What it does |
|---|---|
| `/ask` | One question box + one optional context box — plus two opt-in helpers below and beside them: open-question suggestions (follow-ups from your past runs, one click fills the box) and the Refine question dialogue (a few clarifying answers → a composed, sharper question). Neither ever auto-starts. |
| `/choose` | All 8 locked methodology cards — mock ETA, cost estimate, why-recommended, and the 12 methodology dials behind each card's Advanced drawer. |
| `/run/[id]` | Plan & Run: approve the research plan (sub-questions, source universe, outline), watch the live trace stream, pause/resume or cancel. |
| `/run/[id]/report` | Reading Room: the report with page-anchored citations — hover or focus any `[n]` marker for the exact quoted passage — plus the citation ledger and Markdown/JSON exports. |
| `/library` | Stored reports filed into auto-categorized folders (sortable, links into the Reading Room), the Connections panel (shared sources ⇄, related topics →), plus v2-preview project graph and task inbox. |
| `/settings` | API-key status (masked, never raw), the reports storage directory, methodology defaults with the resolved 12 dials, appearance, and about/version. |
The persistent shell (every screen): sidebar nav + adapter connection-status dots, top bar with the ⌘K navigation palette (screens + stored reports) and the avatar menu (local profile, Settings, About).
All data flows through a typed `ApiClient` interface backed by `MockApiClient` — the same components will talk to the FastAPI backend via the OpenAPI-generated client in the MVP milestone, with zero rewrites.
## Scripts
Run inside `frontend/`:
| Command | Purpose |
|---|---|
| `pnpm dev` | Dev server on :3000 |
| `pnpm build` | Production build |
| `pnpm lint` | ESLint (flat config) |
| `pnpm typecheck` | `tsc --noEmit` (strict) |
| `pnpm test` | Vitest + Testing Library (jsdom) |
## Repository layout
```
frontend/ Next.js 15 App Router, TypeScript strict, Tailwind v4, Vitest
.ciagent/ Pipeline state (roadmap, requirements, plans, checkpoints)
```
Milestone v0.2 (MVP: FastAPI backend, research engine, adapter layer, trust loop) begins only after this prototype is approved.
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.env
.env.*
!.env.example
*.test.ts
*.test.tsx
npm-debug.log*
Dockerfile
.dockerignore
README.md
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.next/
out/
coverage/
*.tsbuildinfo
next-env.d.ts
.DS_Store
+36
View File
@@ -0,0 +1,36 @@
# ResearchX frontend — production release image (ciagent release system)
# Multi-stage: deps -> build -> runtime. Next.js standalone output.
# Built ON the Docker host from the synced source (no local docker required).
# ---- deps ----
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- build ----
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# ---- runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S researchx && adduser -S researchx -G researchx
# Standalone output: the server + minimal node_modules only.
COPY --from=build --chown=researchx:researchx /app/.next/standalone ./
COPY --from=build --chown=researchx:researchx /app/.next/static ./.next/static
USER researchx
EXPOSE 3000
CMD ["node", "server.js"]
+104
View File
@@ -0,0 +1,104 @@
# ResearchX Frontend
The prototype frontend for ResearchX — methodology-driven deep research
with a trust loop. This milestone (v0.1) runs **entirely on mock data**:
no API keys, no env vars, no network calls beyond static assets.
## Quickstart
Prerequisites: **Node 20+** and **pnpm** (any recent version; the repo was
built with pnpm 12).
```bash
cd frontend
pnpm install
pnpm dev
```
Open <http://localhost:3000> — you'll land on `/ask`, inside the app
shell (left sidebar for navigation, top bar with search and your avatar).
The happy path:
1. Type a question (context is optional) → **Research this**
2. Pick a methodology on `/choose` — Deep Report is the recommended
default; open **Advanced** on any card to see its 12 dials
3. **Start research** → `/run/[id]`: review the plan (sub-questions,
source universe, outline), **Approve plan**, and watch the scripted
live trace stream in — **Pause/Resume** or **Cancel** at any time
4. When the run completes → **Read the report** → `/run/[id]/report`:
the Reading Room renders the report with page-anchored citations —
hover or focus any `[n]` marker to see the exact quoted passage —
plus the citation ledger and **Export Markdown/JSON** buttons that
download real files with citations as real links
The shell adds zero required clicks to that path. Everything else it
hosts is wayfinding and accumulated research:
- **Open questions** (`/ask`, below the form) — follow-up suggestions
derived from unanswered questions of your past runs; one click loads
one into the question box for editing (never auto-starts).
- **Refine question** (`/ask`, beside the box) — an opt-in back-and-forth:
a few clarifying questions, then a composed refined question with
suggested context. Use it, or keep your original. Scripted in the
prototype — model-driven refine lands with v0.2.
- **Library** (`/library`) — every report you've generated, filed into
auto-categorized folders; sort by recency or title; open any card to
re-enter its Reading Room. The **Connections** panel draws the links
between reports — shared sources (⇄) and adjacent topics (→). The
project graph and task inbox remain v2 previews.
- **Settings** (`/settings`) — API-key status (masked, never raw),
the local directory reports are stored in, the default methodology
and the 12 dials it resolves to, appearance, and about/version.
- **⌘K** (top bar) — jump to any screen or stored report. Navigation
only; full search arrives with the v0.2 engine.
- **Avatar** (top bar, right) — local single-user profile, links to
Settings and About.
## Scripts
| Script | What it does |
| --- | --- |
| `pnpm dev` | Start the Next.js dev server on port 3000 |
| `pnpm build` | Production build |
| `pnpm start` | Serve the production build |
| `pnpm lint` | ESLint (flat config, eslint-config-next) |
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
| `pnpm test` | Vitest run (jsdom + Testing Library) |
| `pnpm test:watch` | Vitest in watch mode |
## Architecture notes
- **All data access goes through the typed `ApiClient` interface**
(`src/lib/api/client.ts`) — components import the `api` singleton and
never touch `MockApiClient` directly. v0.2 phase 3 swaps the binding
to the OpenAPI-generated backend client with no component rewrites.
- The mock layer lives in `src/lib/api/mock/`: fixture data for all 8
locked methodologies, a scripted Deep Report run (trace events,
report with `[^passageId]` citation markers, citation ledger), the
stored-reports library with folders and cross-report connections,
adapter connection status, settings, and the local user profile.
- The app shell (sidebar + top bar) lives in the root layout
(`src/app/layout.tsx`); every screen renders inside it.
- Design tokens (editorial type scale, warm paper palette, spacing) are
defined as Tailwind v4 `@theme` tokens in `src/app/globals.css`.
- Question + context survive the `/ask` → `/choose` navigation via a
small module-level store (`src/lib/ask-store.ts`).
## Project layout
```
src/
app/ App Router routes (ask, choose, run/[id], run/[id]/report,
library, settings) — all inside the shell layout
components/ UI components (shell chrome, screens, tests co-located)
lib/ Domain types, ApiClient boundary, mock data, stores
test/ Test harness setup
```
Prototype scope note: ETAs, cost estimates, the scripted run, the stored
reports, connection edges, and adapter statuses are realistic fixture
data — not live research. The trace stream replays a deterministic Deep
Report run with pause/resume/cancel controls; the Reading Room's report,
ledger, and exports are generated from the same fixture. API-key entry,
live adapter probing, and full search land with the v0.2 backend.
+25
View File
@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"next-env.d.ts",
"coverage/**",
],
},
];
export default eslintConfig;
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Prototype runs entirely on mock data — no API routes, no network calls
// beyond static assets. Keep the surface minimal and deterministic.
// Standalone output for the Docker release image (ciagent release system).
output: "standalone",
};
export default nextConfig;
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@researchx/frontend",
"version": "0.1.8",
"private": true,
"packageManager": "pnpm@12.6.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"next": "15.5.6",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.7",
"@tailwindcss/postcss": "4.1.18",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "6.10.0",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@types/node": "24.3.1",
"@types/react": "19.2.5",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.7.0",
"eslint": "^9.39.5",
"eslint-config-next": "15.5.6",
"globals": "^17.0.0",
"jsdom": "26.1.0",
"tailwindcss": "4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.1",
"vitest": "3.2.7"
}
}
+5778
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
sharp: true
unrs-resolver: true
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+33
View File
@@ -0,0 +1,33 @@
import { AskView } from "@/components/ask-view";
/**
* /ask (T-3.1, REQ-55; T-7.2/7.3, REQ-67/68) — the front door: one
* question box, one optional context box, one primary action. The
* opt-in affordances live below and beside the form — open-question
* suggestions and the refine dialogue — never competing with it.
* The app shell (sidebar + top bar) lives in the root layout.
*/
export default function AskPage() {
return (
<div className="mx-auto flex max-w-3xl flex-col px-6 py-section">
<header className="mb-block">
<h1 className="text-display font-semibold text-ink">
Ask a question.
<br />
Get a report you can trust.
</h1>
<p className="measure-reading mt-4 text-lead text-ink-soft">
ResearchX picks a research methodology, hunts down sources, and
writes a fully-cited report — with every claim anchored to the exact
passage it came from.
</p>
</header>
<AskView />
<p className="mt-block text-caption text-ink-faint">
Prototype: runs on scripted mock data — no API keys, no live search.
</p>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ChooseView } from "@/components/choose-view";
import { api } from "@/lib/api/client";
/**
* /choose (T-3.2, REQ-56) — the methodology chooser.
*
* Server component: fetches the catalog through the typed ApiClient
* singleton (bound to MockApiClient in v0.1) and hands it to the
* client view. Interactive bits (drawer, start button) live below.
*/
export default async function ChoosePage() {
const methodologies = await api.getMethodologies();
return <ChooseView methodologies={methodologies} />;
}
+126
View File
@@ -0,0 +1,126 @@
@import "tailwindcss";
/* ------------------------------------------------------------------
* ResearchX design tokens (D-018 — "vision §4" typography care)
* Tailwind v4 @theme: every token becomes both a CSS variable and a
* utility namespace (text-*, bg-*, border-* …).
* ------------------------------------------------------------------ */
@theme {
/* ---- Type scale -------------------------------------------------
* Editorial scale: 12 / 14 / 16 / 18 / 21 / 28 / 38 / 50.
* Display sizes lead tight (1.05–1.15); body leads airy (1.6–1.7).
* Reading measure is capped at 68ch (see --measure-reading below).
*/
--text-caption: 0.75rem; /* 12px — metadata, badges */
--text-caption--line-height: 1.5;
--text-caption--letter-spacing: 0.01em;
--text-small: 0.875rem; /* 14px — secondary body */
--text-small--line-height: 1.6;
--text-body: 1rem; /* 16px — default body */
--text-body--line-height: 1.6;
--text-lead: 1.125rem; /* 18px — emphasized body */
--text-lead--line-height: 1.65;
--text-title: 1.3125rem; /* 21px — card headings */
--text-title--line-height: 1.3;
--text-headline: 1.75rem; /* 28px — section headings */
--text-headline--line-height: 1.2;
--text-display: 2.375rem; /* 38px — screen titles */
--text-display--line-height: 1.1;
--text-display--letter-spacing: -0.015em;
--text-masthead: 3.125rem; /* 50px — hero question */
--text-masthead--line-height: 1.05;
--text-masthead--letter-spacing: -0.02em;
/* ---- Color ------------------------------------------------------ */
/* Warm paper + near-black ink + one deep-green accent.
* Semantic aliases below keep components away from raw hexes.
*/
--color-paper: #faf9f6; /* page background */
--color-paper-raised: #ffffff; /* cards, drawers */
--color-paper-sunken: #f1efe9; /* wells, input backgrounds */
--color-ink: #1a1a1a; /* primary text */
--color-ink-soft: #4a4945; /* secondary text */
--color-ink-faint: #8a8880; /* muted metadata */
--color-line: #e5e2da; /* hairline borders */
--color-hairline: #e5e2da; /* border-hairline utility */
--color-accent: #1e5c3f; /* deep green — primary action */
--color-accent-strong: #164732; /* hover / pressed */
--color-accent-wash: #eaf1ed; /* soft accent tint */
--color-accent-ink: #0f3a27; /* accent text on wash */
--color-success: #1d6f42; /* verification: live */
--color-success-wash: #e7f2ea;
--color-warn: #92610a; /* verification: stale */
--color-warn-wash: #f9efdc;
--color-danger: #a52a2a; /* verification: dead */
--color-danger-wash: #f8e8e8;
--color-info: #2b5c8a; /* neutral status / plan chips */
--color-info-wash: #e8eef5;
/* ---- Spacing & radius ------------------------------------------- */
--spacing-gutter: 1.5rem; /* card grid gutter */
--spacing-block: 2rem; /* between major blocks */
--spacing-section: 4rem; /* between screen sections */
--radius-card: 0.75rem;
--radius-pill: 9999px;
/* ---- Motion ------------------------------------------------------ */
--ease-out-soft: cubic-bezier(0.25, 0.6, 0.25, 1);
}
/* ------------------------------------------------------------------
* Semantic CSS variables — the runtime contract components rely on.
* ------------------------------------------------------------------ */
:root {
--measure-reading: 68ch; /* max line length for reading text */
--measure-narrow: 44ch; /* forms, helper copy */
--surface-page: var(--color-paper);
--surface-raised: var(--color-paper-raised);
--text-primary: var(--color-ink);
--text-secondary: var(--color-ink-soft);
--text-muted: var(--color-ink-faint);
--border-hairline: var(--color-line);
}
body {
background: var(--surface-page);
color: var(--text-primary);
font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Reading containers opt in to the measure cap + airy leading. */
.measure-reading {
max-width: var(--measure-reading);
}
.measure-narrow {
max-width: var(--measure-narrow);
}
/* Visible focus ring for keyboard reachability (UX criterion). */
:where(a, button, input, textarea, summary):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
/**
* Root layout (T-5.2, REQ-63) — the persistent app shell.
*
* Every screen renders inside the shell: left sidebar (nav + future
* slots) and top bar (⌘K palette + avatar). The shell adds zero
* required clicks to the happy path — it is pure wayfinding chrome.
* Page content lands in one <main> landmark per route (a11y).
*/
export const metadata: Metadata = {
title: "ResearchX",
description:
"Methodology-driven deep research with a trust loop — prototype on mock data.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
</body>
</html>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { LibraryView } from "@/components/library-view";
export const metadata: Metadata = {
title: "Library — ResearchX",
};
export default function LibraryPage() {
return (
<div className="mx-auto max-w-6xl px-6 py-section">
<LibraryView />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
/**
* Root route (T-3.4, REQ-52) — the front door is /ask.
* One question box lives there; the prototype has no landing page.
*/
export default function Home() {
redirect("/ask");
}
+21
View File
@@ -0,0 +1,21 @@
import { RunView } from "@/components/run-view";
/**
* /run/[id] (T-2.4, REQ-57) — the Plan & Run screen.
*
* Server shell for the run stage: plan approval, the live scripted
* trace, and the terminal-state onward links. Run data flows through
* the ApiClient boundary inside RunView.
*/
export default async function RunPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="mx-auto max-w-3xl px-6 py-section">
<RunView id={id} />
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ReadingRoom } from "@/components/reading-room";
export default async function ReportPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="mx-auto max-w-6xl px-6 py-section">
<ReadingRoom id={id} />
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { SettingsView } from "@/components/settings-view";
export const metadata: Metadata = {
title: "Settings — ResearchX",
};
export default function SettingsPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-section">
<SettingsView />
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* App-shell chrome tests (T-5.2, REQ-63, REQ-66).
*
* Sidebar: nav entries render, connection dots hydrate from the
* boundary. Top bar: ⌘K opens the palette, entries navigate, avatar
* opens the user menu. The shell adds zero required clicks to the
* happy path — that criterion stays pinned in three-click-path.test.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
import { CommandPalette } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/ask",
}));
afterEach(() => {
pushMock.mockClear();
cleanup();
});
describe("AppSidebar (REQ-63)", () => {
it("renders primary nav with the three entries", () => {
render(<AppSidebar />);
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeTruthy();
expect(screen.getByRole("link", { name: "New research" })).toHaveAttribute(
"href",
"/ask",
);
expect(screen.getByRole("link", { name: "Library" })).toHaveAttribute(
"href",
"/library",
);
expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute(
"href",
"/settings",
);
});
it("hydrates all five adapter connection dots", async () => {
render(<AppSidebar />);
const list = await screen.findByRole("list", {
name: "Adapter connection status",
});
expect(list).toBeTruthy();
for (const label of ["Model", "Search", "Storage", "Memory", "Secrets"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("marks the active nav entry with aria-current", () => {
render(<AppSidebar />);
expect(screen.getByRole("link", { name: "New research" })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: "Library" })).not.toHaveAttribute(
"aria-current",
);
});
});
describe("AppTopBar (REQ-63, REQ-66)", () => {
it("shows the search slot with its ⌘K affordance", () => {
render(<AppTopBar />);
expect(
screen.getByRole("button", { name: /Search or jump to/i }),
).toBeInTheDocument();
expect(screen.getByText("⌘K")).toBeInTheDocument();
});
it("⌘K opens the palette with screens + stored reports", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.keyboard("{Meta>}k{/Meta}");
const dialog = await screen.findByRole("dialog", { name: "Search or jump to" });
expect(dialog).toBeTruthy();
// Screen entries
expect(screen.getByText("New research — Ask")).toBeInTheDocument();
expect(screen.getByText("Library")).toBeInTheDocument();
// Stored-report entries hydrate from the boundary
expect(
await screen.findByText("Solid-State Battery Advances: A Deep Report"),
).toBeInTheDocument();
});
it("avatar button opens the user menu with profile + links", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.click(screen.getByRole("button", { name: "User menu" }));
const menu = await screen.findByRole("menu", { name: "User menu" });
expect(menu).toBeTruthy();
expect(screen.getByText("Researcher")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Settings" })).toHaveAttribute(
"href",
"/settings",
);
expect(
screen.getByRole("menuitem", { name: "About & version" }),
).toHaveAttribute("href", "/settings#about");
});
it("Escape closes the user menu", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.click(screen.getByRole("button", { name: "User menu" }));
await screen.findByRole("menu", { name: "User menu" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu", { name: "User menu" })).toBeNull();
});
it("clicking the avatar again closes the menu (toggle, not reopen)", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
const avatar = screen.getByRole("button", { name: "User menu" });
await user.click(avatar);
await screen.findByRole("menu", { name: "User menu" });
await user.click(avatar);
expect(screen.queryByRole("menu", { name: "User menu" })).toBeNull();
});
});
describe("CommandPalette (REQ-63)", () => {
const entries = [
{ kind: "screen" as const, label: "New research — Ask", href: "/ask" },
{ kind: "screen" as const, label: "Library", href: "/library" },
{
kind: "report" as const,
label: "Lithium Refining Capacity Through 2030",
href: "/run/run-materials/report",
},
];
it("filters entries by query", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<CommandPalette entries={entries} onNavigate={onNavigate} onDismiss={() => {}} />,
);
await user.type(screen.getByRole("textbox", { name: "Search or jump to" }), "lith");
expect(screen.getByText("Lithium Refining Capacity Through 2030")).toBeInTheDocument();
expect(screen.queryByText("New research — Ask")).toBeNull();
});
it("Enter navigates to the highlighted entry", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<CommandPalette entries={entries} onNavigate={onNavigate} onDismiss={() => {}} />,
);
const input = screen.getByRole("textbox", { name: "Search or jump to" });
await user.type(input, "library");
await user.keyboard("{Enter}");
expect(onNavigate).toHaveBeenCalledWith("/library");
});
it("shows an honest empty state", async () => {
const user = userEvent.setup();
render(
<CommandPalette entries={entries} onNavigate={() => {}} onDismiss={() => {}} />,
);
await user.type(
screen.getByRole("textbox", { name: "Search or jump to" }),
"zzzz",
);
expect(screen.getByText(/Nothing matches/i)).toBeInTheDocument();
});
});
describe("UserMenu (REQ-66)", () => {
it("renders the local profile from the boundary", async () => {
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
});
});
+134
View File
@@ -0,0 +1,134 @@
"use client";
/**
* AppSidebar (T-5.2, REQ-63) — the persistent left rail.
*
* Primary nav (New research, Library, Settings) + future-option slots:
* adapter connection-status dots (v0.2 preview) and honest preview
* pills where a slot is not yet functional. Server-renderable static
* structure; the connection dots hydrate from the ApiClient boundary.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { api, type ConnectionSlot } from "@/lib/api/client";
type SlotsState =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; slots: ConnectionSlot[] };
/** One nav entry — active state derives from the current pathname. */
function NavItem({
href,
label,
exact,
}: {
href: string;
label: string;
exact: boolean;
}) {
const pathname = usePathname() ?? "";
const active = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`block rounded-card px-3 py-2 text-small font-medium transition-colors ${
active
? "bg-accent-wash text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken hover:text-ink"
}`}
>
{label}
</Link>
);
}
/** Status dot + label for one adapter slot (v0.2 preview). */
function ConnectionDot({ slot }: { slot: ConnectionSlot }) {
const dotColor =
slot.status === "connected"
? "bg-success"
: slot.status === "fallback"
? "bg-warn"
: "bg-ink-faint";
return (
<li className="flex items-center gap-2" title={slot.detail}>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-pill ${dotColor}`}
/>
<span className="text-caption text-ink-soft">{slot.label}</span>
{slot.status === "preview" && (
<span className="rounded-full border border-hairline px-1.5 text-caption text-ink-faint">
preview
</span>
)}
</li>
);
}
export function AppSidebar() {
const [slots, setSlots] = useState<SlotsState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
api
.getConnectionStatus()
.then((result) => {
if (!cancelled) setSlots({ kind: "ready", slots: result });
})
.catch(() => {
if (!cancelled) setSlots({ kind: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<aside
aria-label="Primary"
className="flex w-60 shrink-0 flex-col gap-6 border-r border-hairline bg-paper-raised px-4 py-6"
>
<Link href="/ask" className="px-3">
<p className="text-small font-semibold tracking-wide text-ink">
ResearchX
</p>
<p className="text-caption text-ink-faint">Deep-research studio</p>
</Link>
<nav aria-label="Main navigation" className="flex flex-col gap-1">
<NavItem href="/ask" label="New research" exact />
<NavItem href="/library" label="Library" exact />
<NavItem href="/settings" label="Settings" exact />
</nav>
<div>
<p className="px-3 text-caption font-medium uppercase tracking-wide text-ink-faint">
Connections
</p>
{slots.kind === "ready" ? (
<ul className="mt-2 space-y-1.5" aria-label="Adapter connection status">
{slots.slots.map((slot) => (
<ConnectionDot key={slot.slot} slot={slot} />
))}
</ul>
) : (
<p className="mt-2 px-3 text-caption text-ink-faint">
{slots.kind === "loading" ? "Checking…" : "Status unavailable"}
</p>
)}
<p className="mt-2 px-3 text-caption text-ink-faint">
Adapter health lands in v0.2
</p>
</div>
<p className="mt-auto px-3 text-caption text-ink-faint">
v0.1 · prototype — mock data
</p>
</aside>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
/**
* AppTopBar (T-5.2, REQ-63, REQ-66) — the persistent top rail.
*
* Left: search/⌘K slot opening the navigation palette (screens + stored
* reports — navigation only; full search ships in v0.2). Right: avatar
* button opening the user menu (profile, Settings, About). All data
* flows through the ApiClient boundary; the menu is keyboard-accessible
* (Escape closes, focus ring per global standard).
*/
import { usePathname, useRouter } from "next/navigation";
import { forwardRef, useEffect, useRef, useState } from "react";
import {
api,
type ReportLibrary,
type UserProfile,
} from "@/lib/api/client";
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
export function AppTopBar() {
const router = useRouter();
const pathname = usePathname() ?? "";
const [paletteOpen, setPaletteOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [library, setLibrary] = useState<ReportLibrary | null>(null);
const avatarRef = useRef<HTMLButtonElement | null>(null);
// ⌘K / Ctrl-K opens the palette from anywhere in the app.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((open) => !open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Stored reports feed the palette's report section.
useEffect(() => {
let cancelled = false;
api
.getReportLibrary()
.then((result) => {
if (!cancelled) setLibrary(result);
})
.catch(() => {
/* palette falls back to screen entries when the library is unavailable */
});
return () => {
cancelled = true;
};
}, []);
const paletteEntries: PaletteEntry[] = [
{ kind: "screen", label: "New research — Ask", href: "/ask" },
{ kind: "screen", label: "Library", href: "/library" },
{ kind: "screen", label: "Settings", href: "/settings" },
...(library?.reports ?? []).map((report) => ({
kind: "report" as const,
label: report.title,
href: `/run/${report.runId}/report`,
})),
];
return (
<header className="sticky top-0 z-20 flex items-center gap-4 border-b border-hairline bg-paper-raised/95 px-6 py-3 backdrop-blur">
<p className="text-caption font-medium uppercase tracking-wide text-ink-faint">
{pathLabel(pathname)}
</p>
<div className="ml-auto flex items-center gap-3">
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="flex items-center gap-2 rounded-card border border-hairline bg-paper-sunken px-3 py-1.5 text-caption text-ink-faint transition-colors hover:border-ink-faint"
aria-label="Search or jump to — Command K"
>
<span aria-hidden="true">⌕</span>
<span>Search or jump to…</span>
<kbd className="rounded border border-hairline bg-paper-raised px-1.5 font-sans text-caption text-ink-faint">
⌘K
</kbd>
</button>
<AvatarButton
ref={avatarRef}
menuOpen={menuOpen}
onToggle={() => setMenuOpen((v) => !v)}
/>
</div>
{menuOpen && (
<UserMenu
toggleRef={avatarRef}
onNavigate={() => setMenuOpen(false)}
onDismiss={() => setMenuOpen(false)}
/>
)}
{paletteOpen && (
<CommandPalette
entries={paletteEntries}
onNavigate={(href) => {
setPaletteOpen(false);
router.push(href);
}}
onDismiss={() => setPaletteOpen(false)}
/>
)}
</header>
);
}
/** The avatar disc — initials from the profile fixture. */
const AvatarButton = forwardRef<HTMLButtonElement, {
menuOpen: boolean;
onToggle: () => void;
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
const [profile, setProfile] = useState<UserProfile | null>(null);
useEffect(() => {
let cancelled = false;
api
.getUserProfile()
.then((result) => {
if (!cancelled) setProfile(result);
})
.catch(() => {
/* menu renders a fallback profile when unavailable */
});
return () => {
cancelled = true;
};
}, []);
return (
<button
ref={ref}
type="button"
onClick={onToggle}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="User menu"
className="flex h-9 w-9 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised transition-colors hover:bg-accent-strong"
>
{profile?.initials ?? "··"}
</button>
);
});
/** Screen label shown top-left — quiet wayfinding, not a breadcrumb. */
function pathLabel(pathname: string): string {
if (pathname === "/ask") return "New research";
if (pathname.startsWith("/choose")) return "Choose methodology";
if (pathname === "/library") return "Library";
if (pathname === "/settings") return "Settings";
const runMatch = pathname.match(/^\/run\/([^/]+)/);
if (runMatch) {
return pathname.endsWith("/report") ? "Reading room" : "Plan & run";
}
return "ResearchX";
}
@@ -0,0 +1,88 @@
"use client";
/**
* AskSuggestions (T-7.2, REQ-67) — open questions worth following up.
*
* Follow-up query suggestions derived from unanswered questions of
* past runs, behind the ApiClient boundary. Clicking a card fills the
* question box (editable — never auto-submits). Sits below the primary
* form action; the honest empty state explains how items collect here.
*/
import { useEffect, useState } from "react";
import { api, type SuggestedQuery } from "@/lib/api/client";
type SuggestionsState =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; suggestions: SuggestedQuery[] };
export function AskSuggestions({ onPick }: { onPick: (question: string) => void }) {
const [state, setState] = useState<SuggestionsState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
api
.getSuggestedQueries()
.then((suggestions) => {
if (!cancelled) setState({ kind: "ready", suggestions });
})
.catch(() => {
if (!cancelled) setState({ kind: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<section
aria-label="Open questions from your research"
className="mt-block"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Pick up an open question
</h2>
{state.kind === "loading" && (
<p className="mt-2 text-caption text-ink-faint">Checking your research…</p>
)}
{state.kind === "error" && (
<p className="mt-2 text-caption text-ink-faint">
Suggestions unavailable right now.
</p>
)}
{state.kind === "ready" && state.suggestions.length === 0 && (
<p className="measure-reading mt-2 text-caption text-ink-faint">
No open questions yet — they collect here as you run research.
</p>
)}
{state.kind === "ready" && state.suggestions.length > 0 && (
<>
<p className="mt-1 text-caption text-ink-soft">
Unanswered questions from your past runs — one click loads them
here for editing.
</p>
<ul className="mt-3 space-y-2">
{state.suggestions.map((suggestion) => (
<li key={suggestion.id}>
<button
type="button"
onClick={() => onPick(suggestion.question)}
className="w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-left transition-colors hover:border-ink-faint"
>
<p className="measure-reading text-small text-ink">
{suggestion.question}
</p>
<p className="mt-1 text-caption text-ink-faint">
From your research — {suggestion.sourceReportTitle}
</p>
</button>
</li>
))}
</ul>
</>
)}
</section>
);
}
+319
View File
@@ -0,0 +1,319 @@
/**
* AskView tests (T-7.2/T-7.3, REQ-55, REQ-67, REQ-68).
*
* - Submit behavior: stores question + context, routes to /choose
* - Suggestions: render from open questions with attribution; a click
* fills the question box (editable, NO auto-submit)
* - Refine: disabled without a draft; the exchange completes and
* "Use this" fills question + context; "Keep original"/Escape leaves
* the draft untouched; Enter advances the exchange exactly once and
* never submits the outer form; Enter on drawer buttons activates
* them normally; focus returns to the trigger on close
*/
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AskView } from "@/components/ask-view";
import { api } from "@/lib/api/client";
import { clearAskState, getAskState } from "@/lib/ask-store";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
describe("AskView", () => {
beforeEach(() => {
pushMock.mockClear();
clearAskState();
});
afterEach(cleanup);
it("submits: stores question + context and routes to /choose", async () => {
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries?",
);
await user.type(
screen.getByLabelText(/anything else it should know\?/i),
"Focus on automotive applications",
);
await user.click(screen.getByRole("button", { name: "Research this" }));
const carried = getAskState();
expect(carried?.question).toBe(
"What are the latest advances in solid-state batteries?",
);
expect(carried?.context).toBe("Focus on automotive applications");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/choose");
});
});
it("suggestions: open questions render with source attribution", async () => {
render(<AskView />);
// Open inbox items from the fixtures (3 open of 4 total).
expect(
await screen.findByText(
"Do sulfide electrolytes outperform oxide ceramics below room temperature?",
),
).toBeInTheDocument();
// Both run-solid-state suggestions attribute to the same source report.
expect(
screen.getAllByText(/From your research — Solid-State Battery Advances/i)
.length,
).toBe(2);
// The done item never appears as a suggestion.
expect(
screen.queryByText(
"How do 2027 deployment targets compare across Japanese and EU consortia?",
),
).toBeNull();
});
it("suggestion click fills the question box — no auto-submit", async () => {
const user = userEvent.setup();
render(<AskView />);
const card = await screen.findByRole("button", {
name: /Which solid-state startups have shipped pilot cells to automakers\?/i,
});
await user.click(card);
// The question box now holds the suggestion — editable.
const box = screen.getByLabelText(/what do you want to know\?/i);
expect(box).toHaveValue(
"Which solid-state startups have shipped pilot cells to automakers?",
);
// No navigation fired — the user stays in control.
expect(pushMock).not.toHaveBeenCalled();
});
it("refine: disabled without a draft, opens with one", async () => {
const user = userEvent.setup();
render(<AskView />);
const refine = screen.getByRole("button", { name: /Refine question/i });
expect(refine).toBeDisabled();
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
expect(refine).toBeEnabled();
await user.click(refine);
expect(
await screen.findByRole("region", { name: /Refine your question/i }),
).toBeInTheDocument();
});
it("refine: full exchange composes; Use this fills the form", async () => {
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
await screen.findByRole("region", { name: /Refine your question/i });
// Q1 (required): scope.
const answer1 = await screen.findByLabelText(/What specific part of this topic matters most to you\?/i);
await user.type(answer1, "automotive applications");
await user.click(screen.getByRole("button", { name: "Next" }));
// Q2 (optional): decision.
const answer2 = await screen.findByLabelText(/What decision or action will this research inform\?/i);
await user.type(answer2, "choosing a supplier for a 2027 EV program");
await user.click(screen.getByRole("button", { name: "Next" }));
// Q3 (optional): depth.
const answer3 = await screen.findByLabelText(/How deep should the answer go/i);
await user.type(answer3, "an executive summary with verifiable evidence");
await user.click(screen.getByRole("button", { name: "Compose question" }));
// Composition preview appears.
expect(
await screen.findByText(/— focusing on automotive applications/i),
).toBeInTheDocument();
// Use this → fills question + context, closes the drawer.
await user.click(screen.getByRole("button", { name: "Use this" }));
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"What are the latest advances in solid-state batteries — focusing on automotive applications",
);
const contextValue = screen.getByLabelText(
/anything else it should know\?/i,
);
expect(contextValue).toHaveValue(
"Feeds this decision: choosing a supplier for a 2027 EV program. Depth wanted: an executive summary with verifiable evidence. Refined through the question dialogue.",
);
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
});
it("refine: optional questions are skippable and compose still works", async () => {
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"Who makes solid-state cells today",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
await screen.findByRole("region", { name: /Refine your question/i });
await user.type(
await screen.findByLabelText(/What specific part of this topic matters most to you\?/i),
"pilot production",
);
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(await screen.findByRole("button", { name: "Skip" }));
await user.click(await screen.findByRole("button", { name: "Compose question" }));
expect(
await screen.findByText(/— focusing on pilot production/i),
).toBeInTheDocument();
// Skipped decision answer does not appear in the composed context.
expect(screen.queryByText(/Feeds this decision/i)).toBeNull();
});
it("refine: Keep original leaves the draft untouched", async () => {
const user = userEvent.setup();
render(<AskView />);
const DRAFT = "What are the latest advances in solid-state batteries";
await user.type(screen.getByLabelText(/what do you want to know\?/i), DRAFT);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
await screen.findByRole("region", { name: /Refine your question/i });
// Answer partway, then keep the original.
await user.type(
await screen.findByLabelText(/What specific part of this topic matters most to you\?/i),
"automotive applications",
);
await user.click(screen.getByRole("button", { name: "Keep original" }));
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(DRAFT);
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
});
it("refine: Escape closes the drawer without touching the draft", async () => {
const user = userEvent.setup();
render(<AskView />);
const DRAFT = "How good are solid-state batteries";
await user.type(screen.getByLabelText(/what do you want to know\?/i), DRAFT);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
await screen.findByRole("region", { name: /Refine your question/i });
await user.keyboard("{Escape}");
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(DRAFT);
});
it("refine: Enter advances the exchange, never submits the outer form", async () => {
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
const answer = await screen.findByLabelText(/What specific part of this topic matters most to you\?/i);
await user.type(answer, "automotive applications");
// Enter inside the drawer (which lives in the outer <form>) must
// advance the exchange — not submit and navigate to /choose.
await user.keyboard("{Enter}");
expect(pushMock).not.toHaveBeenCalled();
expect(
await screen.findByLabelText(/What decision or action will this research inform\?/i),
).toBeInTheDocument();
});
it("refine: focus returns to the trigger after the drawer closes", async () => {
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
const refine = screen.getByRole("button", { name: /Refine question/i });
await user.click(refine);
await screen.findByRole("region", { name: /Refine your question/i });
await user.keyboard("{Escape}");
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
// The keyboard user is not stranded on <body>.
expect(refine).toHaveFocus();
});
it("refine: Enter finishing the exchange composes exactly once", async () => {
// Regression (P8 review): the section-level Enter guard re-fired
// submitAnswer on the same bubbled event (stale stage closure) —
// the composer ran twice, a doubled paid POST once v0.2 wires the
// model. The input guard alone must own the Enter path.
const composeSpy = vi.spyOn(api, "composeRefinedQuestion");
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
await screen.findByRole("region", { name: /Refine your question/i });
const answer = await screen.findByLabelText(
/What specific part of this topic matters most to you\?/i,
);
await user.type(answer, "automotive applications");
await user.keyboard("{Enter}");
await user.click(await screen.findByRole("button", { name: "Skip" }));
await user.click(await screen.findByRole("button", { name: "Compose question" }));
await screen.findByText(/Refined question/i);
expect(composeSpy).toHaveBeenCalledTimes(1);
composeSpy.mockRestore();
});
it("refine: Enter on a drawer button activates it (Keep original closes)", async () => {
// Regression (P8 review): the section-level Enter guard swallowed
// the focused button's own activation during the questions stage —
// keyboard "Keep original" did nothing.
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"How good are solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
await screen.findByRole("region", { name: /Refine your question/i });
screen.getByRole("button", { name: "Keep original" }).focus();
await user.keyboard("{Enter}");
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"How good are solid-state batteries",
);
});
});
+96
View File
@@ -0,0 +1,96 @@
"use client";
/**
* AskView (T-7.2, T-7.3, REQ-55, REQ-67, REQ-68) — the /ask screen client.
*
* Owns the question + context state so TWO opt-in affordances can fill
* the form: open-question suggestions (click → box fills, editable) and
* the refine dialogue (inline drawer → composed question + context).
* Neither auto-submits — "Research this" stays the one primary action,
* so the ≤3-click happy path is untouched.
*/
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { FormEvent } from "react";
import { AskSuggestions } from "@/components/ask-suggestions";
import { RefineQuestionButton } from "@/components/refine-question";
import { setAskState } from "@/lib/ask-store";
export function AskView() {
const router = useRouter();
const [question, setQuestion] = useState("");
const [context, setContext] = useState("");
const [submitting, setSubmitting] = useState(false);
const canSubmit = question.trim().length > 0 && !submitting;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setAskState({
question: question.trim(),
...(context.trim() !== "" ? { context: context.trim() } : {}),
});
router.push("/choose");
}
return (
<div>
<form onSubmit={handleSubmit} className="measure-narrow">
<label htmlFor="question" className="block text-body font-medium">
What do you want to know?
</label>
<textarea
id="question"
name="question"
value={question}
onChange={(e) => setQuestion(e.target.value)}
rows={3}
required
autoFocus
placeholder="e.g. What are the latest advances in solid-state batteries?"
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
{/* Refine — opt-in, inline, needs a draft to act on (REQ-68). */}
<RefineQuestionButton
draft={question}
onApply={(refined) => {
setQuestion(refined.question);
if (refined.context.trim() !== "") setContext(refined.context);
}}
/>
<label
htmlFor="context"
className="mt-6 block text-small font-medium text-ink-soft"
>
Anything else it should know?{" "}
<span className="text-ink-faint">(optional)</span>
</label>
<textarea
id="context"
name="context"
value={context}
onChange={(e) => setContext(e.target.value)}
rows={2}
placeholder="Constraints, prior findings, the decision this feeds…"
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink placeholder:text-ink-faint"
/>
<button
type="submit"
disabled={!canSubmit}
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Opening methodologies…" : "Research this"}
</button>
</form>
{/* Suggestions — below the primary action, never competing (REQ-67). */}
<AskSuggestions onPick={(suggested) => setQuestion(suggested)} />
</div>
);
}
@@ -0,0 +1,141 @@
/**
* Choose screen tests (T-3.3, REQ-56).
*
* - Renders all 8 cards from MockApiClient fixtures
* - Exactly ONE "Recommended" badge (deep-report)
* - Advanced drawer is hidden by default; opening reveals the 12 dials
* - "Start research" creates a run and routes to /run/[id]
*/
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChooseView } from "@/components/choose-view";
import { MethodologyCard } from "@/components/methodology-card";
import { api } from "@/lib/api/client";
import { setAskState } from "@/lib/ask-store";
import { DIALS } from "@/lib/types";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
async function loadFixtures() {
return api.getMethodologies();
}
describe("ChooseView", () => {
beforeEach(() => {
pushMock.mockClear();
setAskState({
question: "What are the latest advances in solid-state batteries?",
});
});
afterEach(cleanup);
it("renders all 8 methodology cards from the fixtures", async () => {
const methodologies = await loadFixtures();
render(<ChooseView methodologies={methodologies} />);
for (const methodology of methodologies) {
expect(
screen.getByTestId(`methodology-${methodology.slug}`),
).toBeInTheDocument();
}
expect(screen.getByText("Systematic Review")).toBeInTheDocument();
expect(screen.getByText("Corpus Interrogation")).toBeInTheDocument();
});
it("shows exactly one Recommended badge — on Deep Report", async () => {
const methodologies = await loadFixtures();
render(<ChooseView methodologies={methodologies} />);
const badges = screen.getAllByText("Recommended");
expect(badges).toHaveLength(1);
expect(
within(screen.getByTestId("methodology-deep-report")).getByText(
"Recommended",
),
).toBeInTheDocument();
});
it("shows the carried question with a link back to /ask", async () => {
const methodologies = await loadFixtures();
render(<ChooseView methodologies={methodologies} />);
expect(
await screen.findByText(
"What are the latest advances in solid-state batteries?",
),
).toBeInTheDocument();
expect(screen.getByText("Change question")).toBeInTheDocument();
});
it("drawer: hidden by default; opening reveals all 12 dial labels", async () => {
const user = userEvent.setup();
const methodologies = await loadFixtures();
render(<ChooseView methodologies={methodologies} />);
const rapid = screen.getByTestId("methodology-rapid-answer");
const drawer = within(rapid).getByTestId("dials-list");
expect(drawer).not.toBeVisible(); // <details> collapsed: present but hidden
await user.click(within(rapid).getByText("Advanced"));
for (const dial of DIALS) {
expect(within(drawer).getByText(dial.label)).toBeVisible();
}
// Defaults render too: Rapid Answer is breadth 1 / depth 1.
const breadth = within(drawer).getByText("Breadth");
expect(breadth.nextElementSibling).toHaveTextContent("1 —");
const depth = within(drawer).getByText("Depth");
expect(depth.nextElementSibling).toHaveTextContent("1 —");
});
it("Start research: creates the run and routes to /run/[id]", async () => {
const user = userEvent.setup();
const methodologies = await loadFixtures();
const deepReport = methodologies.find((m) => m.slug === "deep-report");
if (!deepReport) throw new Error("fixture missing deep-report");
render(
<MethodologyCard
methodology={deepReport}
question="What are the latest advances in solid-state batteries?"
/>,
);
await user.click(screen.getByRole("button", { name: "Start research" }));
await vi.waitFor(() => {
const calls = pushMock.mock.calls.flat();
const runRoute = calls.find((c) => typeof c === "string" && c.startsWith("/run/"));
expect(runRoute).toMatch(/^\/run\/run-[a-z0-9]+$/);
});
});
it("empty-question guard: Start research disabled without a question (P1-2 fix)", async () => {
const methodologies = await loadFixtures();
const deepReport = methodologies.find((m) => m.slug === "deep-report");
if (!deepReport) throw new Error("fixture missing deep-report");
// Direct /choose navigation or a hard refresh: no carried question.
setAskState({ question: "" });
render(
<MethodologyCard methodology={deepReport} question={""} />,
);
const start = screen.getByRole("button", { name: "Start research" });
expect(start).toBeDisabled();
expect(start).toHaveAttribute("aria-disabled", "true");
// Whitespace-only questions are equally blocked.
render(
<MethodologyCard methodology={deepReport} question={" "} />,
);
expect(screen.getAllByRole("button", { name: "Start research" })[0]).toBeDisabled();
});
});
+84
View File
@@ -0,0 +1,84 @@
"use client";
/**
* ChooseView (T-3.2, REQ-56) — the client shell of /choose.
*
* Receives the methodology catalog from the server page (fetched via
* the ApiClient singleton) and reads the carried ask payload from the
* ask store on mount. Direct navigation without a question shows a
* notice with a link back to /ask instead of a dead end.
*/
import Link from "next/link";
import { useEffect, useState } from "react";
import { MethodologyCard } from "@/components/methodology-card";
import { getAskState } from "@/lib/ask-store";
import type { Methodology } from "@/lib/types";
export function ChooseView({ methodologies }: { methodologies: Methodology[] }) {
// The ask payload lives in client-only module state; hydrate from it
// after mount so the server render stays static.
const [ask, setAsk] = useState<{ question: string; context?: string } | null>(
null,
);
const [mounted, setMounted] = useState(false);
useEffect(() => {
const carried = getAskState();
if (carried) setAsk(carried);
setMounted(true);
}, []);
const question = ask?.question ?? "";
return (
<div className="mx-auto max-w-6xl px-6 py-section">
<header className="mb-block">
<h1 className="text-headline font-semibold text-ink">
How deep should this go?
</h1>
{mounted && question !== "" ? (
<div className="mt-3">
<p className="text-small text-ink-faint">Your question</p>
<blockquote className="measure-reading mt-1 border-l-2 border-accent pl-3 text-lead text-ink">
{question}
</blockquote>
<p className="mt-2 text-caption">
<Link href="/ask" className="text-accent underline underline-offset-2">
Change question
</Link>
</p>
</div>
) : (
mounted && (
<p className="mt-3 text-small text-ink-soft" role="note">
You arrived without a question.{" "}
<Link href="/ask" className="font-medium text-accent underline underline-offset-2">
Start by asking one
</Link>
.
</p>
)
)}
</header>
<div className="grid grid-cols-1 gap-gutter md:grid-cols-2 xl:grid-cols-4">
{methodologies.map((methodology) => (
<MethodologyCard
key={methodology.slug}
methodology={methodology}
question={question}
{...(ask?.context !== undefined
? { context: ask.context }
: {})}
/>
))}
</div>
<p className="mt-block text-caption text-ink-faint">
Prototype: ETAs and costs are mock heuristics; “Start research”
replays a scripted run.
</p>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* CommandPalette (T-5.2, REQ-63) — the ⌘K navigation surface.
*
* Navigation-only in the prototype: jump to screens or open a stored
* report in the Reading Room. Full search (projects, sources, claims)
* ships with the v0.2 engine. Keyboard-first: type to filter, Up/Down
* to move, Enter to go, Escape to close.
*/
import { useEffect, useRef, useState } from "react";
/** One navigable palette row. */
export interface PaletteEntry {
kind: "screen" | "report";
label: string;
href: string;
}
export function CommandPalette({
entries,
onNavigate,
onDismiss,
}: {
entries: PaletteEntry[];
onNavigate: (href: string) => void;
onDismiss: () => void;
}) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = entries.filter((entry) =>
entry.label.toLowerCase().includes(query.trim().toLowerCase()),
);
// Keep the highlighted row in range as the filter narrows.
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape") {
onDismiss();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setActive((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (event.key === "Enter") {
const entry = filtered[active];
if (entry) onNavigate(entry.href);
}
};
return (
<div
role="dialog"
aria-label="Search or jump to"
className="fixed inset-0 z-50 flex items-start justify-center bg-ink/25 pt-[14vh] backdrop-blur-sm"
onClick={onDismiss}
>
<div
className="w-full max-w-xl overflow-hidden rounded-card border border-hairline bg-paper-raised shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onKeyDown}
placeholder="Jump to a screen or report…"
aria-label="Search or jump to"
className="w-full border-b border-hairline bg-transparent px-5 py-4 text-body text-ink outline-none placeholder:text-ink-faint"
/>
<ul className="max-h-80 overflow-y-auto py-2" role="listbox">
{filtered.map((entry, index) => (
<li key={`${entry.kind}|${entry.href}|${entry.label}`}>
<button
type="button"
role="option"
aria-selected={index === active}
onMouseEnter={() => setActive(index)}
onClick={() => onNavigate(entry.href)}
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-small ${
index === active ? "bg-accent-wash text-accent-ink" : "text-ink"
}`}
>
<span
aria-hidden="true"
className={`rounded-full border border-hairline px-2 py-0.5 text-caption ${
entry.kind === "report" ? "text-accent-ink" : "text-ink-faint"
}`}
>
{entry.kind === "report" ? "Report" : "Screen"}
</span>
<span className="measure-narrow truncate">{entry.label}</span>
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-5 py-6 text-center text-small text-ink-faint">
Nothing matches “{query}”.
</li>
)}
</ul>
<p className="border-t border-hairline px-5 py-2 text-caption text-ink-faint">
Navigation only in the prototype — full search ships in v0.2.
</p>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
/**
* Research library tests (T-5.3, REQ-64; REQ-60 panels retained).
*
* - Folder tree renders "All reports" + every fixture folder with counts
* - Report cards link into the Reading Room with library-card metadata
* - Folder filter narrows the visible cards; empty folder is honest
* - Sorting flips recency ↔ title order
* - Connections panel draws shared-source ⇄ and related-topic → edges
* - Project graph + task inbox (v2 preview) still render (REQ-60)
*/
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { LibraryView } from "@/components/library-view";
import { api } from "@/lib/api/client";
const ALL_REPORTS = 4;
describe("LibraryView — stored reports (REQ-64)", () => {
afterEach(cleanup);
it("renders the folder tree with All reports + fixture folders + counts", async () => {
render(<LibraryView />);
const allButton = await screen.findByRole("button", { name: /All reports/i });
expect(allButton).toBeInTheDocument();
expect(screen.getByText(String(ALL_REPORTS))).toBeInTheDocument();
// Fixture folders with their report counts.
expect(screen.getByRole("button", { name: /Energy storage/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^📁 Transport/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Policy & supply chains/i }),
).toBeInTheDocument();
});
it("report cards link into the Reading Room with metadata", async () => {
render(<LibraryView />);
const link = await screen.findByRole("link", {
name: /Solid-State Battery Advances: A Deep Report/i,
});
expect(link).toHaveAttribute("href", "/run/run-solid-state/report");
expect(screen.getByText(/7 citations/i)).toBeInTheDocument();
expect(screen.getAllByText(/deep-report/i).length).toBeGreaterThanOrEqual(1);
});
it("folder filter narrows cards; empty folder state is honest", async () => {
const user = userEvent.setup();
render(<LibraryView />);
await screen.findByRole("button", { name: /All reports/i });
// Transport folder holds exactly one fixture report.
await user.click(screen.getByRole("button", { name: /^📁 Transport/i }));
expect(screen.getByRole("link", { name: /Automotive Solid-State Pilot Production/i })).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /Solid-State Battery Advances/i }),
).toBeNull();
// Back to all — every report is visible again.
await user.click(screen.getByRole("button", { name: /^📁 All reports/i }));
expect(screen.getByRole("link", { name: /Solid-State Battery Advances/i })).toBeInTheDocument();
});
it("sorting flips between recency and title order", async () => {
const user = userEvent.setup();
render(<LibraryView />);
const list = await screen.findByRole("list", { name: "Reports" });
const cards = () => within(list).getAllByRole("link");
// Default: most recent first — the 2026-09-25 materials report.
expect(cards()[0]).toHaveTextContent(/Lithium Refining Capacity/i);
const sort = screen.getByRole("combobox");
await user.selectOptions(sort, "title");
expect(cards()[0]).toHaveTextContent(/Automotive Solid-State Pilot/i);
await user.selectOptions(sort, "recent");
expect(cards()[0]).toHaveTextContent(/Lithium Refining Capacity/i);
});
});
describe("LibraryView — connections (REQ-64)", () => {
afterEach(cleanup);
it("draws shared-source and related-topic edges between reports", async () => {
render(<LibraryView />);
expect(
await screen.findByText(/Shared sources — 2 shared sources/i),
).toBeInTheDocument();
expect(
screen.getAllByText(/Related topic/i).length,
).toBeGreaterThanOrEqual(1);
});
it("every connection edge references reports that exist", async () => {
const [library, connections] = await Promise.all([
api.getReportLibrary(),
api.getReportConnections(),
]);
const ids = new Set(library.reports.map((r) => r.id));
for (const edge of connections) {
expect(ids).toContain(edge.fromReportId);
expect(ids).toContain(edge.toReportId);
}
});
});
describe("LibraryView — v2 preview panels (REQ-60, retained)", () => {
afterEach(cleanup);
it("project graph renders nodes with shared-source cross-links", async () => {
render(<LibraryView />);
expect(await screen.findByText("Project graph")).toBeInTheDocument();
expect(
await screen.findByText("Solid-state batteries"),
).toBeInTheDocument();
expect(
screen.getByText(/⇄ Critical materials supply chains \(2\)/),
).toBeInTheDocument();
});
it("task inbox lists items; toggling flips the visible status", async () => {
const user = userEvent.setup();
render(<LibraryView />);
const question =
"Which solid-state startups have shipped pilot cells to automakers?";
const inboxPanel = await screen.findByRole("region", { name: "Task inbox" });
const openQuestion = await within(inboxPanel).findByText(question);
expect(openQuestion.className).not.toContain("line-through");
const checkbox = within(inboxPanel).getByRole("checkbox", { name: question });
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(openQuestion.className).toContain("line-through");
expect(
screen.getByText(/open questions worth following up · 2 open/i),
).toBeInTheDocument();
});
});
+481
View File
@@ -0,0 +1,481 @@
"use client";
/**
* LibraryView (T-5.3, REQ-64, REQ-60) — the research library.
*
* Four panels rendered from fixture data behind the ApiClient boundary:
* - Stored reports: folder-structure view (auto-categorized folders)
* with sortable report cards linking into the Reading Room
* - Connections: cross-report edges — shared sources ⇄ and related
* topics → — drawn between stored reports
* - Project graph: cross-linked research topics (v2 preview, REQ-60)
* - Task inbox: open questions as checkable follow-ups (REQ-60)
*
* Report storage + connections are the P5 deliverable; graph + inbox
* remain v2-preview mockups.
*/
import Link from "next/link";
import { useEffect, useState } from "react";
import {
api,
type InboxItem,
type ProjectGraph,
type ReportConnection,
type ReportLibrary,
type ReportSortKey,
type ReportSummary,
} from "@/lib/api/client";
type LibraryState =
| { kind: "loading" }
| { kind: "error"; message: string }
| {
kind: "ready";
library: ReportLibrary;
connections: ReportConnection[];
graph: ProjectGraph;
inbox: InboxItem[];
};
export function LibraryView() {
const [state, setState] = useState<LibraryState>({ kind: "loading" });
const [folderId, setFolderId] = useState<string | "all">("all");
const [sortKey, setSortKey] = useState<ReportSortKey>("recent");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [library, connections, graph, inbox] = await Promise.all([
api.getReportLibrary(),
api.getReportConnections(),
api.getProjectGraph(),
api.getTaskInbox(),
]);
if (!cancelled) {
setState({ kind: "ready", library, connections, graph, inbox });
}
} catch (cause) {
if (!cancelled) {
setState({
kind: "error",
message: cause instanceof Error ? cause.message : "Library unavailable",
});
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (state.kind === "loading") {
return <p className="mt-8 text-small text-ink-faint">Loading library…</p>;
}
if (state.kind === "error") {
return <p role="alert" className="mt-8 text-small text-danger">{state.message}</p>;
}
const { library, connections, graph, inbox } = state;
const visible =
folderId === "all"
? library.reports
: library.reports.filter((r) => r.category === folderId);
const sorted = sortReports(visible, sortKey);
const counts = new Map<string, number>();
for (const report of library.reports) {
counts.set(report.category, (counts.get(report.category) ?? 0) + 1);
}
return (
<div>
<h1 className="text-display font-semibold text-ink">Library</h1>
<p className="measure-reading mt-2 text-body text-ink-soft">
Everything you have researched, filed by folder — with the
connections your reports draw between each other.
</p>
<div className="mt-block grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-8">
<StoredReportsPanel
library={library}
counts={counts}
folderId={folderId}
sortKey={sortKey}
onFolderChange={setFolderId}
onSortChange={setSortKey}
visibleReports={sorted}
/>
<ConnectionsPanel
connections={connections}
reports={library.reports}
/>
</div>
<div className="space-y-8">
<ProjectGraphPanel graph={graph} />
<TaskInboxPanel initialItems={inbox} />
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Stored reports — folder tree + sortable cards (REQ-64) */
/* ------------------------------------------------------------------ */
function StoredReportsPanel({
library,
counts,
folderId,
sortKey,
onFolderChange,
onSortChange,
visibleReports,
}: {
library: ReportLibrary;
counts: Map<string, number>;
folderId: string | "all";
sortKey: ReportSortKey;
onFolderChange: (id: string | "all") => void;
onSortChange: (key: ReportSortKey) => void;
visibleReports: ReportSummary[];
}) {
return (
<section
aria-label="Stored reports"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Stored reports
</h2>
<fieldset className="flex items-center gap-2 text-caption text-ink-faint">
<legend className="sr-only">Sort reports</legend>
<span id="sort-label">Sort</span>
<select
aria-labelledby="sort-label"
value={sortKey}
onChange={(event) => onSortChange(event.target.value as ReportSortKey)}
className="rounded-card border border-hairline bg-paper px-2 py-1 text-caption text-ink"
>
<option value="recent">Most recent</option>
<option value="title">Title A–Z</option>
</select>
</fieldset>
</div>
{/* Folder tree — "All reports" + one entry per auto-category. */}
<ul className="mt-4 space-y-1" aria-label="Report folders">
<li>
<button
type="button"
aria-current={folderId === "all" ? "true" : undefined}
onClick={() => onFolderChange("all")}
className={`flex w-full items-center justify-between rounded-card px-3 py-2 text-small transition-colors ${
folderId === "all"
? "bg-accent-wash font-medium text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken"
}`}
>
<span>📁 All reports</span>
<span className="text-caption text-ink-faint">
{library.reports.length}
</span>
</button>
</li>
{library.folders.map((folder) => (
<li key={folder.id}>
<button
type="button"
aria-current={folderId === folder.id ? "true" : undefined}
onClick={() => onFolderChange(folder.id)}
className={`flex w-full items-center justify-between rounded-card px-3 py-2 text-left text-small transition-colors ${
folderId === folder.id
? "bg-accent-wash font-medium text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken"
}`}
>
<span>
📁 {folder.name}
<span className="mt-0.5 block text-caption text-ink-faint">
{folder.description}
</span>
</span>
<span className="text-caption text-ink-faint">
{counts.get(folder.id) ?? 0}
</span>
</button>
</li>
))}
</ul>
{/* Report cards */}
<ul className="mt-5 space-y-3" aria-label="Reports">
{visibleReports.map((report) => (
<li key={report.id}>
<ReportCard report={report} />
</li>
))}
{visibleReports.length === 0 && (
<li className="text-caption text-ink-faint">
No reports in this folder yet.
</li>
)}
</ul>
</section>
);
}
function ReportCard({ report }: { report: ReportSummary }) {
return (
<Link
href={`/run/${report.runId}/report`}
className="block rounded-card border border-hairline bg-paper px-4 py-3 transition-colors hover:border-ink-faint"
>
<p className="text-body font-medium text-ink">{report.title}</p>
<p className="measure-reading mt-1 text-caption text-ink-soft">
{report.question}
</p>
<p className="mt-2 flex flex-wrap gap-x-3 text-caption text-ink-faint">
<span>{formatDate(report.generatedAt)}</span>
<span>· {report.methodologySlug}</span>
<span>
· {report.citationCount} citation{report.citationCount === 1 ? "" : "s"}
</span>
</p>
</Link>
);
}
/** Sort by recency (newest first) or title (A–Z, locale-aware). */
function sortReports(
reports: ReportSummary[],
key: ReportSortKey,
): ReportSummary[] {
const copy = [...reports];
if (key === "recent") {
copy.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
} else {
copy.sort((a, b) => a.title.localeCompare(b.title));
}
return copy;
}
/** "Sep 20, 2026" — short, unambiguous, no time clutter on cards. */
function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/* ------------------------------------------------------------------ */
/* Cross-report connections (REQ-64) */
/* ------------------------------------------------------------------ */
function ConnectionsPanel({
connections,
reports,
}: {
connections: ReportConnection[];
reports: ReportSummary[];
}) {
const titleById = new Map(reports.map((r) => [r.id, r.title] as const));
return (
<section
aria-label="Report connections"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Connections
</h2>
<p className="mt-1 text-caption text-ink-soft">
How your reports draw on each other — shared sources and adjacent topics.
</p>
<ul className="mt-4 space-y-2">
{connections.map((edge) => (
<li
key={`${edge.fromReportId}|${edge.toReportId}|${edge.kind}`}
className="rounded-card border border-hairline bg-paper px-3 py-2"
>
<p className="text-small text-ink">
<span aria-hidden="true">{edge.kind === "shared-sources" ? "⇄" : "→"} </span>
<span className="font-medium">
{shortTitle(titleById.get(edge.fromReportId))}
</span>
<span className="text-ink-faint"> ↔ </span>
<span className="font-medium">
{shortTitle(titleById.get(edge.toReportId))}
</span>
</p>
<p
className={`mt-0.5 text-caption ${
edge.kind === "shared-sources" ? "text-accent-ink" : "text-ink-faint"
}`}
>
{edge.kind === "shared-sources"
? `Shared sources — ${edge.label}`
: `Related topic — ${edge.label}`}
</p>
</li>
))}
</ul>
</section>
);
}
/** Card-safe truncation for connection labels. */
function shortTitle(title: string | undefined): string {
if (title === undefined) return "Report";
return title.length > 34 ? `${title.slice(0, 33)}…` : title;
}
/* ------------------------------------------------------------------ */
/* Project graph panel (v2 preview, REQ-60) */
/* ------------------------------------------------------------------ */
function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
return (
<section aria-label="Project graph" className="rounded-card border border-hairline bg-paper-raised px-5 py-5">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Project graph
</h2>
<span className="rounded-full border border-hairline px-1.5 text-caption text-ink-faint">
preview
</span>
</div>
<p className="mt-1 text-caption text-ink-soft">
Topics you have researched, linked where they share sources.
</p>
<p className="mt-1 text-caption text-ink-faint">
Cross-project linking lands in v0.2 — preview mockup below.
</p>
<div className="mt-5 space-y-4">
{graph.nodes.map((node) => {
const nodeEdges = graph.edges.filter(
(e) => e.fromId === node.id || e.toId === node.id,
);
return (
<div
key={node.id}
className="rounded-card border border-hairline bg-paper px-4 py-3"
>
<p className="text-body font-medium text-ink" title={node.topic}>
{node.topic}
</p>
{node.findings.length > 0 && (
<p className="mt-1 text-caption text-ink-soft">
{node.findings[0]}
</p>
)}
<div className="mt-2 flex flex-wrap gap-1.5">
{node.linkedSources.map((source) => (
<span
key={source}
className="rounded-full border border-hairline bg-paper-raised px-2.5 py-0.5 text-caption text-ink-faint"
>
{source}
</span>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="Cross-links">
{nodeEdges.map((edge) => {
const otherId = edge.fromId === node.id ? edge.toId : edge.fromId;
const other = graph.nodes.find((n) => n.id === otherId);
return (
<li
key={`${node.id}|${otherId}|${edge.sharedSourceCount}`}
className="rounded-full bg-accent-wash px-2.5 py-0.5 text-caption text-accent-ink"
title={
edge.sharedSourceCount > 0
? `${edge.sharedSourceCount} shared source${edge.sharedSourceCount === 1 ? "" : "s"}`
: "topical link — no shared sources yet"
}
>
{edge.sharedSourceCount > 0
? `⇄ ${other?.topic ?? otherId} (${edge.sharedSourceCount})`
: `→ ${other?.topic ?? otherId}`}
</li>
);
})}
{nodeEdges.length === 0 && (
<li className="text-caption text-ink-faint">No links</li>
)}
</ul>
</div>
);
})}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* Task inbox panel (REQ-60) */
/* ------------------------------------------------------------------ */
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
const [items, setItems] = useState(initialItems);
const toggle = (id: string) => {
setItems((current) =>
current.map((item) =>
item.id === id
? { ...item, status: item.status === "done" ? "open" : "done" }
: item,
),
);
};
const openCount = items.filter((i) => i.status !== "done").length;
return (
<section aria-label="Task inbox" className="rounded-card border border-hairline bg-paper-raised px-5 py-5">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Task inbox
</h2>
<span className="rounded-full border border-hairline px-1.5 text-caption text-ink-faint">
preview
</span>
</div>
<p className="mt-1 text-caption text-ink-soft">
Open questions worth following up · {openCount} open
</p>
<p className="mt-1 text-caption text-ink-faint">
Toggles are local-only — the inbox persists in v0.2.
</p>
<ul className="mt-4 space-y-2">
{items.map((item) => (
<li key={item.id} className="flex items-start gap-3">
<input
type="checkbox"
checked={item.status === "done"}
onChange={() => toggle(item.id)}
aria-label={item.question}
className="mt-1 h-4 w-4 accent-[var(--color-accent)]"
/>
<span
className={`measure-narrow text-small ${
item.status === "done" ? "text-ink-faint line-through" : "text-ink"
}`}
>
{item.question}
</span>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,150 @@
"use client";
/**
* MethodologyCard (T-3.2, REQ-56) — one card in the chooser grid.
*
* Progressive disclosure: the 12 dials are hidden behind an "Advanced"
* drawer (a <details> element — no JS needed to toggle, no layout shift:
* content expands below the card's summary strip). "Start research"
* calls api.createRun and routes to /run/[id].
*/
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api } from "@/lib/api/client";
import { allDials, formatDialValue } from "@/lib/dials";
import type { Methodology } from "@/lib/types";
const verificationBadge: Record<Methodology["verification"], { label: string; classes: string }> = {
"per-claim": { label: "Per-claim citations", classes: "bg-info-wash text-info" },
full: { label: "Full verification", classes: "bg-success-wash text-success" },
standard: { label: "Standard verification", classes: "bg-info-wash text-info" },
};
export function MethodologyCard({
methodology,
question,
context,
}: {
methodology: Methodology;
question: string;
context?: string;
}) {
const router = useRouter();
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleStart() {
setStarting(true);
setError(null);
try {
const run = await api.createRun({
question,
...(context !== undefined && context !== "" ? { context } : {}),
methodologySlug: methodology.slug,
});
router.push(`/run/${run.id}`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not start run");
setStarting(false);
}
}
const verification = verificationBadge[methodology.verification];
return (
<article
className="relative flex h-full flex-col rounded-card border border-hairline bg-paper-raised p-5"
data-testid={`methodology-${methodology.slug}`}
>
{methodology.recommended && (
<span className="absolute -top-3 left-4 rounded-pill bg-accent px-3 py-1 text-caption font-medium text-paper-raised">
Recommended
</span>
)}
{!methodology.recommended && methodology.badge && (
<span className="absolute -top-3 left-4 rounded-pill bg-paper-sunken px-3 py-1 text-caption font-medium text-ink-soft">
{methodology.badge}
</span>
)}
<h2 className="text-title font-semibold text-ink">{methodology.name}</h2>
<p className="mt-1 text-small text-ink-soft">{methodology.tagline}</p>
<div className="mt-3 flex flex-wrap items-center gap-2 text-caption">
<span className="rounded-pill bg-paper-sunken px-2.5 py-1 font-medium text-ink-soft">
~{methodology.etaMinutes} min
</span>
<span className="rounded-pill bg-paper-sunken px-2.5 py-1 font-medium text-ink-soft">
{methodology.costEstimate}
</span>
<span className={`rounded-pill px-2.5 py-1 font-medium ${verification.classes}`}>
{verification.label}
</span>
</div>
<p className="measure-reading mt-4 text-small leading-relaxed text-ink-soft">
{methodology.whyRecommended}
</p>
<div
className="mt-4 border-t border-hairline pt-3"
aria-label="Dial defaults"
>
{methodology.dialSummary.map((line) => (
<p key={line} className="text-caption text-ink-faint">
{line}
</p>
))}
</div>
<details className="group mt-4">
<summary className="cursor-pointer select-none text-caption font-medium text-accent-ink underline decoration-accent-wash underline-offset-4">
Advanced
<span className="ml-1 inline-block transition-transform group-open:rotate-90" aria-hidden="true">
▸
</span>
</summary>
<dl
className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 rounded-card bg-paper-sunken px-4 py-3 text-caption"
data-testid="dials-list"
>
{allDials().map((dial) => {
const value = methodology.defaultDials[dial.key];
return (
<div key={dial.key} className="col-span-2 grid grid-cols-subgrid">
<dt className="font-medium text-ink">{dial.label}</dt>
<dd className="text-ink-soft" title={dial.description}>
<span className="font-medium">{formatDialValue(value)}</span>
<span className="text-ink-faint"> — {dial.description}</span>
</dd>
</div>
);
})}
</dl>
</details>
<div className="mt-auto pt-5">
<button
type="button"
onClick={handleStart}
disabled={starting || question.trim() === ""}
aria-disabled={question.trim() === ""}
title={
question.trim() === ""
? "Ask a question first — the Ask screen is one click away"
: undefined
}
className="w-full rounded-pill bg-accent px-5 py-2.5 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-50"
>
{starting ? "Starting…" : "Start research"}
</button>
{error && (
<p role="alert" className="mt-2 text-caption text-danger">
{error}
</p>
)}
</div>
</article>
);
}
@@ -0,0 +1,109 @@
/**
* Reading Room tests (T-2.12, REQ-58, REQ-59).
*
* - Report renders title + sections with citation markers
* - Marker focus reveals the exact quoted passage (trust affordance)
* - Ledger panel lists all sources with health badges
* - Exports produce real content: markdown carries [title](url) links;
* JSON parses
*/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ReadingRoom } from "@/components/reading-room";
import { api } from "@/lib/api/client";
import { parseReportBody } from "@/lib/report-body";
const QUESTION = "What are the latest advances in solid-state batteries?";
async function runId(): Promise<string> {
const run = await api.createRun({
question: QUESTION,
methodologySlug: "deep-report",
});
return run.id;
}
describe("parseReportBody", () => {
it("splits bodies into text and citation segments", () => {
const segments = parseReportBody("Density gains [^p1] and cycling [^p2].");
expect(segments).toEqual([
{ kind: "text", text: "Density gains " },
{ kind: "citation", passageId: "p1" },
{ kind: "text", text: " and cycling " },
{ kind: "citation", passageId: "p2" },
{ kind: "text", text: "." },
]);
});
it("leaves marker-free bodies as a single text segment", () => {
expect(parseReportBody("Plain body.")).toEqual([
{ kind: "text", text: "Plain body." },
]);
});
});
describe("ReadingRoom", () => {
afterEach(cleanup);
it("renders the report with citation markers and the ledger", async () => {
const id = await runId();
render(<ReadingRoom id={id} />);
expect(
await screen.findByText(`Deep Report — ${QUESTION}`),
).toBeInTheDocument();
// Section headings render.
expect(screen.getByText("Executive summary")).toBeInTheDocument();
// Citation markers render as superscript buttons (markers repeat
// across sections — assert presence, not uniqueness).
expect(screen.getAllByRole("button", { name: /Citation 1:/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole("button", { name: /Citation 2:/i }).length).toBeGreaterThan(0);
// Ledger panel lists all 6 sources with verification language.
expect(screen.getByText("Citation ledger")).toBeInTheDocument();
expect(
screen.getByText("6 sources · every claim traceable to a visited passage"),
).toBeInTheDocument();
expect(
screen.getByText("Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review"),
).toBeInTheDocument();
expect(screen.getAllByText(/live/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/stale/i)).toBeInTheDocument();
});
it("reveals the exact quoted passage on marker focus", async () => {
const id = await runId();
render(<ReadingRoom id={id} />);
const markers = await screen.findAllByRole("button", { name: /Citation 2:/i });
const marker = markers[0];
if (marker === undefined) throw new Error("citation 2 marker not rendered");
marker.focus();
const tooltip = await screen.findByRole("tooltip");
// The exact quote from passage p2 — the hover-to-quote affordance.
expect(tooltip.textContent).toContain(
"Sulfide electrolytes exhibit ionic conductivity comparable to liquid electrolytes",
);
expect(tooltip.textContent).toContain("example.org");
expect(tooltip.textContent).toContain("tier 1");
});
it("export content: markdown carries real links; JSON parses (REQ-59)", async () => {
const id = await runId();
const markdown = await api.exportReport(id, "markdown");
expect(markdown.filename).toBe(`researchx-${id}.md`);
expect(markdown.content).toContain("## Sources");
expect(markdown.content).toMatch(/\[.*\]\(https:\/\/.+\)/);
expect(markdown.content).toContain("](https://example.org/arxiv-sulfide-electrolytes-review)");
const json = await api.exportReport(id, "json");
expect(json.filename).toBe(`researchx-${id}.json`);
const parsed = JSON.parse(json.content) as {
report: { runId: string };
ledger: { citations: unknown[] };
};
expect(parsed.report.runId).toBe(id);
expect(parsed.ledger.citations).toHaveLength(6);
});
});
+254
View File
@@ -0,0 +1,254 @@
"use client";
/**
* ReadingRoom (T-2.7..T-2.9, REQ-58, REQ-59) — the /run/[id]/report screen.
*
* Report reader with page-anchored citations: [^pN] markers render as
* superscript links; hover OR keyboard focus reveals the exact quoted
* passage with source title, verification badge, and authority tier.
* The citation ledger panel lists every consulted source. Export
* buttons download real Markdown/JSON files with citations as links.
*/
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type Citation, type Report, type CitationLedger } from "@/lib/api/client";
import { parseReportBody } from "@/lib/report-body";
type RoomState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; report: Report; ledger: CitationLedger };
export function ReadingRoom({ id }: { id: string }) {
const [state, setState] = useState<RoomState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [report, ledger] = await Promise.all([
api.getReport(id),
api.getLedger(id),
]);
if (!cancelled) setState({ kind: "ready", report, ledger });
} catch (cause) {
if (!cancelled) {
setState({
kind: "error",
message: cause instanceof Error ? cause.message : "Report not found",
});
}
}
})();
return () => {
cancelled = true;
};
}, [id]);
if (state.kind === "loading") {
return <p className="mt-8 text-small text-ink-faint">Loading report…</p>;
}
if (state.kind === "error") {
return (
<div>
<p role="alert" className="mt-8 text-small text-danger">
{state.message}
</p>
<p className="mt-block text-caption">
<Link href="/ask" className="text-accent underline underline-offset-2">
Ask a question
</Link>
</p>
</div>
);
}
const { report, ledger } = state;
const byPassage = new Map(report.citations.map((c) => [c.passageId, c]));
return (
<div>
<p className="mt-2 text-caption text-ink-faint">
<Link href={`/run/${report.runId}`} className="text-accent underline underline-offset-2">
← Back to run
</Link>
{" · "}
<Link href="/library" className="text-accent underline underline-offset-2">
Library
</Link>
</p>
<h1 className="measure-reading mt-3 text-display font-semibold text-ink">
{report.title}
</h1>
<div className="mt-block grid gap-8 lg:grid-cols-[minmax(0,1fr)_300px]">
{/* Report body */}
<article>
{report.sections.map((section) => (
<section key={section.heading} className="mt-block first:mt-0">
<h2 className="text-headline font-semibold text-ink">{section.heading}</h2>
<div className="measure-reading mt-2 space-y-2 text-body leading-relaxed text-ink">
{parseReportBody(section.body).map((segment, i) =>
segment.kind === "text" ? (
<span key={i}>{segment.text}</span>
) : (
<CitationMarker
key={i}
citation={byPassage.get(segment.passageId)}
passageId={segment.passageId}
/>
),
)}
</div>
</section>
))}
<ExportButtons runId={report.runId} />
</article>
{/* Citation ledger panel */}
<aside>
<div className="rounded-card border border-hairline bg-paper-raised px-4 py-4">
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Citation ledger
</h2>
<p className="mt-1 text-caption text-ink-faint">
{ledger.citations.length} sources · every claim traceable to a
visited passage
</p>
<ul className="mt-3 space-y-3">
{ledger.citations.map((c) => (
<li key={c.id} className="border-t border-hairline pt-3 first:border-t-0 first:pt-0">
<p className="text-small font-medium text-ink">
<a
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline underline-offset-2"
>
{c.title}
</a>
</p>
<p className="mt-1 text-caption text-ink-soft">
{c.site} · tier {c.authorityTier} ·{" "}
<HealthBadge health={c.verification} />
</p>
<p className="mt-1 text-caption italic text-ink-soft">“{c.quote}”</p>
</li>
))}
</ul>
</div>
</aside>
</div>
</div>
);
}
function HealthBadge({ health }: { health: Citation["verification"] }) {
const styles =
health === "live"
? "bg-success/10 text-success"
: health === "stale"
? "bg-warn/10 text-warn"
: "bg-danger/10 text-danger";
const label = health === "live" ? "✓ live" : health === "stale" ? "△ stale" : "✗ dead";
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${styles}`}>
{label}
</span>
);
}
/**
* A page-anchored citation marker. Hover or keyboard focus reveals the
* exact quoted passage — the trust affordance of the Reading Room.
*/
function CitationMarker({
citation,
passageId,
}: {
citation: Citation | undefined;
passageId: string;
}) {
const [open, setOpen] = useState(false);
if (citation === undefined) {
// Unknown passage id — render inert text rather than a broken link.
return <sup className="text-caption text-ink-faint">[{passageId}]</sup>;
}
const markerIndex = citation.passageId.replace(/^p/, "");
return (
<span className="relative inline-block">
<button
type="button"
aria-label={`Citation ${markerIndex}: ${citation.title}`}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="align-super text-caption font-semibold text-accent underline underline-offset-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
[{markerIndex}]
</button>
{open && (
<span
role="tooltip"
className="absolute bottom-full left-1/2 z-10 mb-2 w-80 max-w-[80vw] -translate-x-1/2 rounded-card border border-hairline bg-paper-raised px-3 py-2 text-left shadow-lg"
>
<span className="block text-caption font-medium text-ink">{citation.title}</span>
<span className="mt-1 block text-caption text-ink-soft">
{citation.site} · tier {citation.authorityTier} · <HealthBadge health={citation.verification} />
</span>
<span className="mt-1 block text-caption italic text-ink">“{citation.quote}”</span>
</span>
)}
</span>
);
}
/** Download buttons for real Markdown/JSON exports (REQ-59). */
function ExportButtons({ runId }: { runId: string }) {
const [busy, setBusy] = useState<"markdown" | "json" | null>(null);
const download = async (format: "markdown" | "json") => {
setBusy(format);
try {
const { filename, content } = await api.exportReport(runId, format);
const blob = new Blob([content], {
type: format === "markdown" ? "text/markdown" : "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
} finally {
setBusy(null);
}
};
return (
<div className="mt-block flex flex-wrap gap-3">
<button
type="button"
disabled={busy !== null}
onClick={() => void download("markdown")}
className="rounded-card border border-hairline bg-paper-raised px-4 py-2 text-small font-semibold text-ink hover:bg-accent-wash disabled:opacity-50"
>
{busy === "markdown" ? "Preparing…" : "↓ Export Markdown"}
</button>
<button
type="button"
disabled={busy !== null}
onClick={() => void download("json")}
className="rounded-card border border-hairline bg-paper-raised px-4 py-2 text-small font-semibold text-ink hover:bg-accent-wash disabled:opacity-50"
>
{busy === "json" ? "Preparing…" : "↓ Export JSON"}
</button>
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
/**
* RefineQuestionButton + RefineDrawer (T-7.3, REQ-68).
*
* Opt-in question refinement: an inline drawer (the dials-drawer
* progressive-disclosure pattern) hosting a back-and-forth exchange —
* one clarifying question at a time, then the composed refined
* question + suggested context. "Use this" applies the composition to
* the form; "Keep original" discards it; the draft is never modified
* until the user accepts. The answer input's Enter guard advances the
* exchange (never the outer form — the input is the only Enter path,
* and all drawer buttons are type="button"); focus returns to the
* trigger when the drawer closes. Scripted in the prototype — honest
* caption says the model-driven version lands in v0.2.
*/
import { useEffect, useRef, useState } from "react";
import {
api,
type RefinedQuestion,
type RefinementQuestion,
} from "@/lib/api/client";
type Exchange =
| { stage: "questions"; items: RefinementQuestion[]; index: number; answers: Record<string, string> }
| { stage: "composing" }
| { stage: "composed"; refined: RefinedQuestion };
export function RefineQuestionButton({
draft,
onApply,
}: {
draft: string;
onApply: (refined: RefinedQuestion) => void;
}) {
const hasDraft = draft.trim().length > 0;
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const close = () => {
setOpen(false);
// Focus returns to the trigger — the drawer never strands the
// keyboard user on <body> (house pattern: avatar → UserMenu).
triggerRef.current?.focus();
};
return (
<>
<button
ref={triggerRef}
type="button"
disabled={!hasDraft}
onClick={() => setOpen(true)}
className="mt-3 rounded-pill border border-hairline bg-paper-raised px-4 py-1.5 text-caption font-medium text-ink-soft transition-colors hover:border-ink-faint hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
>
✎ Refine question
</button>
{open && (
<RefineDrawer
draft={draft}
onClose={close}
onApply={(refined) => {
onApply(refined);
close();
}}
/>
)}
</>
);
}
function RefineDrawer({
draft,
onClose,
onApply,
}: {
draft: string;
onClose: () => void;
onApply: (refined: RefinedQuestion) => void;
}) {
const [exchange, setExchange] = useState<Exchange | null>(null);
const [answer, setAnswer] = useState("");
const [error, setError] = useState(false);
const answerRef = useRef<HTMLInputElement>(null);
// Load the scripted question set on first open.
useEffect(() => {
let cancelled = false;
api
.getRefinementQuestions(draft)
.then((items) => {
if (!cancelled) {
setExchange({ stage: "questions", items, index: 0, answers: {} });
}
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [draft]);
// Escape closes the drawer — the draft stays untouched.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Focus the answer box on every question change.
useEffect(() => {
if (exchange?.stage === "questions") answerRef.current?.focus();
}, [exchange]);
const current =
exchange?.stage === "questions" ? exchange.items[exchange.index] : undefined;
function submitAnswer() {
if (exchange?.stage !== "questions" || current === undefined) return;
if (!current.optional && answer.trim() === "") return;
const answers = { ...exchange.answers, [current.id]: answer.trim() };
setAnswer("");
if (exchange.index + 1 < exchange.items.length) {
setExchange({ ...exchange, index: exchange.index + 1, answers });
return;
}
// Last question answered — compose.
setExchange({ stage: "composing" });
api
.composeRefinedQuestion(draft, answers)
.then((refined) => {
setExchange({ stage: "composed", refined });
})
.catch(() => {
setError(true);
setExchange(null);
});
}
function restart() {
setAnswer("");
setError(false);
api
.getRefinementQuestions(draft)
.then((items) => {
setExchange({ stage: "questions", items, index: 0, answers: {} });
})
.catch(() => setError(true));
}
return (
<section
aria-label="Refine your question"
className="mt-3 rounded-card border border-hairline bg-accent-wash px-5 py-5"
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-small font-semibold uppercase tracking-wide text-accent-ink">
Refine your question
</h3>
{exchange?.stage === "questions" && (
<p className="text-caption text-ink-faint" role="status">
{exchange.index + 1} of {exchange.items.length}
</p>
)}
</div>
{/* The draft stays visible — the exchange is about THIS text. */}
<blockquote className="measure-reading mt-2 border-l-2 border-accent pl-3 text-small text-ink">
{draft}
</blockquote>
{error && (
<p role="alert" className="mt-3 text-caption text-danger">
Refine is unavailable right now — try again.
</p>
)}
{error && (
<div className="mt-3">
<button
type="button"
onClick={onClose}
className="rounded-pill border border-hairline px-4 py-1.5 text-caption font-medium text-ink-soft transition-colors hover:text-ink"
>
Keep original
</button>
</div>
)}
{exchange === null && !error && (
<p className="mt-3 text-caption text-ink-faint">Preparing questions…</p>
)}
{exchange?.stage === "questions" && current !== undefined && (
<div className="mt-4">
<label
htmlFor="refine-answer"
className="block text-small font-medium text-ink"
>
{current.prompt}
{!current.optional && (
<span className="text-ink-faint"> (required)</span>
)}
</label>
<input
ref={answerRef}
id="refine-answer"
value={answer}
onChange={(event) => setAnswer(event.target.value)}
onKeyDown={(event) => {
// Enter advances the exchange — the input lives inside the
// outer <form>, so without this guard Enter would submit
// the form and navigate away mid-refine.
if (event.key === "Enter") {
event.preventDefault();
submitAnswer();
}
}}
placeholder={current.placeholder}
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-3 py-2 text-small text-ink placeholder:text-ink-faint"
/>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={submitAnswer}
disabled={!current.optional && answer.trim() === ""}
className="rounded-pill bg-accent px-4 py-1.5 text-caption font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{exchange.index + 1 < exchange.items.length ? "Next" : "Compose question"}
</button>
{current.optional && (
<button
type="button"
onClick={submitAnswer}
className="rounded-pill border border-hairline px-4 py-1.5 text-caption text-ink-soft transition-colors hover:text-ink"
>
Skip
</button>
)}
<button
type="button"
onClick={onClose}
className="ml-auto rounded-pill px-3 py-1.5 text-caption text-ink-faint transition-colors hover:text-ink"
>
Keep original
</button>
</div>
</div>
)}
{exchange?.stage === "composing" && (
<p className="mt-4 text-caption text-ink-faint" role="status">
Composing your refined question…
</p>
)}
{exchange?.stage === "composed" && (
<div className="mt-4">
<p className="text-caption font-medium text-ink">Refined question</p>
<p className="measure-reading mt-1 rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink">
{exchange.refined.question}
</p>
<p className="mt-3 text-caption font-medium text-ink">Suggested context</p>
<p className="measure-reading mt-1 rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink-soft">
{exchange.refined.context}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onApply(exchange.refined)}
className="rounded-pill bg-accent px-4 py-1.5 text-caption font-medium text-paper-raised transition-colors hover:bg-accent-strong"
>
Use this
</button>
<button
type="button"
onClick={restart}
className="rounded-pill border border-hairline px-4 py-1.5 text-caption text-ink-soft transition-colors hover:text-ink"
>
Restart
</button>
<button
type="button"
onClick={onClose}
className="ml-auto rounded-pill px-3 py-1.5 text-caption text-ink-faint transition-colors hover:text-ink"
>
Keep original
</button>
</div>
</div>
)}
<p className="mt-4 border-t border-hairline pt-2 text-caption text-ink-faint">
Scripted refinement in the prototype — model-driven refine lands in v0.2.
</p>
</section>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Plan & Run screen tests (T-2.11, REQ-57).
*
* - Plan approval stage renders sub-questions/universe/outline + Approve
* - Approve → status change → trace events stream in progressively
* - Pause halts event arrival; Resume continues
* - Cancel → cancelled state with link back to /choose
* - Replay completion → done state with link to the Reading Room
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RunView } from "@/components/run-view";
import { api } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
const QUESTION = "What are the latest advances in solid-state batteries?";
async function makeRun() {
return api.createRun({ question: QUESTION, methodologySlug: "deep-report" });
}
describe("RunView — Plan & Run", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
pushMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("shows the plan with sub-questions, source universe, and outline before approval", async () => {
const run = await makeRun();
render(<RunView id={run.id} />);
expect(
await screen.findByText("Research plan — approval required"),
).toBeInTheDocument();
expect(
screen.getByText("What is the current state of solid-state battery technology?"),
).toBeInTheDocument();
expect(
screen.getByText("open web (technology and industry press)"),
).toBeInTheDocument();
expect(screen.getByText("Executive summary")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Approve plan & start research/i }),
).toBeEnabled();
});
it("approve streams trace events progressively and reaches done", async () => {
const run = await makeRun();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<RunView id={run.id} />);
const approve = await screen.findByRole("button", {
name: /Approve plan & start research/i,
});
await user.click(approve);
// First trace event arrives (progressive — not all at once).
expect(await screen.findByText(/Plan drafted/i)).toBeInTheDocument();
// The plan stage is replaced by the streaming stage.
expect(
screen.queryByText("Research plan — approval required"),
).not.toBeInTheDocument();
// Let the whole scripted trace replay through fake time.
await vi.advanceTimersByTimeAsync(23 * 150 + 100);
// Done stage: report link points at the Reading Room route.
const reportLink = await screen.findByRole("link", { name: /Read the report/i });
expect(reportLink).toHaveAttribute("href", `/run/${run.id}/report`);
expect((await api.getRun(run.id)).status).toBe("done");
});
it("pause halts the stream; resume continues it", async () => {
const run = await makeRun();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<RunView id={run.id} />);
await user.click(
await screen.findByRole("button", { name: /Approve plan & start research/i }),
);
await vi.advanceTimersByTimeAsync(3 * 150);
const eventsAtPause = screen.getAllByRole("listitem").length;
await user.click(screen.getByRole("button", { name: /⏸ Pause/i }));
await vi.advanceTimersByTimeAsync(5 * 150);
// Paused: no new events rendered.
expect(screen.getAllByRole("listitem")).toHaveLength(eventsAtPause);
await user.click(screen.getByRole("button", { name: /▶ Resume/i }));
await vi.advanceTimersByTimeAsync(5 * 150);
// Resumed: events continue arriving.
expect(screen.getAllByRole("listitem").length).toBeGreaterThan(eventsAtPause);
});
it("cancel stops the run and links back to /choose", async () => {
const run = await makeRun();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<RunView id={run.id} />);
await user.click(
await screen.findByRole("button", { name: /Approve plan & start research/i }),
);
await vi.advanceTimersByTimeAsync(2 * 150);
await user.click(screen.getByRole("button", { name: /Cancel/i }));
expect(await screen.findByText("Run cancelled")).toBeInTheDocument();
const back = screen.getByRole("link", {
name: /Choose a methodology to start over/i,
});
expect(back).toHaveAttribute("href", "/choose");
expect((await api.getRun(run.id)).status).toBe("cancelled");
});
});
+362
View File
@@ -0,0 +1,362 @@
"use client";
/**
* RunView (T-2.4..T-2.6, REQ-57) — the /run/[id] Plan & Run screen.
*
* Three stages mirroring the real engine:
* 1. awaiting_approval — the plan (sub-questions, source universe,
* outline) with Approve as the sole primary action
* 2. streaming — the scripted live trace with Pause/Resume + Cancel
* 3. done/cancelled — terminal states with onward links
*
* All data flows through the ApiClient singleton; the mock binding
* keeps run state in browser module state, so this is a client
* component (same pattern as P1).
*/
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type RunEventSubscription } from "@/lib/api/client";
import type { Methodology, ResearchRun, TraceEvent } from "@/lib/types";
type Stage =
| { kind: "loading" }
| { kind: "error"; message: string }
| {
kind: "plan";
run: ResearchRun;
methodology: Methodology | undefined;
approving: boolean;
}
| {
kind: "stream";
run: ResearchRun;
methodology: Methodology | undefined;
events: TraceEvent[];
paused: boolean;
}
| { kind: "done"; run: ResearchRun }
| { kind: "cancelled" };
const EVENT_GLYPHS: Record<TraceEvent["type"], string> = {
query: "?",
source_visited: "◆",
passage_extracted: "✂",
synthesis: "✍",
verification: "✓",
plan: "▤",
status: "▸",
};
/** HH:MM:SS in the fixture's UTC timeline — the trace clock. */
function formatTime(ts: string): string {
return new Date(ts).toISOString().slice(11, 19);
}
export function RunView({ id }: { id: string }) {
const [stage, setStage] = useState<Stage>({ kind: "loading" });
const subscriptionRef = useRef<RunEventSubscription | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
// Initial load: run + methodology for the card header.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const run = await api.getRun(id);
if (cancelled) return;
if (run.status === "cancelled") {
setStage({ kind: "cancelled" });
return;
}
const methodology = (await api.getMethodologies()).find(
(m) => m.slug === run.plan.methodologySlug,
);
if (cancelled) return;
if (run.status === "awaiting_approval" || run.status === "planning") {
setStage({ kind: "plan", run, methodology, approving: false });
} else if (run.status === "done") {
setStage({ kind: "done", run });
} else {
// Mid-stream reload: show plan stage; Approve resumes the flow.
setStage({ kind: "plan", run: { ...run, status: "awaiting_approval" }, methodology, approving: false });
}
} catch (cause) {
if (!cancelled) {
setStage({
kind: "error",
message: cause instanceof Error ? cause.message : "Run not found",
});
}
}
})();
return () => {
cancelled = true;
subscriptionRef.current?.cancel();
subscriptionRef.current = null;
};
}, [id]);
// Auto-scroll the trace container to the newest event while streaming.
// The container itself is the scroller — scrollTop, not scrollIntoView.
// (scrollIntoView would move ancestors, not the inner fold.)
useEffect(() => {
if (stage.kind === "stream" && !stage.paused) {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
}, [stage]);
const beginStream = useCallback(
(run: ResearchRun, methodology: Methodology | undefined) => {
setStage({ kind: "stream", run, methodology, events: [], paused: false });
const subscription = api.getRunEvents(
run.id,
(event) => {
setStage((s) =>
s.kind === "stream" ? { ...s, events: [...s.events, event] } : s,
);
},
() => {
// DONE arrives via the stream terminal event (engine-owned
// in v0.2); locally the mock has already persisted it.
setStage({ kind: "done", run });
},
);
subscriptionRef.current = subscription;
},
[],
);
const approve = useCallback(
async (run: ResearchRun, methodology: Methodology | undefined) => {
setStage((s) => (s.kind === "plan" ? { ...s, approving: true } : s));
try {
const approved = await api.approvePlan(run.id);
beginStream(approved, methodology);
} catch {
setStage((s) =>
s.kind === "plan" ? { ...s, approving: false } : s,
);
}
},
[beginStream],
);
const togglePause = useCallback(() => {
setStage((s) => {
if (s.kind !== "stream") return s;
if (s.paused) subscriptionRef.current?.resume();
else subscriptionRef.current?.pause();
return { ...s, paused: !s.paused };
});
}, []);
const cancel = useCallback(async () => {
subscriptionRef.current?.cancel();
subscriptionRef.current = null;
await api.cancelRun(id).catch(() => undefined);
setStage({ kind: "cancelled" });
}, [id]);
if (stage.kind === "loading") {
return <p className="mt-8 text-small text-ink-faint">Loading run…</p>;
}
if (stage.kind === "error") {
return (
<div>
<p role="alert" className="mt-8 text-small text-danger">
{stage.message}
</p>
<p className="mt-block text-caption">
<Link href="/ask" className="text-accent underline underline-offset-2">
Ask a question
</Link>
</p>
</div>
);
}
if (stage.kind === "cancelled") {
return (
<div>
<h1 className="measure-reading mt-2 text-display font-semibold text-ink">
Run cancelled
</h1>
<p className="mt-4 measure-reading text-body text-ink-soft">
The research run was stopped. No report was written.
</p>
<p className="mt-block text-small">
<Link href="/choose" className="text-accent underline underline-offset-2">
Choose a methodology to start over
</Link>
</p>
</div>
);
}
if (stage.kind === "done") {
return (
<div>
<p className="mt-2 inline-flex items-center gap-2 rounded-full border border-hairline bg-accent-wash px-3 py-1 text-caption font-medium text-accent-ink">
✓ Run complete
</p>
<h1 className="measure-reading mt-4 text-display font-semibold text-ink">
{stage.run.plan.question}
</h1>
<p className="mt-4 text-small text-ink-soft">
The report and citation ledger are ready in the Reading Room.
</p>
<p className="mt-block">
<Link
href={`/run/${stage.run.id}/report`}
className="inline-flex items-center gap-2 rounded-card bg-accent px-4 py-2 text-small font-semibold text-paper hover:opacity-90"
>
Read the report
</Link>
</p>
<p className="mt-3 text-caption">
<Link href="/library" className="text-accent underline underline-offset-2">
Library
</Link>
</p>
</div>
);
}
const { run, methodology } = stage;
return (
<div>
<h1 className="measure-reading mt-2 text-display font-semibold text-ink">
{run.plan.question}
</h1>
<p className="mt-4 text-small text-ink-soft">
Methodology:{" "}
<span className="font-medium text-ink">
{methodology?.name ?? run.plan.methodologySlug}
</span>
{run.plan.context !== undefined && (
<>
{" "}
· Context: <span className="text-ink">{run.plan.context}</span>
</>
)}
</p>
{stage.kind === "plan" ? (
<div className="mt-block">
<div className="rounded-card border border-hairline bg-paper-raised px-5 py-5">
<p className="text-caption font-semibold uppercase tracking-wide text-ink-faint">
Research plan — approval required
</p>
<h2 className="mt-2 text-body font-semibold text-ink">
Sub-questions
</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-small text-ink">
{run.plan.subQuestions.map((q) => (
<li key={q}>{q}</li>
))}
</ol>
<h2 className="mt-4 text-body font-semibold text-ink">
Source universe
</h2>
<ul className="mt-2 flex flex-wrap gap-2">
{run.plan.sourceUniverse.map((s) => (
<li
key={s}
className="rounded-full border border-hairline bg-paper px-3 py-1 text-caption text-ink-soft"
>
{s}
</li>
))}
</ul>
<h2 className="mt-4 text-body font-semibold text-ink">Outline</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-small text-ink">
{run.plan.outline.map((o) => (
<li key={o}>{o}</li>
))}
</ol>
</div>
<p className="mt-6">
<button
type="button"
disabled={stage.approving}
onClick={() => void approve(run, methodology)}
className="inline-flex items-center gap-2 rounded-card bg-accent px-4 py-2 text-small font-semibold text-paper hover:opacity-90 disabled:opacity-50"
>
{stage.approving ? "Starting…" : "Approve plan & start research"}
</button>
</p>
</div>
) : null}
{stage.kind === "stream" ? (
<div className="mt-block">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={togglePause}
className="rounded-card border border-hairline bg-paper-raised px-4 py-2 text-small font-semibold text-ink hover:bg-accent-wash"
aria-pressed={stage.paused}
>
{stage.paused ? "▶ Resume" : "⏸ Pause"}
</button>
<button
type="button"
onClick={() => void cancel()}
className="rounded-card border border-danger/40 px-4 py-2 text-small font-semibold text-danger hover:bg-danger/5"
>
Cancel
</button>
<span className="text-caption text-ink-faint">
{stage.events.length} events{stage.paused ? " · paused" : ""}
</span>
</div>
<div
ref={listRef}
aria-live="polite"
aria-label="Live research trace"
className="mt-4 max-h-96 overflow-y-auto rounded-card border border-hairline bg-paper-raised px-4 py-3"
>
{stage.events.length === 0 ? (
<p className="text-caption text-ink-faint">
Research starting — first events arriving…
</p>
) : (
<ul className="space-y-2">
{stage.events.map((event) => (
<li key={event.id} className="flex gap-3 text-small">
<span
aria-hidden
className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-hairline text-caption text-accent-ink"
>
{EVENT_GLYPHS[event.type]}
</span>
<span className="text-ink">
{event.message}
{event.detail !== undefined && (
<span className="block text-caption text-ink-faint">
{event.detail}
</span>
)}
</span>
<time
dateTime={event.ts}
className="ml-auto shrink-0 pt-0.5 text-caption text-ink-faint"
>
{formatTime(event.ts)}
</time>
</li>
))}
</ul>
)}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,112 @@
/**
* Settings screen tests (T-5.4, REQ-65).
*
* - All five sections render from the boundary: API keys (masked,
* configured state), storage directory, methodology defaults with
* the resolved 12-dial display, appearance (preview), about/version
* - Editing + saving round-trips through api.saveSettings
* - Save button is disabled until dirty; status feedback after save
*/
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { SettingsView } from "@/components/settings-view";
import { api } from "@/lib/api/client";
describe("SettingsView (REQ-65)", () => {
afterEach(cleanup);
it("renders the API-keys section with masked values and honest status", async () => {
render(<SettingsView />);
expect(await screen.findByText("API keys")).toBeInTheDocument();
expect(await screen.findByText("Model API key")).toBeInTheDocument();
expect(screen.getByText("Ollama cloud (ollama.com/v1)")).toBeInTheDocument();
// Configured key shows its mask — never a raw key.
expect(screen.getByText("sk-…7f2a")).toBeInTheDocument();
// Unconfigured key is honest about it.
expect(screen.getByText("Not configured")).toBeInTheDocument();
});
it("renders storage directory from the boundary and allows edits", async () => {
const user = userEvent.setup();
render(<SettingsView />);
const input = await screen.findByRole("textbox", { name: /Reports directory/i });
expect(input).toHaveValue("~/researchx/reports");
await user.clear(input);
await user.type(input, "~/researchx/archive");
expect(input).toHaveValue("~/researchx/archive");
});
it("renders methodology defaults with the resolved 12-dial display", async () => {
render(<SettingsView />);
const section = await screen.findByRole("region", { name: /Methodology defaults/i });
expect(within(section).getByLabelText(/Default methodology/i)).toBeTruthy();
// The resolved dials of the default methodology (deep-report):
// fixture defaults — depth 3, breadth 2, per-claim citations.
const dialRow = (label: string) =>
within(section)
.getAllByText(label)
.filter((el) => el.tagName === "DT")[0];
expect(dialRow("Depth")).toBeInTheDocument();
expect(dialRow("Breadth")).toBeInTheDocument();
expect(dialRow("Citation rigor")).toBeInTheDocument();
expect(within(section).getByText("Per-Claim")).toBeInTheDocument();
expect(within(section).getByText("Depth")).toBeInTheDocument();
});
it("the resolved dial display follows the selected methodology live", async () => {
const user = userEvent.setup();
render(<SettingsView />);
const section = await screen.findByRole("region", { name: /Methodology defaults/i });
// deep-report resolves to hybrid routing; Rapid Answer is web-only.
expect(within(section).getByText("Hybrid")).toBeInTheDocument();
await user.selectOptions(
within(section).getByLabelText(/Default methodology/i),
"rapid-answer",
);
// The dial preview updates to Rapid Answer's bundle — memo output.
expect(within(section).getAllByText("Memo").length).toBeGreaterThanOrEqual(1);
expect(within(section).queryByText("Hybrid")).toBeNull();
});
it("save is disabled until dirty; saving round-trips through the boundary", async () => {
const user = userEvent.setup();
render(<SettingsView />);
const save = await screen.findByRole("button", { name: /Save changes/i });
expect(save).toBeDisabled();
const input = screen.getByRole("textbox", { name: /Reports directory/i });
await user.clear(input);
await user.type(input, "~/researchx/archive");
expect(save).toBeEnabled();
await user.click(save);
expect(
await screen.findByText(/Saved — stored on this machine/i),
).toBeInTheDocument();
// Round-trip: a fresh boundary read sees the saved value.
const settings = await api.getSettings();
expect(settings.storageDirectory).toBe("~/researchx/archive");
// Restore the fixture default for other suites.
await api.saveSettings({ storageDirectory: "~/researchx/reports" });
});
it("renders appearance preview and about/version sections", async () => {
render(<SettingsView />);
expect(await screen.findByText("Appearance")).toBeInTheDocument();
expect(screen.getByText("Paper (light)")).toBeInTheDocument();
expect(screen.getByText("preview", { exact: true })).toBeInTheDocument();
expect(screen.getByText(/About & version|About and version/i)).toBeInTheDocument();
expect(screen.getByText(/v0\.1\.8 · prototype/i)).toBeInTheDocument();
});
});
+323
View File
@@ -0,0 +1,323 @@
"use client";
/**
* SettingsView (T-5.4, REQ-65) — the /settings screen.
*
* Five sections, all through the ApiClient boundary:
* - API keys: masked values (raw keys never reach the UI), endpoint
* labels, configured state
* - Storage: local directory reports are written to
* - Methodology defaults: default methodology + resolved 12-dial
* display (the REQ-04 reproducibility vocabulary)
* - Appearance: theme slot — preview-marked (paper palette is
* light-only today)
* - About & version: prototype notice + version
*
* Save persists through api.saveSettings; the mock round-trips it.
*/
import { useEffect, useState } from "react";
import {
api,
type Methodology,
type Settings,
} from "@/lib/api/client";
import { formatDialValue } from "@/lib/dials";
type SettingsState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; settings: Settings; methodologies: Methodology[] };
export function SettingsView() {
const [state, setState] = useState<SettingsState>({ kind: "loading" });
const [storage, setStorage] = useState("");
const [defaultSlug, setDefaultSlug] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [settings, methodologies] = await Promise.all([
api.getSettings(),
api.getMethodologies(),
]);
if (!cancelled) {
setState({ kind: "ready", settings, methodologies });
setStorage(settings.storageDirectory);
setDefaultSlug(settings.defaultMethodologySlug);
}
} catch (cause) {
if (!cancelled) {
setState({
kind: "error",
message: cause instanceof Error ? cause.message : "Settings unavailable",
});
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (state.kind === "loading") {
return <p className="mt-8 text-small text-ink-faint">Loading settings…</p>;
}
if (state.kind === "error") {
return <p role="alert" className="mt-8 text-small text-danger">{state.message}</p>;
}
const { settings, methodologies } = state;
const dirty =
storage !== settings.storageDirectory ||
defaultSlug !== settings.defaultMethodologySlug;
const save = async () => {
setSaveState("saving");
try {
const saved = await api.saveSettings({
storageDirectory: storage,
defaultMethodologySlug: defaultSlug,
});
setStorage(saved.storageDirectory);
setDefaultSlug(saved.defaultMethodologySlug);
// Refresh the ready state so the resolved dial display and the
// dirty check reflect the SAVED payload, not the stale one.
setState({ kind: "ready", settings: saved, methodologies });
setSaveState("saved");
} catch {
setSaveState("error");
}
};
return (
<div>
<h1 className="text-display font-semibold text-ink">Settings</h1>
<p className="measure-reading mt-2 text-body text-ink-soft">
Model and search keys, where reports are stored, and the defaults
new research runs start from.
</p>
<div className="mt-block space-y-8">
{/* ---- API keys --------------------------------------------- */}
<section
aria-label="API keys"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
API keys
</h2>
<p className="mt-1 text-caption text-ink-soft">
Keys stay on this machine — stored masked, never displayed raw.
</p>
<ul className="mt-4 space-y-3">
{settings.apiKeys.map((key) => (
<li
key={key.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-card border border-hairline bg-paper px-4 py-3"
>
<div>
<p className="text-small font-medium text-ink">{key.label}</p>
<p className="text-caption text-ink-faint">{key.endpoint}</p>
</div>
<span
className={`rounded-pill px-2.5 py-0.5 text-caption ${
key.configured
? "bg-success-wash text-success"
: "bg-warn-wash text-warn"
}`}
>
{key.configured ? key.maskedValue : "Not configured"}
</span>
</li>
))}
</ul>
<p className="mt-3 text-caption text-ink-faint">
Key entry lands with the v0.2 adapter layer — the prototype
displays status only.
</p>
</section>
{/* ---- Storage ---------------------------------------------- */}
<section
aria-label="Storage"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Storage
</h2>
<p className="mt-1 text-caption text-ink-soft">
Local directory finished reports are written to — no upload
quotas, plain Markdown and JSON on disk.
</p>
<label className="mt-4 block">
<span className="text-small font-medium text-ink">Reports directory</span>
<input
type="text"
value={storage}
onChange={(event) => {
setStorage(event.target.value);
setSaveState("idle");
}}
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
/>
</label>
</section>
{/* ---- Methodology defaults ---------------------------------- */}
<section
aria-label="Methodology defaults"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Methodology defaults
</h2>
<p className="mt-1 text-caption text-ink-soft">
The methodology new runs start from, and the dials it resolves
to — every run snapshots these (REQ-04).
</p>
<label className="mt-4 block">
<span className="text-small font-medium text-ink">Default methodology</span>
<select
value={defaultSlug}
onChange={(event) => {
setDefaultSlug(event.target.value);
setSaveState("idle");
}}
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper px-3 py-2 text-body text-ink"
>
{methodologies.map((m) => (
<option key={m.slug} value={m.slug}>
{m.name}
</option>
))}
</select>
</label>
<DialDisplay
selectedSlug={defaultSlug}
settings={settings}
methodologies={methodologies}
/>
</section>
{/* ---- Appearance -------------------------------------------- */}
<section
aria-label="Appearance"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Appearance
</h2>
<p className="mt-1 text-caption text-ink-soft">
Theme — one careful light theme today.
</p>
<div className="mt-4 flex items-center gap-3">
<span className="rounded-pill border border-hairline bg-paper-sunken px-3 py-1 text-small text-ink">
Paper (light)
</span>
<span className="rounded-full border border-hairline px-2 text-caption text-ink-faint">
preview
</span>
</div>
<p className="mt-2 text-caption text-ink-faint">
Dark mode is a deliberate post-MVP pass — the reading
typography comes first.
</p>
</section>
{/* ---- About ------------------------------------------------ */}
<section
id="about"
aria-label="About and version"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
About &amp; version
</h2>
<p className="mt-2 text-body text-ink">
ResearchX — deep-research studio. Methodology-first selection,
visited-only citations, three clicks to a trustworthy report.
</p>
<p className="mt-2 text-caption text-ink-faint">
v0.1.8 · prototype — runs on scripted mock data; the engine,
adapters, and trust loop arrive in v0.2.
</p>
</section>
{/* ---- Save -------------------------------------------------- */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={save}
disabled={!dirty || saveState === "saving"}
className="rounded-pill bg-accent px-5 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{saveState === "saving" ? "Saving…" : "Save changes"}
</button>
{saveState === "saved" && (
<p role="status" className="text-caption text-success">
Saved — stored on this machine.
</p>
)}
{saveState === "error" && (
<p role="alert" className="text-caption text-danger">
Save failed — try again.
</p>
)}
</div>
</div>
</div>
);
}
/**
* The resolved default-dial display — labels from the DIALS registry.
* Previews the currently selected methodology (live), falling back to
* the saved payload's resolved dials when the selection is unknown.
*/
function DialDisplay({
selectedSlug,
settings,
methodologies,
}: {
selectedSlug: string;
settings: Settings;
methodologies: Methodology[];
}) {
const chosen =
methodologies.find((m) => m.slug === selectedSlug) ??
methodologies.find((m) => m.slug === settings.defaultMethodologySlug) ??
methodologies[0];
const dials = chosen?.defaultDials ?? settings.defaultDials;
const rows: [string, string][] = [
["Source universe", formatDialValue(dials.sourceUniverse)],
["Search routing", formatDialValue(dials.searchRouting)],
["Query strategy", formatDialValue(dials.queryStrategy)],
["Breadth", String(dials.breadth)],
["Depth", String(dials.depth)],
["Quality gates", formatDialValue(dials.qualityGates)],
["Citation rigor", formatDialValue(dials.citationRigor)],
["Verification passes", String(dials.verificationPasses)],
["Synthesis style", formatDialValue(dials.synthesisStyle)],
["Output structure", formatDialValue(dials.outputStructure)],
["Memory scope", formatDialValue(dials.memoryScope)],
["Checkpoints", formatDialValue(dials.checkpoints)],
];
return (
<dl className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 rounded-card border border-hairline bg-paper px-4 py-4 sm:grid-cols-2">
{rows.map(([label, value]) => (
<div key={label} className="flex items-baseline justify-between gap-2">
<dt className="text-caption text-ink-faint">{label}</dt>
<dd className="text-caption font-medium text-ink">{value}</dd>
</div>
))}
</dl>
);
}
@@ -0,0 +1,94 @@
/**
* 3-click happy-path validation (T-3.5, REQ-61).
*
* With defaults only, a new user reaches a streaming research run in
* at most 3 discrete clicks:
* 1. "Research this" (Ask)
* 2. "Start research" (Choose — Deep Report, the recommended default)
* 3. "Approve plan" (Plan & Run)
* The test drives the REAL components against the REAL mock binding
* and counts the clicks — the criterion is pinned, not assumed.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AskView } from "@/components/ask-view";
import { MethodologyCard } from "@/components/methodology-card";
import { RunView } from "@/components/run-view";
import { api } from "@/lib/api/client";
import { clearAskState, getAskState } from "@/lib/ask-store";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
const QUESTION = "What are the latest advances in solid-state batteries?";
describe("3-click happy path (REQ-61)", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
pushMock.mockClear();
clearAskState();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("reaches a streaming run in ≤3 clicks on defaults", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let clicks = 0;
/* Screen 1 — Ask ***********************************************/
// Suggestions + refine are opt-in affordances — the default path
// (type → submit) never touches them, so the click count holds.
const { unmount: unmountAsk } = render(<AskView />);
const questionBox = screen.getByLabelText(/what do you want to know\?/i);
await user.type(questionBox, QUESTION);
// Click 1: Research this — defaults mean empty context is fine.
await user.click(screen.getByRole("button", { name: "Research this" }));
clicks += 1;
// State carried: the question survives navigation to Choose.
const carried = getAskState();
if (carried === undefined) throw new Error("ask state not carried to /choose");
expect(carried.question).toBe(QUESTION);
unmountAsk();
/* Screen 2 — Choose ********************************************/
const methodologies = await api.getMethodologies();
const recommended = methodologies.find((m) => m.recommended);
if (recommended === undefined) throw new Error("no recommended default");
const { unmount: unmountChoose } = render(
<MethodologyCard methodology={recommended} question={QUESTION} />,
);
// Click 2: Start research — on the recommended default card,
// no browsing or drawer interaction needed.
await user.click(screen.getByRole("button", { name: "Start research" }));
clicks += 1;
const runRoute = pushMock.mock.calls
.flat()
.find((c): c is string => typeof c === "string" && c.startsWith("/run/"));
if (runRoute === undefined) throw new Error("no run route pushed");
expect(runRoute).toMatch(/^\/run\/run-[a-z0-9]+$/);
unmountChoose();
/* Screen 3 — Plan & Run ******************************************/
const runId = runRoute.replace("/run/", "");
render(<RunView id={runId} />);
// Click 3: Approve plan & start research — trace starts streaming.
const approve = await screen.findByRole("button", {
name: /Approve plan & start research/i,
});
await user.click(approve);
clicks += 1;
// Criterion: a streaming run reached with at most 3 clicks.
expect(clicks).toBeLessThanOrEqual(3);
// And the stream is live: the first trace event has arrived.
expect(await screen.findByText(/Plan drafted/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /⏸ Pause/i })).toBeInTheDocument();
});
});
+115
View File
@@ -0,0 +1,115 @@
"use client";
/**
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
*
* Local single-user profile (no auth — single-user constraint), links to
* Settings and About (the settings page's about section). Escape and
* outside clicks dismiss. Rendered as a menu for assistive tech.
*/
import Link from "next/link";
import { useEffect, useRef, useState, type RefObject } from "react";
import { api, type UserProfile } from "@/lib/api/client";
export function UserMenu({
onNavigate,
onDismiss,
toggleRef,
}: {
onNavigate: () => void;
onDismiss: () => void;
/** The avatar button — clicks on it toggle, not dismiss-then-reopen. */
toggleRef?: RefObject<HTMLButtonElement | null>;
}) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
api
.getUserProfile()
.then((result) => {
if (!cancelled) setProfile(result);
})
.catch(() => {
/* fallback initials render below */
});
return () => {
cancelled = true;
};
}, []);
// Escape closes; outside clicks close. Focus stays in the header.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onDismiss();
};
const onClick = (event: MouseEvent) => {
const target = event.target as Node;
if (toggleRef?.current?.contains(target)) return;
if (menuRef.current && !menuRef.current.contains(target)) {
onDismiss();
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onClick);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousedown", onClick);
};
}, [onDismiss, toggleRef]);
return (
<div
ref={menuRef}
role="menu"
aria-label="User menu"
className="absolute top-14 right-6 z-30 w-64 rounded-card border border-hairline bg-paper-raised p-4 shadow-lg"
>
<div className="flex items-center gap-3 border-b border-hairline pb-3">
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
>
{profile?.initials ?? "··"}
</span>
<div>
<p className="text-small font-medium text-ink">
{profile?.name ?? "Researcher"}
</p>
<p className="text-caption text-ink-faint">
{profile?.tagline ?? "Local workspace"}
</p>
</div>
</div>
<ul className="pt-2">
<li>
<Link
role="menuitem"
href="/settings"
onClick={onNavigate}
className="block rounded-card px-2 py-2 text-small text-ink transition-colors hover:bg-paper-sunken"
>
Settings
</Link>
</li>
<li>
<Link
role="menuitem"
href="/settings#about"
onClick={onNavigate}
className="block rounded-card px-2 py-2 text-small text-ink transition-colors hover:bg-paper-sunken"
>
About &amp; version
</Link>
</li>
</ul>
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
Local profile — accounts and sync land post-MVP.
</p>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
/**
* ApiClient — the single data-access boundary (T-1.4, REQ-53, REQ-54).
*
* Components consume ONLY this interface via the `api` singleton. In
* v0.1 the singleton is bound to MockApiClient; in v0.2 phase 3 the
* binding swaps to the OpenAPI-generated client with no component
* rewrites. This is the ONLY module allowed to import MockApiClient.
*/
import type {
CitationLedger,
ConnectionSlot,
DialValues,
InboxItem,
Methodology,
ProjectGraph,
RefinementQuestion,
RefinedQuestion,
Report,
ReportConnection,
ReportLibrary,
ResearchRun,
Settings,
SuggestedQuery,
TraceEvent,
UserProfile,
} from "@/lib/types";
import { MockApiClient } from "@/lib/api/mock/mock-client";
export type { Report, ReportSection } from "@/lib/types";
export type {
Citation,
CitationLedger,
ConnectionSlot,
DialValues,
GraphEdge,
GraphNode,
InboxItem,
Methodology,
ProjectGraph,
RefinementQuestion,
RefinedQuestion,
ReportConnection,
ReportConnectionKind,
ReportFolder,
ReportLibrary,
ReportSortKey,
ReportSummary,
ResearchRun,
RunPlan,
RunStatus,
Settings,
SuggestedQuery,
Task,
TraceEvent,
UserProfile,
} from "@/lib/types";
/** Input to createRun — the Ask + Choose screens' combined payload. */
export interface CreateRunInput {
question: string;
context?: string;
methodologySlug: string;
dialOverrides?: Partial<DialValues>;
}
/**
* Subscription handle for the live trace stream (EventSource-shaped).
* v0.2 backs this with SSE; pause/resume stay client-side concerns.
*/
export interface RunEventSubscription {
/** Stop the stream and release its timers (idempotent). */
cancel(): void;
/** Halt event delivery — the stream buffers until resumed. */
pause(): void;
/** Continue delivering buffered + incoming events. */
resume(): void;
}
/**
* The backend-swappable contract. Every screen talks to this — never
* to a concrete client, never to the database, never to the network.
*/
export interface ApiClient {
/** Methodology catalog: cards with ETA, cost, why-recommended. */
getMethodologies(): Promise<Methodology[]>;
/** Start a research run for a question + methodology (+ dial tweaks). */
createRun(input: CreateRunInput): Promise<ResearchRun>;
/** Fetch a run by id (plan, status, and — after replay — events). */
getRun(id: string): Promise<ResearchRun>;
/** Approve the plan; the run proceeds past AWAITING_APPROVAL. */
approvePlan(id: string): Promise<ResearchRun>;
/** Cancel a streaming run — terminal state (maps to POST /api/runs/{id}/cancel). */
cancelRun(id: string): Promise<ResearchRun>;
/**
* Stream a run's trace events to `onEvent` as they replay/arrive,
* then invoke `onDone`. Returns a handle that cancels the stream.
* EventSource-shaped so v0.2 can back it with SSE unchanged.
*/
getRunEvents(
id: string,
onEvent: (event: TraceEvent) => void,
onDone?: () => void,
): RunEventSubscription;
/** The final report for a run (sections may carry [^passageId] markers). */
getReport(id: string): Promise<Report>;
/** The run's citation ledger (URL, passage, quote, health, tier). */
getLedger(id: string): Promise<CitationLedger>;
/** Export a run's report as a real downloadable Markdown/JSON file. */
exportReport(
id: string,
format: "markdown" | "json",
): Promise<{ filename: string; content: string }>;
/** The cross-project graph mockup (maps to GET /api/graph in v0.2). */
getProjectGraph(): Promise<ProjectGraph>;
/** The task inbox mockup (maps to GET /api/tasks in v0.2). */
getTaskInbox(): Promise<InboxItem[]>;
/**
* The stored-reports library: folders + report summaries
* (maps to GET /api/library in v0.2 — REQ-64).
*/
getReportLibrary(): Promise<ReportLibrary>;
/**
* Cross-report connections — shared-source and topical edges
* between stored reports (maps to GET /api/library/connections
* in v0.2 — REQ-64).
*/
getReportConnections(): Promise<ReportConnection[]>;
/** The local single-user profile behind the avatar (REQ-66). */
getUserProfile(): Promise<UserProfile>;
/** Adapter-slot connection status for the shell + settings (REQ-65). */
getConnectionStatus(): Promise<ConnectionSlot[]>;
/** The settings payload (maps to GET /api/config in v0.2 — REQ-65). */
getSettings(): Promise<Settings>;
/**
* Persist a settings update; returns the saved settings
* (maps to PUT /api/config in v0.2). Raw keys never appear in
* responses — masked values only.
*/
saveSettings(update: Partial<Settings>): Promise<Settings>;
/**
* Follow-up query suggestions for the New Research view —
* derived from unanswered open questions of past runs
* (maps to GET /api/suggestions in v0.2 — REQ-67).
*/
getSuggestedQueries(): Promise<SuggestedQuery[]>;
/**
* The clarifying questions for the refine dialogue, keyed to the
* draft (maps to POST /api/refine/questions in v0.2 — REQ-68).
*/
getRefinementQuestions(draft: string): Promise<RefinementQuestion[]>;
/**
* Compose the refined question + suggested context from the
* exchange answers (maps to POST /api/refine/compose in v0.2,
* model-driven there; deterministic in the prototype — REQ-68).
*/
composeRefinedQuestion(
draft: string,
answers: Record<string, string>,
): Promise<RefinedQuestion>;
}
/**
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
* event replay, zero API keys). v0.2 P3: rebind to the OpenAPI-generated
* client — this line is the only thing that changes.
*/
export const api: ApiClient = new MockApiClient();
@@ -0,0 +1,85 @@
/**
* Boundary tests for the P7 ask-intelligence surface (T-7.1, REQ-67, REQ-68).
*
* Suggestions derive from OPEN inbox items with source attribution;
* done items drop out. The refine dialogue returns its scripted
* question set and composes deterministically — skipped optional
* answers degrade gracefully, the non-optional scope always weaves in.
*/
import { describe, expect, it } from "vitest";
import { api } from "@/lib/api/client";
import type { ApiClient } from "@/lib/api/client";
import { taskInboxFixture } from "@/lib/api/mock/library-fixtures";
const client: ApiClient = api;
describe("getSuggestedQueries (REQ-67)", () => {
it("derives suggestions from open inbox items only", async () => {
const suggestions = await client.getSuggestedQueries();
const openItems = taskInboxFixture.filter((i) => i.status !== "done");
expect(suggestions).toHaveLength(openItems.length);
const openQuestions = new Set(openItems.map((i) => i.question));
for (const suggestion of suggestions) {
expect(openQuestions).toContain(suggestion.question);
}
});
it("attributes each suggestion to its source report", async () => {
const suggestions = await client.getSuggestedQueries();
expect(suggestions.length).toBeGreaterThan(0);
for (const suggestion of suggestions) {
expect(suggestion.sourceRunId).toBeTruthy();
expect(suggestion.sourceReportTitle.length).toBeGreaterThan(0);
}
});
});
describe("getRefinementQuestions (REQ-68)", () => {
it("returns the scripted 3-question exchange", async () => {
const questions = await client.getRefinementQuestions("How do batteries work?");
expect(questions).toHaveLength(3);
const first = questions[0];
if (first === undefined) throw new Error("no refinement questions");
expect(first.prompt.length).toBeGreaterThan(0);
expect(first.placeholder.length).toBeGreaterThan(0);
// The scope question is required; the rest are skippable.
expect(questions.filter((q) => !q.optional).map((q) => q.id)).toEqual([
"scope",
]);
});
});
describe("composeRefinedQuestion (REQ-68)", () => {
const DRAFT = "What are the latest advances in solid-state batteries";
it("weaves the scope into the question and answers into context", async () => {
const refined = await client.composeRefinedQuestion(DRAFT, {
scope: "automotive applications",
decision: "choosing a supplier for a 2027 EV program",
depth: "an executive summary with verifiable evidence",
});
expect(refined.question).toBe(
"What are the latest advances in solid-state batteries — focusing on automotive applications",
);
expect(refined.context).toContain("choosing a supplier for a 2027 EV program");
expect(refined.context).toContain(
"an executive summary with verifiable evidence",
);
});
it("degrades gracefully when optional answers are skipped", async () => {
const refined = await client.composeRefinedQuestion(DRAFT, {
scope: "grid storage",
});
expect(refined.question).toBe(`${DRAFT} — focusing on grid storage`);
expect(refined.context).not.toContain("Feeds this decision");
expect(refined.context).not.toContain("Depth wanted");
});
it("keeps the draft intact when no answers are given", async () => {
const refined = await client.composeRefinedQuestion(DRAFT, {});
expect(refined.question).toBe(DRAFT);
expect(refined.context).toContain("Refined through the question dialogue");
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Ask-intelligence fixtures (T-7.1, REQ-67, REQ-68).
*
* Suggested queries derive from OPEN task-inbox items joined with
* their source reports (the unanswered-questions contract). The
* refine dialogue is a scripted 3-question exchange with a
* deterministic composer — the model-driven version is v0.2.
*/
import type {
RefinementQuestion,
RefinedQuestion,
SuggestedQuery,
} from "@/lib/types";
import { reportLibraryFixture } from "@/lib/api/mock/shell-fixtures";
import { taskInboxFixture } from "@/lib/api/mock/library-fixtures";
/* ------------------------------------------------------------------ */
/* Suggested queries (REQ-67) */
/* ------------------------------------------------------------------ */
/**
* Build suggestions from open inbox items: each unanswered question
* becomes a suggested query, attributed to its source report.
*/
export function buildSuggestedQueries(): SuggestedQuery[] {
const titleByRunId = new Map(
reportLibraryFixture.reports.map((r) => [r.runId, r.title] as const),
);
return taskInboxFixture
.filter((item) => item.status !== "done")
.map((item) => ({
id: `sug-${item.id}`,
question: item.question,
sourceRunId: item.sourceRunId,
sourceReportTitle: titleByRunId.get(item.sourceRunId) ?? "a past run",
}));
}
/* ------------------------------------------------------------------ */
/* Refine dialogue (REQ-68) — scripted in the prototype */
/* ------------------------------------------------------------------ */
/**
* The scripted clarifying questions. Generic enough to apply to any
* research draft: scope, decision, and depth — the three answers a
* good research question bakes in.
*/
export const refinementQuestionsFixture: RefinementQuestion[] = [
{
id: "scope",
prompt: "What specific part of this topic matters most to you?",
placeholder: "e.g. just automotive applications, not consumer devices",
optional: false,
},
{
id: "decision",
prompt: "What decision or action will this research inform?",
placeholder: "e.g. choosing a battery supplier for a 2027 EV program",
optional: true,
},
{
id: "depth",
prompt: "How deep should the answer go — a quick memo or a full report?",
placeholder: "e.g. executive summary with evidence I can verify",
optional: true,
},
];
/**
* Deterministic composer: weave the answers into the draft question
* plus a suggested context block. Answers are optional-terminated so
* a half-answered exchange still composes (skipped questions drop).
*/
export function composeRefinedQuestion(
draft: string,
answers: Record<string, string>,
): RefinedQuestion {
const scope = answers.scope?.trim();
const decision = answers.decision?.trim();
const depth = answers.depth?.trim();
// Question: draft + the specific scope (the one non-optional dial).
const question =
scope && scope.length > 0
? `${draft.trim()} — focusing on ${scope}`
: draft.trim();
// Context: the remaining answers, assembled as guidance lines.
const contextLines: string[] = [];
if (decision && decision.length > 0) {
contextLines.push(`Feeds this decision: ${decision}.`);
}
if (depth && depth.length > 0) {
contextLines.push(`Depth wanted: ${depth}.`);
}
contextLines.push("Refined through the question dialogue.");
return { question, context: contextLines.join(" ") };
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Methodology fixtures (T-2.1, REQ-53 + REQ-56).
*
* All 8 locked methodologies with the card metadata the ROADMAP
* prescribes: ETA, cost estimate, why-recommended, verification level,
* dial defaults. `recommended` is TRUE on deep-report ONLY.
*/
import type { DialValues, Methodology } from "@/lib/types";
/* ------------------------------------------------------------------ */
/* Dial bundles */
/* ------------------------------------------------------------------ */
const rapidAnswerDials: DialValues = {
sourceUniverse: "web",
searchRouting: "web",
queryStrategy: "broad",
breadth: 1,
depth: 1,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "memo",
outputStructure: "memo",
memoryScope: "none",
checkpoints: "plan-only",
};
const deepReportDials: DialValues = {
sourceUniverse: "web+academic",
searchRouting: "hybrid",
queryStrategy: "targeted",
breadth: 2,
depth: 3,
qualityGates: "strict",
citationRigor: "per-claim",
verificationPasses: 2,
synthesisStyle: "analytical",
outputStructure: "report",
memoryScope: "none",
checkpoints: "plan-only",
};
const sotaSurveyDials: DialValues = {
sourceUniverse: "academic",
searchRouting: "academic",
queryStrategy: "iterative",
breadth: 3,
depth: 4,
qualityGates: "strict",
citationRigor: "per-claim",
verificationPasses: 2,
synthesisStyle: "encyclopedic",
outputStructure: "report",
memoryScope: "none",
checkpoints: "plan-only",
};
const systematicReviewDials: DialValues = {
sourceUniverse: "academic",
searchRouting: "academic",
queryStrategy: "iterative",
breadth: 5,
depth: 5,
qualityGates: "strict",
citationRigor: "per-claim",
verificationPasses: 3,
synthesisStyle: "encyclopedic",
outputStructure: "report",
memoryScope: "none",
checkpoints: "plan+draft",
};
const factCheckDossierDials: DialValues = {
sourceUniverse: "web",
searchRouting: "web",
queryStrategy: "adversarial",
breadth: 2,
depth: 2,
qualityGates: "strict",
citationRigor: "per-claim",
verificationPasses: 2,
synthesisStyle: "adversarial",
outputStructure: "dossier",
memoryScope: "none",
checkpoints: "plan-only",
};
const multiPerspectiveDossierDials: DialValues = {
sourceUniverse: "web",
searchRouting: "web",
queryStrategy: "targeted",
breadth: 3,
depth: 3,
qualityGates: "standard",
citationRigor: "section",
verificationPasses: 2,
synthesisStyle: "analytical",
outputStructure: "dossier",
memoryScope: "none",
checkpoints: "plan-only",
};
const competitiveLandscapeDials: DialValues = {
sourceUniverse: "web",
searchRouting: "web",
queryStrategy: "targeted",
breadth: 3,
depth: 3,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "analytical",
outputStructure: "matrix",
memoryScope: "none",
checkpoints: "plan-only",
};
const corpusInterrogationDials: DialValues = {
sourceUniverse: "corpus",
searchRouting: "corpus",
queryStrategy: "iterative",
breadth: 4,
depth: 4,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "encyclopedic",
outputStructure: "report",
memoryScope: "project",
checkpoints: "plan-only",
};
/* ------------------------------------------------------------------ */
/* The 8 locked methodologies */
/* ------------------------------------------------------------------ */
export const methodologyFixtures: readonly Methodology[] = [
{
slug: "rapid-answer",
name: "Rapid Answer",
tagline: "One question, one pass, one cited memo.",
description:
"A single breadth-1 sweep of the open web that answers a focused question in a short cited memo. No depth loops, no detours — built for questions you need answered in the next few minutes.",
etaMinutes: 3,
costEstimate: "$0.05 – $0.15",
whyRecommended:
"Pick this when your question is narrow and you need a trustworthy answer now — “what does X mean”, “when did Y happen”, “is Z still true in 2026”.",
recommended: false,
verification: "per-claim",
dialSummary: [
"Breadth: 1 · Depth: 1",
"Web sources · memo output",
"Per-claim citations",
],
defaultDials: rapidAnswerDials,
badge: "Fastest",
},
{
slug: "deep-report",
name: "Deep Report",
tagline: "The workhorse: multi-source executive report.",
description:
"A depth-3 run across web and academic sources that plans sub-questions, verifies every claim, and writes a structured executive report. The default methodology for any open-ended research question.",
etaMinutes: 12,
costEstimate: "$0.40 – $1.20",
whyRecommended:
"Pick this when your question is open-ended — “state of X”, “why does Y”, “should we Z” — and you need a structured, fully-cited report rather than a quick answer.",
recommended: true,
verification: "full",
dialSummary: [
"Breadth: 2 · Depth: 3",
"Web + academic · strict gates",
"Per-claim citations · full verification",
],
defaultDials: deepReportDials,
},
{
slug: "sota-survey",
name: "SOTA Survey",
tagline: "What's new and what's next in a research area.",
description:
"Academic-first routing with an outline-first encyclopedic synthesis. Surveys recent literature, groups findings by theme, and maps where the frontier is moving.",
etaMinutes: 25,
costEstimate: "$0.90 – $2.50",
whyRecommended:
"Pick this when you need the current state of the art in a research area — recent papers, benchmark movement, open problems — not a general web answer.",
recommended: false,
verification: "full",
dialSummary: [
"Breadth: 3 · Depth: 4",
"Academic routing · outline-first",
"Per-claim citations · full verification",
],
defaultDials: sotaSurveyDials,
},
{
slug: "systematic-review",
name: "Systematic Review",
tagline: "PRISMA-like rigor: screened, appraised, exhaustive.",
description:
"The strictest methodology in the catalog: an academic-only systematic sweep with screening criteria, quality appraisal, and a PRISMA-like flow diagram. Built for questions where exhaustiveness is the point.",
etaMinutes: 45,
costEstimate: "$2.00 – $6.00",
whyRecommended:
"Pick this when you intend to rely on the answer for a decision that must survive scrutiny — evidence synthesis, clinical or policy questions, “what does the literature actually support”.",
recommended: false,
verification: "standard",
dialSummary: [
"Breadth: 5 · Depth: 5",
"Academic only · PRISMA-like flow",
"Per-claim citations · 3 verification passes",
],
defaultDials: systematicReviewDials,
},
{
slug: "fact-check-dossier",
name: "Fact-Check Dossier",
tagline: "Evidence for, evidence against, verdict.",
description:
"An adversarial run that steelmans both sides of a specific factual claim, anchors every piece of evidence to an exact quote, and closes with a verdict you can audit line by line.",
etaMinutes: 8,
costEstimate: "$0.20 – $0.60",
whyRecommended:
"Pick this when someone said something you need to confirm or refute — a statistic, a quote, a “studies show” claim — and the answer must cite chapter and verse.",
recommended: false,
verification: "per-claim",
dialSummary: [
"Breadth: 2 · Depth: 2",
"Adversarial queries · exact-quote anchoring",
"Per-claim citations",
],
defaultDials: factCheckDossierDials,
},
{
slug: "multi-perspective-dossier",
name: "Multi-Perspective Dossier",
tagline: "Steelmanned views across a contested question.",
description:
"Maps the strongest version of each major perspective on a contested topic — assumptions, evidence, and where the disagreement actually lives — without flattening anyone's argument.",
etaMinutes: 20,
costEstimate: "$0.70 – $1.80",
whyRecommended:
"Pick this when reasonable people disagree and you need the disagreement itself documented — policy debates, competing predictions, “who's right about X”.",
recommended: false,
verification: "standard",
dialSummary: [
"Breadth: 3 · Depth: 3",
"Perspective-mapped sections",
"Standard verification",
],
defaultDials: multiPerspectiveDossierDials,
},
{
slug: "competitive-landscape",
name: "Competitive Landscape",
tagline: "Who's building what, and how they compare.",
description:
"A comparison-matrix run: identifies the players in a space, fills each cell with individually provenance-backed evidence, and keeps the whole landscape on one page.",
etaMinutes: 15,
costEstimate: "$0.50 – $1.40",
whyRecommended:
"Pick this when the answer has to be a landscape, not a narrative — vendor comparisons, “which tools solve X”, market maps with per-cell sources.",
recommended: false,
verification: "per-claim",
dialSummary: [
"Breadth: 3 · Depth: 3",
"Comparison matrix · per-cell provenance",
"Per-claim citations",
],
defaultDials: competitiveLandscapeDials,
},
{
slug: "corpus-interrogation",
name: "Corpus Interrogation",
tagline: "Your documents, interrogated in depth.",
description:
"A local-corpus run: no web search at all. Chunks and re-annotates your own document collection, then answers questions strictly from what the corpus contains — with quality annotations on every passage.",
etaMinutes: 30,
costEstimate: "$0.60 – $1.60",
whyRecommended:
"Pick this when the answer must come from material you already have — a document dump, an archive, a filing cabinet — and from nowhere else.",
recommended: false,
verification: "standard",
dialSummary: [
"Breadth: 4 · Depth: 4",
"Corpus only · chunk + rerank",
"Project memory · quality annotation",
],
defaultDials: corpusInterrogationDials,
},
] as const;
/** Look up a methodology fixture by slug (case-insensitive). */
export function findMethodologyBySlug(
slug: string,
): Methodology | undefined {
return methodologyFixtures.find((m) => m.slug === slug);
}
@@ -0,0 +1,105 @@
/**
* Library fixtures (T-3.1, REQ-60) — the v2 surface mockup data.
*
* Project graph: cross-linked research topics sharing sources/findings.
* Task inbox: open questions from past runs as checkable follow-ups.
* Realistic fixture domains, clearly example content.
*/
import type { InboxItem, ProjectGraph } from "@/lib/types";
export const projectGraphFixture: ProjectGraph = {
nodes: [
{
id: "proj-batteries",
topic: "Solid-state batteries",
linkedSources: [
"Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
"Critical Materials Outlook: Lithium and Beyond",
],
findings: [
"Interface degradation — not bulk conductivity — is the commercial bottleneck.",
"Lithium-metal anodes re-concentrate supply risk on refining capacity.",
],
},
{
id: "proj-grid-storage",
topic: "Grid-scale storage economics",
linkedSources: [
"Critical Materials Outlook: Lithium and Beyond",
"Thermal Safety of Next-Generation Battery Chemistries",
],
findings: [
"Safety advantages shift the siting calculus toward dense urban deployments.",
],
},
{
id: "proj-materials",
topic: "Critical materials supply chains",
linkedSources: [
"Critical Materials Outlook: Lithium and Beyond",
"Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
],
findings: [
"Refining capacity — not reserves — is the choke point for solid-state scale.",
],
},
{
id: "proj-transport",
topic: "Automotive electrification timelines",
linkedSources: [
"Automakers Begin Pilot Production of Solid-State Cells",
"Venture Funding Flows Into Solid-State Startups",
],
findings: [
"Pilot-line announcements cluster around 2027 vehicle deployment targets.",
],
},
{
id: "proj-safety",
topic: "Battery safety testing standards",
linkedSources: [
"Thermal Safety of Next-Generation Battery Chemistries",
"Automakers Begin Pilot Production of Solid-State Cells",
],
findings: [
"Abuse testing shows reduced heat release; dendrite persistence at high rates.",
],
},
],
edges: [
{ fromId: "proj-batteries", toId: "proj-materials", sharedSourceCount: 2 },
{ fromId: "proj-batteries", toId: "proj-grid-storage", sharedSourceCount: 1 },
{ fromId: "proj-batteries", toId: "proj-transport", sharedSourceCount: 0 },
{ fromId: "proj-grid-storage", toId: "proj-safety", sharedSourceCount: 1 },
{ fromId: "proj-materials", toId: "proj-transport", sharedSourceCount: 0 },
{ fromId: "proj-safety", toId: "proj-transport", sharedSourceCount: 1 },
],
};
export const taskInboxFixture: InboxItem[] = [
{
id: "task-001",
question: "Do sulfide electrolytes outperform oxide ceramics below room temperature?",
sourceRunId: "run-solid-state",
status: "open",
},
{
id: "task-002",
question: "Which solid-state startups have shipped pilot cells to automakers?",
sourceRunId: "run-solid-state",
status: "in_progress",
},
{
id: "task-003",
question: "How do 2027 deployment targets compare across Japanese and EU consortia?",
sourceRunId: "run-transport",
status: "done",
},
{
id: "task-004",
question: "What is the lithium refining capacity expansion pipeline through 2030?",
sourceRunId: "run-materials",
status: "open",
},
];
@@ -0,0 +1,272 @@
/**
* MockApiClient contract + behavior tests (T-2.3, REQ-53).
*
* Verifies the fixtures typecheck against the domain types, that
* MockApiClient satisfies the ApiClient interface (compile-time
* assertion lives in mock-client.ts; here we exercise behavior), and
* that exports are real files with citations as real links.
*/
import { describe, expect, it } from "vitest";
import type { ApiClient } from "@/lib/api/client";
import { api } from "@/lib/api/client";
import { methodologyFixtures } from "@/lib/api/mock/fixtures";
import { scriptedTraceEvents } from "@/lib/api/mock/scripted-run";
import type { Methodology } from "@/lib/types";
/* ------------------------------------------------------------------ */
/* Fixtures */
/* ------------------------------------------------------------------ */
describe("methodology fixtures", () => {
it("typecheck: all 8 entries are valid Methodology objects", () => {
const all: Methodology[] = [...methodologyFixtures];
expect(all).toHaveLength(8);
for (const methodology of all) {
expect(typeof methodology.slug).toBe("string");
expect(typeof methodology.name).toBe("string");
expect(typeof methodology.tagline).toBe("string");
expect(typeof methodology.description).toBe("string");
expect(methodology.etaMinutes).toBeGreaterThan(0);
expect(typeof methodology.costEstimate).toBe("string");
expect(typeof methodology.whyRecommended).toBe("string");
expect(typeof methodology.recommended).toBe("boolean");
expect(["per-claim", "full", "standard"]).toContain(methodology.verification);
expect(methodology.dialSummary.length).toBeGreaterThanOrEqual(3);
expect(Object.keys(methodology.defaultDials)).toHaveLength(12);
}
});
it("cover the 8 locked slugs from the ROADMAP", () => {
const slugs = methodologyFixtures.map((m) => m.slug);
expect(slugs).toEqual([
"rapid-answer",
"deep-report",
"sota-survey",
"systematic-review",
"fact-check-dossier",
"multi-perspective-dossier",
"competitive-landscape",
"corpus-interrogation",
]);
});
it("mark exactly deep-report as recommended", () => {
const recommended = methodologyFixtures.filter((m) => m.recommended);
expect(recommended).toHaveLength(1);
expect(recommended[0]?.slug).toBe("deep-report");
});
});
/* ------------------------------------------------------------------ */
/* Scripted run */
/* ------------------------------------------------------------------ */
describe("scripted trace", () => {
it("replays 12–18+ events including plan, queries, sources, verification", () => {
const events = scriptedTraceEvents("run-test");
expect(events.length).toBeGreaterThanOrEqual(12);
expect(events.length).toBeLessThanOrEqual(24);
const types = events.map((e) => e.type);
expect(types).toContain("plan");
expect(types.filter((t) => t === "query").length).toBeGreaterThanOrEqual(2);
expect(types.filter((t) => t === "source_visited").length).toBe(6);
expect(types).toContain("synthesis");
expect(types).toContain("verification");
});
});
/* ------------------------------------------------------------------ */
/* MockApiClient behavior (through the ApiClient-typed singleton) */
/* ------------------------------------------------------------------ */
describe("MockApiClient (bound as `api`)", () => {
// The singleton is the ApiClient-typed binding — proves the boundary.
const client: ApiClient = api;
it("returns 8 methodologies", async () => {
const methodologies = await client.getMethodologies();
expect(methodologies).toHaveLength(8);
});
it("createRun embeds the question and context into the plan", async () => {
const run = await client.createRun({
question: "What are the latest advances in solid-state batteries?",
context: "Focus on automotive applications",
methodologySlug: "deep-report",
});
expect(run.plan.question).toBe(
"What are the latest advances in solid-state batteries?",
);
expect(run.plan.context).toBe("Focus on automotive applications");
expect(run.plan.methodologySlug).toBe("deep-report");
});
it("getRun returns the created run; getReport carries [^pN] markers", async () => {
const question = "What are the latest advances in solid-state batteries?";
const run = await client.createRun({ question, methodologySlug: "deep-report" });
const fetched = await client.getRun(run.id);
expect(fetched.id).toBe(run.id);
const report = await client.getReport(run.id);
expect(report.title).toContain(question);
expect(report.sections[0]?.body).toMatch(/\[\^p1\]/);
expect(report.citations).toHaveLength(6);
const live = report.citations.filter((c) => c.verification === "live");
const stale = report.citations.filter((c) => c.verification === "stale");
expect(live).toHaveLength(5);
expect(stale).toHaveLength(1);
});
it("exports markdown with citations as real links", async () => {
const question = "What are the latest advances in solid-state batteries?";
const run = await client.createRun({ question, methodologySlug: "deep-report" });
const markdown = await client.exportReport(run.id, "markdown");
expect(markdown.filename).toMatch(/\.md$/);
expect(markdown.content).toContain("# Deep Report —");
// Per-claim markers survive as footnote-style references…
expect(markdown.content).toContain("[^p1]:");
// …and each citation is a real Markdown link to its source URL.
expect(markdown.content).toContain("](https://example.org/arxiv-sulfide-electrolytes-review)");
expect(markdown.content).toContain("✓ live");
expect(markdown.content).toContain("△ stale");
});
it("exports JSON that round-trips the report", async () => {
const question = "What are the latest advances in solid-state batteries?";
const run = await client.createRun({ question, methodologySlug: "deep-report" });
const json = await client.exportReport(run.id, "json");
expect(json.filename).toMatch(/\.json$/);
// P2 shape: { report, ledger } — the export carries the full audit record.
const parsed = JSON.parse(json.content) as {
report: { runId: string; citations: unknown[] };
ledger: { runId: string; citations: unknown[] };
};
expect(parsed.report.runId).toBe(run.id);
expect(parsed.ledger.runId).toBe(run.id);
expect(parsed.report.citations).toHaveLength(6);
expect(parsed.ledger.citations).toHaveLength(6);
});
it("getLedger returns 6 citations with the run id and health mix", async () => {
const question = "What are the latest advances in solid-state batteries?";
const run = await client.createRun({ question, methodologySlug: "deep-report" });
const ledger = await client.getLedger(run.id);
expect(ledger.runId).toBe(run.id);
expect(ledger.citations).toHaveLength(6);
expect(ledger.citations.filter((c) => c.verification === "stale")).toHaveLength(1);
});
it("replays trace events one by one then signals done", async () => {
const question = "What are the latest advances in solid-state batteries?";
const run = await client.createRun({ question, methodologySlug: "deep-report" });
const seen: string[] = [];
const done = new Promise<void>((resolve) => {
const subscription = client.getRunEvents(
run.id,
(event) => {
seen.push(event.message);
},
() => {
subscription.cancel(); // already done; cancel is a no-op
resolve();
},
);
});
await done;
expect(seen.length).toBeGreaterThanOrEqual(12);
expect(seen[0]).toContain("Plan drafted");
});
/* ---------------------------------------------------------------- */
/* P2: multi-run store, approval persistence, pause/resume (T-2.13) */
/* ---------------------------------------------------------------- */
it("keeps every created run retrievable — back-nav does not 404 (P1-3)", async () => {
const runA = await client.createRun({
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
});
const runB = await client.createRun({
question: "How do vector databases handle billion-scale embeddings?",
methodologySlug: "deep-report",
});
expect(runA.id).not.toBe(runB.id);
const fetchedA = await client.getRun(runA.id);
const fetchedB = await client.getRun(runB.id);
expect(fetchedA.plan.question).toContain("solid-state");
expect(fetchedB.plan.question).toContain("vector databases");
});
it("createRun halts at awaiting_approval; approvePlan persists the transition", async () => {
const run = await client.createRun({
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
});
expect(run.status).toBe("awaiting_approval");
const approved = await client.approvePlan(run.id);
expect(approved.status).toBe("searching");
// The transition PERSISTS — a getRun afterwards must see it (P1 flag).
const refetched = await client.getRun(run.id);
expect(refetched.status).toBe("searching");
});
it("cancelRun persists cancelled; completeRun persists done (mock-local)", async () => {
const run = await client.createRun({
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
});
const cancelled = await client.cancelRun(run.id);
expect(cancelled.status).toBe("cancelled");
expect((await client.getRun(run.id)).status).toBe("cancelled");
const run2 = await client.createRun({
question: "A second question about solid-state batteries?",
methodologySlug: "deep-report",
});
// completeRun is mock-local (not on ApiClient) — the typed binding
// exposes it via the concrete class; the cast is the test seam.
const mock = client as unknown as { completeRun(id: string): Promise<{ status: string }> };
const done = await mock.completeRun(run2.id);
expect(done.status).toBe("done");
expect((await client.getRun(run2.id)).status).toBe("done");
});
it("pause halts event delivery; resume continues; cancel stops for good", async () => {
const run = await client.createRun({
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
});
let count = 0;
let finished = false;
const subscription = client.getRunEvents(
run.id,
() => {
count += 1;
},
() => {
finished = true;
},
);
// Let a few events arrive, then pause.
await new Promise((r) => setTimeout(r, 500));
subscription.pause();
const countAtPause = count;
// While paused, no further events arrive.
await new Promise((r) => setTimeout(r, 450));
expect(count).toBe(countAtPause);
subscription.resume();
await new Promise((r) => setTimeout(r, 450));
expect(count).toBeGreaterThan(countAtPause);
// Cancel stops delivery permanently and never fires onDone.
subscription.cancel();
const countAtCancel = count;
await new Promise((r) => setTimeout(r, 400));
expect(count).toBe(countAtCancel);
expect(finished).toBe(false);
});
});
+381
View File
@@ -0,0 +1,381 @@
/**
* MockApiClient — the v0.1 binding of the ApiClient interface (T-2.3, REQ-53).
*
* Fixture data + scripted event replay. Zero API keys, zero network.
* The ONLY module that imports this is `@/lib/api/client` (the binding);
* components always consume the `api` singleton through the interface.
*/
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
import {
buildSuggestedQueries,
composeRefinedQuestion,
refinementQuestionsFixture,
} from "@/lib/api/mock/ask-fixtures";
import { methodologyFixtures, findMethodologyBySlug } from "@/lib/api/mock/fixtures";
import {
projectGraphFixture,
taskInboxFixture,
} from "@/lib/api/mock/library-fixtures";
import {
connectionStatusFixture,
reportConnectionsFixture,
reportLibraryFixture,
settingsFixture,
userProfileFixture,
} from "@/lib/api/mock/shell-fixtures";
import {
buildScriptedLedger,
buildScriptedReport,
buildScriptedRun,
scriptedTraceEvents,
} from "@/lib/api/mock/scripted-run";
import type {
CitationLedger,
ConnectionSlot,
InboxItem,
Methodology,
ProjectGraph,
RefinementQuestion,
RefinedQuestion,
Report,
ReportConnection,
ReportLibrary,
ResearchRun,
Settings,
SuggestedQuery,
TraceEvent,
UserProfile,
} from "@/lib/types";
/** Handle returned by getRunEvents — EventSource-shaped, pause-aware. */
export interface RunEventSubscription {
cancel(): void;
pause(): void;
resume(): void;
}
/** Interval between replayed trace events (ms). */
const EVENT_REPLAY_INTERVAL_MS = 150;
export class MockApiClient implements ApiClient {
/**
* Session store of created runs, keyed by id — back-navigation to an
* earlier run resolves instead of 404ing (P1 review flag P1-3).
*/
#runs = new Map<string, ResearchRun>();
/** Cancel the run from the trace UI — terminal state, persisted. */
async cancelRun(id: string): Promise<ResearchRun> {
return this.#setStatus(id, "cancelled");
}
/**
* Mark the run done once its trace replay completes.
* Mock-local (NOT on the ApiClient): in v0.2 the engine owns the
* DONE transition and it arrives via the SSE stream — no separate
* client call exists, so the interface stays honest.
*/
async completeRun(id: string): Promise<ResearchRun> {
return this.#setStatus(id, "done");
}
async getMethodologies(): Promise<Methodology[]> {
return [...methodologyFixtures];
}
async createRun(input: CreateRunInput): Promise<ResearchRun> {
const methodology =
findMethodologyBySlug(input.methodologySlug) ??
methodologyFixtures.find((m) => m.recommended);
if (!methodology) {
throw new Error(`MockApiClient: unknown methodology "${input.methodologySlug}"`);
}
// dialOverrides are accepted and recorded per REQ-56's drawer contract;
// the scripted run's behavior does not branch on them in the prototype.
// The run halts at AWAITING_APPROVAL — the Plan & Run screen drives it
// forward via approvePlan (REQ-57's approval gate).
const run = buildScriptedRun({
question: input.question,
...(input.context !== undefined ? { context: input.context } : {}),
methodologySlug: methodology.slug,
});
const runAwaitingApproval: ResearchRun = { ...run, status: "awaiting_approval" };
this.#runs.set(run.id, runAwaitingApproval);
return structuredClone(runAwaitingApproval);
}
async getRun(id: string): Promise<ResearchRun> {
const stored = this.#runs.get(id);
if (stored) {
return structuredClone(stored);
}
// A stored-library report's run: deep links from the library cards
// and ⌘K palette resolve to the card's own question and title —
// never the demo-run fallback (REQ-64).
const libraryRun = this.#storedRun(id);
if (libraryRun) {
return structuredClone(libraryRun);
}
// No created run this session — fall back to the scripted demo run so
// deep links and hard refreshes keep working (prototype behavior).
return structuredClone(this.#defaultRun());
}
async approvePlan(id: string): Promise<ResearchRun> {
const run = await this.getRun(id);
if (run.status !== "awaiting_approval") {
return run;
}
const approved: ResearchRun = { ...run, status: "searching" };
this.#runs.set(id, approved);
return structuredClone(approved);
}
/** Persist a terminal (or transitional) status from the trace UI. */
async #setStatus(id: string, status: ResearchRun["status"]): Promise<ResearchRun> {
const run = await this.getRun(id);
const updated: ResearchRun = { ...run, status };
const known = this.#runs.has(id) || this.#isDefaultRun(id);
if (known) {
this.#runs.set(id, updated);
}
return structuredClone(updated);
}
#isDefaultRun(id: string): boolean {
return id === this.#defaultRun().id;
}
/**
* A stored-library report's run (REQ-64). Built on demand from the
* library fixture — same question/title as the card, methodology
* slug from the summary, status done. Deep links from library
* cards and the ⌘K palette resolve through here.
*/
#storedRun(id: string): ResearchRun | undefined {
const stored = reportLibraryFixture.reports.find((r) => r.runId === id);
if (!stored) return undefined;
return buildScriptedRun({
question: stored.question,
methodologySlug: stored.methodologySlug,
id: stored.runId,
});
}
getRunEvents(
id: string,
onEvent: (event: TraceEvent) => void,
onDone?: () => void,
): RunEventSubscription {
const events = scriptedTraceEvents(id);
let index = 0;
let cancelled = false;
let paused = false;
// Replay the scripted trace one event at a time, then signal done.
// Pause halts delivery by re-arming the timer without emitting.
let timer: ReturnType<typeof setInterval> | undefined;
const arm = () => {
timer = setInterval(() => {
if (cancelled) return;
if (paused) return;
if (index >= events.length) {
finish();
return;
}
onEvent(events[index] as TraceEvent);
index += 1;
}, EVENT_REPLAY_INTERVAL_MS);
};
const stopTimer = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const finish = () => {
stopTimer();
// Engine-owned DONE transition: persist, then notify subscriber.
void this.completeRun(id).then(() => onDone?.());
};
arm();
// When the scripted trace finishes, transition the stored run to
// DONE (the engine owns this in v0.2; the mock owns it here) and
// deliver the terminal onDone to the subscriber.
return {
cancel(): void {
cancelled = true;
stopTimer();
},
pause(): void {
paused = true;
},
resume(): void {
paused = false;
},
};
}
async getReport(id: string): Promise<Report> {
const run = await this.getRun(id);
// Stored-library reports keep their card title — the library card,
// palette entry, and report headline always agree.
const stored = reportLibraryFixture.reports.find((r) => r.runId === id);
return buildScriptedReport(
run,
run.plan.question,
stored?.title,
);
}
async getLedger(id: string): Promise<CitationLedger> {
const run = await this.getRun(id);
return buildScriptedLedger(run);
}
async exportReport(
id: string,
format: "markdown" | "json",
): Promise<{ filename: string; content: string }> {
const run = await this.getRun(id);
const stored = reportLibraryFixture.reports.find((r) => r.runId === id);
const report = buildScriptedReport(run, run.plan.question, stored?.title);
const ledger = buildScriptedLedger(run);
if (format === "json") {
return {
filename: `researchx-${report.runId}.json`,
content: JSON.stringify({ report, ledger }, null, 2),
};
}
return {
filename: `researchx-${report.runId}.md`,
content: renderReportMarkdown(report),
};
}
/** The cross-project graph mockup (v2 preview, REQ-60). */
async getProjectGraph(): Promise<ProjectGraph> {
return structuredClone(projectGraphFixture);
}
/** The task inbox mockup (v2 preview, REQ-60). */
async getTaskInbox(): Promise<InboxItem[]> {
return structuredClone(taskInboxFixture);
}
/** The stored-reports library: folders + summaries (REQ-64). */
async getReportLibrary(): Promise<ReportLibrary> {
return structuredClone(reportLibraryFixture);
}
/** Cross-report connections for the library's connections panel (REQ-64). */
async getReportConnections(): Promise<ReportConnection[]> {
return structuredClone(reportConnectionsFixture);
}
/** The local single-user profile behind the avatar (REQ-66). */
async getUserProfile(): Promise<UserProfile> {
return structuredClone(userProfileFixture);
}
/** Adapter-slot connection status (REQ-65). */
async getConnectionStatus(): Promise<ConnectionSlot[]> {
return structuredClone(connectionStatusFixture);
}
/** The settings payload (REQ-65). */
async getSettings(): Promise<Settings> {
return structuredClone(this.#settings);
}
/**
* Persist a settings update (REQ-65). Raw key values never round-trip:
* apiKeys are fixture-owned (masking is the backend's job in v0.2),
* and only storage/default-methodology/dials fields persist here.
*/
async saveSettings(update: Partial<Settings>): Promise<Settings> {
const next: Settings = {
...this.#settings,
...update,
// Identity fields never accept client-side overwrites:
apiKeys: structuredClone(this.#settings.apiKeys),
theme: "paper",
};
this.#settings = next;
return structuredClone(next);
}
/** Session-persisted settings (mock-local). */
#settings: Settings = structuredClone(settingsFixture);
/**
* Follow-up suggestions from unanswered open questions (REQ-67).
* Derived live from the inbox fixtures — a completed item drops
* out of suggestions automatically.
*/
async getSuggestedQueries(): Promise<SuggestedQuery[]> {
return structuredClone(buildSuggestedQueries());
}
/** The scripted refine-dialogue questions (REQ-68). */
async getRefinementQuestions(
// Draft keys the question set in v0.2; the prototype set is static.
_draft: string,
): Promise<RefinementQuestion[]> {
void _draft;
return structuredClone(refinementQuestionsFixture);
}
/** Deterministic refine composer (REQ-68; model-driven in v0.2). */
async composeRefinedQuestion(
draft: string,
answers: Record<string, string>,
): Promise<RefinedQuestion> {
return composeRefinedQuestion(draft, answers);
}
/** The scripted run for the prototype's demo question. */
#defaultRun(): ResearchRun {
return buildScriptedRun({
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
});
}
}
/* ------------------------------------------------------------------ */
/* Markdown rendering — citations survive as real links */
/* ------------------------------------------------------------------ */
/** Render a report as Markdown: title, sections, [^pN] markers, links. */
function renderReportMarkdown(report: Report): string {
const lines: string[] = [`# ${report.title}`, ""];
for (const section of report.sections) {
lines.push(`## ${section.heading}`, "", section.body, "");
}
// Citation markers become a reference list; each entry is a real link.
lines.push("## Sources", "");
for (const citation of report.citations) {
const health =
citation.verification === "live"
? "✓ live"
: citation.verification === "stale"
? "△ stale"
: "✗ dead";
lines.push(
`[^${citation.passageId}]: [${citation.title}](${citation.url}) — ${citation.site} · ${health} · tier ${citation.authorityTier}`,
);
}
return lines.join("\n");
}
/*
* Compile-time contract assertion (REQ-53): MockApiClient satisfies
* ApiClient. If this line typechecks, the binding in client.ts is
* safe and v0.2's swap stays a one-line change.
*/
const _satisfies: ApiClient = new MockApiClient();
void _satisfies;
+384
View File
@@ -0,0 +1,384 @@
/**
* Scripted Deep Report run (T-2.2, REQ-53).
*
* A deterministic, question-parameterized fixture: a full ResearchRun
* (planning → … → done) with a realistic trace (~16 events: plan,
* queries fired, sources visited, passages extracted, synthesis,
* verification, done), a final report with per-claim citation
* markers [^p1]…[^p6], and a 6-citation ledger (5 live, 1 stale).
*
* All URLs are clearly example/fixture domains — no real claims made.
*/
import type {
Citation,
CitationLedger,
Report,
ResearchRun,
TraceEvent,
} from "@/lib/types";
/* ------------------------------------------------------------------ */
/* Citations — 6 passages, 5 live + 1 stale, tiers 1–3 */
/* ------------------------------------------------------------------ */
export const scriptedCitations: readonly Citation[] = [
{
id: "c1",
url: "https://example.com/solid-state-battery-outlook-2026",
title: "Solid-State Batteries: The 2026 Industry Outlook",
site: "example.com",
passageId: "p1",
quote:
"Solid-state cells replaced flammable liquid electrolytes with a ceramic separator, unlocking simultaneous gains in energy density and thermal safety.",
verification: "live",
authorityTier: 2,
},
{
id: "c2",
url: "https://example.org/arxiv-sulfide-electrolytes-review",
title: "Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
site: "example.org",
passageId: "p2",
quote:
"Sulfide electrolytes exhibit ionic conductivity comparable to liquid electrolytes, but interface degradation during cycling remains the central obstacle to commercial cycle life.",
verification: "live",
authorityTier: 1,
},
{
id: "c3",
url: "https://example.com/japan-battery-pilot-line",
title: "Automakers Begin Pilot Production of Solid-State Cells",
site: "example.com",
passageId: "p3",
quote:
"A consortium of Japanese automakers and chemical firms announced a pilot line targeting limited vehicle deployment of all-solid-state cells by 2027.",
verification: "live",
authorityTier: 2,
},
{
id: "c4",
url: "https://example.org/energy-storage-safety-review",
title: "Thermal Safety of Next-Generation Battery Chemistries",
site: "example.org",
passageId: "p4",
quote:
"Across abuse testing, solid-state prototypes exhibited substantially reduced heat release relative to conventional lithium-ion, though dendrite formation at high charge rates persists in some designs.",
verification: "live",
authorityTier: 2,
},
{
id: "c5",
url: "https://example.com/supply-chain-lithium-report",
title: "Critical Materials Outlook: Lithium and Beyond",
site: "example.com",
passageId: "p5",
quote:
"Solid-state designs favor lithium-metal anodes, increasing per-cell lithium intensity and re-concentrating supply-chain risk on lithium refining capacity.",
verification: "live",
authorityTier: 3,
},
{
id: "c6",
url: "https://example.net/battery-startup-funding-2025",
title: "Venture Funding Flows Into Solid-State Startups",
site: "example.net",
passageId: "p6",
quote:
"Venture funding for solid-state battery startups reached a record in 2025, though most funded companies remain pre-revenue.",
verification: "stale",
authorityTier: 3,
},
] as const;
/* ------------------------------------------------------------------ */
/* Trace events */
/* ------------------------------------------------------------------ */
/** Base timestamp — fixed so replays are byte-for-byte deterministic. */
const T0 = "2026-09-27T12:00:00Z";
/** Milliseconds offset helper: T0 + n seconds. */
function atSeconds(seconds: number): string {
const t = Date.parse(T0);
return new Date(t + seconds * 1000).toISOString();
}
/**
* The scripted trace: plan, 3 queries, 6 sources visited,
* 6 passages extracted, synthesis, verification, done.
*/
export function scriptedTraceEvents(runId: string): TraceEvent[] {
return [
{
id: `${runId}-e01`,
ts: atSeconds(0),
type: "plan",
message: "Plan drafted — 4 sub-questions, 12 planned sources",
detail:
"Outline: 1) State of the technology 2) Remaining obstacles 3) Who is building it 4) What it means for the supply chain",
},
{
id: `${runId}-e02`,
ts: atSeconds(4),
type: "status",
message: "Plan approved — run started",
},
{
id: `${runId}-e03`,
ts: atSeconds(8),
type: "query",
message: 'Query fired: "solid-state battery energy density 2026"',
},
{
id: `${runId}-e04`,
ts: atSeconds(12),
type: "query",
message: 'Query fired: "all-solid-state lithium battery review"',
},
{
id: `${runId}-e05`,
ts: atSeconds(16),
type: "query",
message: 'Query fired: "solid-state battery pilot production automaker"',
},
{
id: `${runId}-e06`,
ts: atSeconds(24),
type: "source_visited",
message: "Visited: Solid-State Batteries: The 2026 Industry Outlook",
detail: "example.com · authority tier 2",
},
{
id: `${runId}-e07`,
ts: atSeconds(29),
type: "passage_extracted",
message: "Stored passage p1 — ceramic separator density/safety gains",
},
{
id: `${runId}-e08`,
ts: atSeconds(36),
type: "source_visited",
message:
"Visited: Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
detail: "example.org · authority tier 1",
},
{
id: `${runId}-e09`,
ts: atSeconds(41),
type: "passage_extracted",
message: "Stored passage p2 — interface degradation obstacle",
},
{
id: `${runId}-e10`,
ts: atSeconds(55),
type: "source_visited",
message: "Visited: Automakers Begin Pilot Production of Solid-State Cells",
detail: "example.com · authority tier 2",
},
{
id: `${runId}-e11`,
ts: atSeconds(60),
type: "passage_extracted",
message: "Stored passage p3 — 2027 limited deployment target",
},
{
id: `${runId}-e12`,
ts: atSeconds(72),
type: "source_visited",
message: "Visited: Thermal Safety of Next-Generation Battery Chemistries",
detail: "example.org · authority tier 2",
},
{
id: `${runId}-e13`,
ts: atSeconds(75),
type: "passage_extracted",
message: "Stored passage p4 — reduced heat release vs lithium-ion",
},
{
id: `${runId}-e14`,
ts: atSeconds(88),
type: "source_visited",
message: "Visited: Critical Materials Outlook: Lithium and Beyond",
detail: "example.com · authority tier 3",
},
{
id: `${runId}-e15`,
ts: atSeconds(91),
type: "passage_extracted",
message: "Stored passage p5 — lithium intensity supply risk",
},
{
id: `${runId}-e16`,
ts: atSeconds(102),
type: "source_visited",
message: "Visited: Venture Funding Flows Into Solid-State Startups",
detail: "example.net · authority tier 3",
},
{
id: `${runId}-e17`,
ts: atSeconds(105),
type: "passage_extracted",
message: "Stored passage p6 — record 2025 funding (rechecking source…)",
},
{
id: `${runId}-e18`,
ts: atSeconds(150),
type: "synthesis",
message: "Depth loop 1 — executive summary drafted",
detail: "Depth 1/3: covered technology state; adding obstacles section",
},
{
id: `${runId}-e19`,
ts: atSeconds(210),
type: "synthesis",
message: "Depth loop 2 — obstacles and landscape sections drafted",
detail: "Depth 2/3: gap found — supply-chain angle thin; expanding",
},
{
id: `${runId}-e20`,
ts: atSeconds(270),
type: "synthesis",
message: "Depth loop 3 — supply-chain section expanded; draft complete",
},
{
id: `${runId}-e21`,
ts: atSeconds(300),
type: "verification",
message: "Liveness pass: 5 live, 1 stale, 0 dead",
detail:
"p6 source (example.net) responded but content changed since extraction — marked stale",
},
{
id: `${runId}-e22`,
ts: atSeconds(312),
type: "verification",
message: "Quote-anchor pass: 6/6 cited quotes matched stored passages",
},
{
id: `${runId}-e23`,
ts: atSeconds(320),
type: "status",
message: "Run complete — report and citation ledger written",
},
];
}
/* ------------------------------------------------------------------ */
/* Run, report, ledger — question-parameterized */
/* ------------------------------------------------------------------ */
/** Deterministic run id derived from the question. */
export function scriptedRunId(question: string): string {
return `run-${hash(question)}`;
}
/** Tiny stable string hash (FNV-1a, 32-bit) for readable run ids. */
function hash(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36).padStart(7, "0");
}
/**
* Build the scripted Deep Report run. The question (and optional
* context) are embedded verbatim into the plan, so every screen in the
* click-path shows the user's own words. An explicit id override keeps
* stored-library run ids stable for deep links.
*/
export function buildScriptedRun(input: {
question: string;
context?: string;
methodologySlug: string;
id?: string;
}): ResearchRun {
const runId = input.id ?? scriptedRunId(input.question);
return {
id: runId,
status: "done",
plan: {
id: `${runId}-plan`,
question: input.question,
...(input.context !== undefined ? { context: input.context } : {}),
methodologySlug: input.methodologySlug,
subQuestions: [
"What is the current state of solid-state battery technology?",
"What obstacles remain between prototypes and mass production?",
"Which companies are building solid-state cells, and on what timeline?",
"What does the shift mean for critical-material supply chains?",
],
sourceUniverse: [
"open web (technology and industry press)",
"academic preprints (arXiv, PubMed-adjacent)",
"company announcements and pilot-line reports",
],
outline: [
"Executive summary",
"The state of the technology",
"Remaining obstacles",
"Who is building it — landscape",
"Supply-chain implications",
"Sources",
],
},
startedAt: T0,
events: scriptedTraceEvents(runId),
};
}
/**
* The scripted Deep Report — executive summary + 4 sections, [^pN] markers.
* An explicit title override keeps stored-library reports consistent with
* their library-card titles (the card title IS the report title).
*/
export function buildScriptedReport(
run: ResearchRun,
question: string,
title?: string,
): Report {
return {
id: `${run.id}-report`,
runId: run.id,
title: title ?? `Deep Report — ${question}`,
sections: [
{
heading: "Executive summary",
body:
"Solid-state batteries have moved from laboratory promise to pilot production: ceramic-separator cells now deliver simultaneous gains in energy density and thermal safety [^p1], and a Japanese consortium targets limited vehicle deployment by 2027 [^p3]. The central remaining obstacle is not bulk conductivity — sulfide electrolytes now match liquids [^p2] — but interface degradation across charge cycles [^p2]. Abuse testing shows substantially reduced heat release versus conventional lithium-ion [^p4], though the lithium-metal anodes these designs favor re-concentrate supply risk on lithium refining [^p5]. Funding hit a record in 2025 even as most startups remain pre-revenue [^p6].",
},
{
heading: "The state of the technology",
body:
"Replacing the flammable liquid electrolyte with a ceramic separator is the defining move of solid-state design, and it is what unlocks the chemistry's simultaneous density and safety gains [^p1]. On conductivity specifically, sulfide electrolytes have closed the gap: they now exhibit ionic conductivity comparable to liquid electrolytes [^p2].",
},
{
heading: "Remaining obstacles",
body:
"The frontier problem has shifted from making ions move to keeping interfaces alive: interface degradation during cycling remains the central obstacle to commercial cycle life [^p2]. Safety testing is encouraging but not unqualified — solid-state prototypes show substantially reduced heat release in abuse testing, while dendrite formation at high charge rates persists in some designs [^p4].",
},
{
heading: "Who is building it — landscape",
body:
"Industrialization is underway: a consortium of Japanese automakers and chemical firms has announced a pilot line targeting limited vehicle deployment of all-solid-state cells by 2027 [^p3]. Capital is following — venture funding for solid-state startups reached a record in 2025, though most funded companies remain pre-revenue [^p6].",
},
{
heading: "Supply-chain implications",
body:
"The technology does not escape materials geography; it reshuffles it. Solid-state designs favor lithium-metal anodes, which increases per-cell lithium intensity and re-concentrates supply-chain risk on lithium refining capacity [^p5].",
},
],
citations: [...scriptedCitations],
};
}
/** The run's citation ledger — the trust-loop audit record. */
export function buildScriptedLedger(run: ResearchRun): CitationLedger {
return {
runId: run.id,
citations: [...scriptedCitations],
generatedAt: atSeconds(320),
};
}
@@ -0,0 +1,143 @@
/**
* Boundary tests for the P5 shell surface (T-5.1, REQ-63..66).
*
* Exercises the NEW ApiClient methods against the mock binding —
* library payload shape, connection edges, settings round-trip,
* masked-key honesty, profile, and connection status. Runs against
* the real `api` singleton typed as ApiClient (house pattern: no
* component test ever mocks the boundary).
*/
import { describe, expect, it } from "vitest";
import { api } from "@/lib/api/client";
import type { ApiClient } from "@/lib/api/client";
const client: ApiClient = api;
describe("getReportLibrary (REQ-64)", () => {
it("returns folders and reports with consistent categories", async () => {
const library = await client.getReportLibrary();
const folderIds = new Set(library.folders.map((f) => f.id));
expect(folderIds.size).toBeGreaterThan(0);
for (const report of library.reports) {
expect(folderIds).toContain(report.category);
}
});
it("report summaries carry library-card fields", async () => {
const library = await client.getReportLibrary();
const first = library.reports[0];
if (first === undefined) throw new Error("no reports in fixture");
expect(first.title.length).toBeGreaterThan(0);
expect(first.question.length).toBeGreaterThan(0);
expect(first.methodologySlug).toBeTruthy();
expect(first.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(first.citationCount).toBeGreaterThanOrEqual(0);
});
it("every stored report's run deep-links resolve to its own title + question", async () => {
const library = await client.getReportLibrary();
for (const summary of library.reports) {
// The run behind the card: the card's own id, question, methodology.
const run = await client.getRun(summary.runId);
expect(run.id).toBe(summary.runId);
expect(run.plan.question).toBe(summary.question);
expect(run.plan.methodologySlug).toBe(summary.methodologySlug);
expect(run.status).toBe("done");
// The report headline matches the card title — no bait-and-switch.
const report = await client.getReport(summary.runId);
expect(report.title).toBe(summary.title);
expect(report.runId).toBe(summary.runId);
}
});
});
describe("getReportConnections (REQ-64)", () => {
it("edges reference reports that exist in the library", async () => {
const [library, connections] = await Promise.all([
client.getReportLibrary(),
client.getReportConnections(),
]);
const ids = new Set(library.reports.map((r) => r.id));
expect(connections.length).toBeGreaterThan(0);
for (const edge of connections) {
expect(ids).toContain(edge.fromReportId);
expect(ids).toContain(edge.toReportId);
}
});
it("every edge declares a kind and a human label", async () => {
const connections = await client.getReportConnections();
for (const edge of connections) {
expect(["shared-sources", "related-topic"]).toContain(edge.kind);
expect(edge.label.length).toBeGreaterThan(0);
}
});
});
describe("getUserProfile (REQ-66)", () => {
it("returns a local single-user profile with initials", async () => {
const profile = await client.getUserProfile();
expect(profile.name.length).toBeGreaterThan(0);
expect(profile.initials).toMatch(/^[A-Z]{2}$/);
expect(profile.tagline.length).toBeGreaterThan(0);
});
});
describe("getConnectionStatus (REQ-65)", () => {
it("covers all five adapter slots with honest statuses", async () => {
const slots = await client.getConnectionStatus();
const seen = new Set(slots.map((s) => s.slot));
expect(seen).toEqual(
new Set(["model", "search", "storage", "memory", "secrets"]),
);
for (const slot of slots) {
expect(["connected", "fallback", "preview"]).toContain(slot.status);
expect(slot.detail.length).toBeGreaterThan(0);
}
});
});
describe("getSettings / saveSettings (REQ-65)", () => {
it("returns masked API keys — never raw", async () => {
const settings = await client.getSettings();
for (const key of settings.apiKeys) {
// Masked shape: "sk-…XXXX" tail or empty for unconfigured.
expect(key.maskedValue).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
}
});
it("persists storage directory + default methodology updates", async () => {
const before = await client.getSettings();
const saved = await client.saveSettings({
storageDirectory: "~/researchx/archive",
defaultMethodologySlug: "rapid-answer",
});
expect(saved.storageDirectory).toBe("~/researchx/archive");
expect(saved.defaultMethodologySlug).toBe("rapid-answer");
// Round-trip: a fresh read sees the same values.
const reread = await client.getSettings();
expect(reread.storageDirectory).toBe("~/researchx/archive");
// Restore the fixture default for other test suites.
await client.saveSettings({
storageDirectory: before.storageDirectory,
defaultMethodologySlug: before.defaultMethodologySlug,
});
});
it("ignores client-side apiKeys/theme overwrites — backend owns masking", async () => {
const saved = await client.saveSettings({
apiKeys: [
{
id: "model",
label: "Model API key",
endpoint: "evil.example",
maskedValue: "sk-rawkeyexfiltrated",
configured: true,
},
],
});
expect(saved.apiKeys[0]?.endpoint).not.toBe("evil.example");
expect(saved.theme).toBe("paper");
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* App-shell fixtures (T-5.1, REQ-63..66) — shell/library/settings data.
*
* Stored reports with folder categorization, cross-report connections,
* masked API-key settings, adapter-slot connection status, and the local
* single-user profile. Clearly example content, consistent with the
* battery-research domain used across the prototype fixtures.
*/
import type { Settings } from "@/lib/types";
import type {
ReportConnection,
ReportLibrary,
UserProfile,
} from "@/lib/types";
/* ------------------------------------------------------------------ */
/* Stored reports & folders (REQ-64) */
/* ------------------------------------------------------------------ */
export const reportLibraryFixture: ReportLibrary = {
folders: [
{
id: "energy-storage",
name: "Energy storage",
description: "Battery chemistry, grid storage, and materials research.",
},
{
id: "transport",
name: "Transport",
description: "Vehicle electrification and deployment timelines.",
},
{
id: "policy",
name: "Policy & supply chains",
description: "Critical-materials outlooks and industrial strategy.",
},
],
reports: [
{
id: "rep-solid-state",
runId: "run-solid-state",
title: "Solid-State Battery Advances: A Deep Report",
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
category: "energy-storage",
generatedAt: "2026-09-20T14:32:00Z",
citationCount: 7,
},
{
id: "rep-electrolytes",
runId: "run-electrolytes",
title: "Sulfide vs. Oxide Electrolytes at Low Temperature",
question: "Do sulfide electrolytes outperform oxide ceramics below room temperature?",
methodologySlug: "rapid-answer",
category: "energy-storage",
generatedAt: "2026-09-22T09:14:00Z",
citationCount: 3,
},
{
id: "rep-pilots",
runId: "run-transport",
title: "Automotive Solid-State Pilot Production",
question: "Which solid-state startups have shipped pilot cells to automakers?",
methodologySlug: "rapid-answer",
category: "transport",
generatedAt: "2026-09-23T16:45:00Z",
citationCount: 4,
},
{
id: "rep-materials",
runId: "run-materials",
title: "Lithium Refining Capacity Through 2030",
question: "What is the lithium refining capacity expansion pipeline through 2030?",
methodologySlug: "deep-report",
category: "policy",
generatedAt: "2026-09-25T11:03:00Z",
citationCount: 9,
},
],
};
/* ------------------------------------------------------------------ */
/* Cross-report connections (REQ-64) */
/* ------------------------------------------------------------------ */
export const reportConnectionsFixture: ReportConnection[] = [
{
fromReportId: "rep-solid-state",
toReportId: "rep-electrolytes",
kind: "shared-sources",
label: "2 shared sources",
},
{
fromReportId: "rep-solid-state",
toReportId: "rep-pilots",
kind: "related-topic",
label: "Adjacent topic — pilot deployment",
},
{
fromReportId: "rep-solid-state",
toReportId: "rep-materials",
kind: "shared-sources",
label: "1 shared source",
},
{
fromReportId: "rep-electrolytes",
toReportId: "rep-materials",
kind: "related-topic",
label: "Adjacent topic — materials supply",
},
{
fromReportId: "rep-pilots",
toReportId: "rep-materials",
kind: "related-topic",
label: "Adjacent topic — 2027 targets",
},
];
/* ------------------------------------------------------------------ */
/* Settings (REQ-65) */
/* ------------------------------------------------------------------ */
export const settingsFixture: Settings = {
apiKeys: [
{
id: "model",
label: "Model API key",
endpoint: "Ollama cloud (ollama.com/v1)",
maskedValue: "sk-…7f2a",
configured: true,
},
{
id: "search",
label: "Search API key",
endpoint: "Tavily (api.tavily.com)",
maskedValue: "",
configured: false,
},
],
storageDirectory: "~/researchx/reports",
defaultMethodologySlug: "deep-report",
defaultDials: {
sourceUniverse: "web",
searchRouting: "web",
queryStrategy: "targeted",
breadth: 2,
depth: 3,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "analytical",
outputStructure: "report",
memoryScope: "none",
checkpoints: "plan-only",
},
theme: "paper",
};
/* ------------------------------------------------------------------ */
/* Connection status (REQ-65 — adapter-slot health) */
/* ------------------------------------------------------------------ */
import type { ConnectionSlot } from "@/lib/types";
export const connectionStatusFixture: ConnectionSlot[] = [
{
slot: "model",
label: "Model",
status: "connected",
detail: "Ollama cloud — key detected",
},
{
slot: "search",
label: "Search",
status: "fallback",
detail: "Tavily key missing — keyless DuckDuckGo fallback",
},
{
slot: "storage",
label: "Storage",
status: "connected",
detail: "Local filesystem — ~/researchx/reports",
},
{
slot: "memory",
label: "Memory",
status: "preview",
detail: "Stubbed off — preview until v0.2",
},
{
slot: "secrets",
label: "Secrets",
status: "preview",
detail: "Env-file backed — OpenBao preview until v0.2",
},
];
/* ------------------------------------------------------------------ */
/* User profile (REQ-66) */
/* ------------------------------------------------------------------ */
export const userProfileFixture: UserProfile = {
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
};
+35
View File
@@ -0,0 +1,35 @@
/**
* Ask store (T-3.1, REQ-55).
*
* Module-level client state carrying question + context from /ask to
* /choose. Deliberately simple for the prototype: a single mutable
* record with typed accessors. If a later phase needs reactivity, this
* is the ONLY module to upgrade — consumers already go through it.
*/
export interface AskState {
question: string;
context?: string;
}
let state: AskState | undefined;
/** Store the ask payload (called on submit, before routing to /choose). */
export function setAskState(next: AskState): void {
state = {
question: next.question,
...(next.context !== undefined && next.context !== ""
? { context: next.context }
: {}),
};
}
/** Read the carried ask payload; undefined if /ask was never submitted. */
export function getAskState(): AskState | undefined {
return state ? { ...state } : undefined;
}
/** Clear the carried payload (e.g. after a run is started). */
export function clearAskState(): void {
state = undefined;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Dial formatting tests (final-phase review fix — P0-1).
*
* formatDialValue must preserve separator meaning: hyphens stay
* hyphens ("per-claim" → "Per-Claim"), plus stays plus
* ("web+academic" → "Web+Academic"). This survived three phases
* unpinned; it is pinned now.
*/
import { describe, expect, it } from "vitest";
import { formatDialValue } from "@/lib/dials";
describe("formatDialValue", () => {
it("numbers pass through as strings", () => {
expect(formatDialValue(1)).toBe("1");
expect(formatDialValue(3)).toBe("3");
});
it("preserves hyphens: per-claim renders Per-Claim, not Per+Claim", () => {
expect(formatDialValue("per-claim")).toBe("Per-Claim");
expect(formatDialValue("plan-only")).toBe("Plan-Only");
expect(formatDialValue("plan+draft")).toBe("Plan+Draft");
});
it("preserves plus separators: web+academic renders Web+Academic", () => {
expect(formatDialValue("web+academic")).toBe("Web+Academic");
});
it("single words title-case; connectors stay lowercase", () => {
expect(formatDialValue("encyclopedic")).toBe("Encyclopedic");
// Space-separated words beyond connectors title-case normally;
// the connector rule keeps "and"/"or" lowercase mid-compound.
expect(formatDialValue("breadth and depth")).toBe("Breadth and depth");
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Dial display helpers — render DialValues for the Advanced drawer
* and the card's dial summary strip (T-3.2, REQ-56).
*/
import type { DialDefinition, DialValues } from "@/lib/types";
import { DIALS } from "@/lib/types";
/** Humanize a raw dial value: numbers pass through, strings title-case. */
export function formatDialValue(value: DialValues[keyof DialValues]): string {
if (typeof value === "number") return String(value);
// Split into words AND separators, title-case words, keep the
// original separator characters ("per-claim" → "Per-Claim",
// "web+academic" → "Web+Academic") — separators are meaningful.
return value
.split(/([-+])/)
.map((part) =>
part === "-" || part === "+"
? part
: part === "and" || part === "or"
? part
: part === ""
? part
: part.charAt(0).toUpperCase() + part.slice(1),
)
.join("");
}
/** All 12 dial definitions (for the Advanced drawer). */
export function allDials(): readonly DialDefinition[] {
return DIALS;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Citation-marker parsing (T-2.8, REQ-58).
*
* Splits a report section body on [^pN] citation markers into plain
* text segments and marker segments. NO HTML string building — the
* Reading Room maps these to React nodes, so nothing is ever
* injected; all text renders as escaped React text nodes.
*/
export interface MarkerRef {
passageId: string;
}
export type ReportBodySegment =
| { kind: "text"; text: string }
| { kind: "citation"; passageId: string };
const MARKER_RE = /\[\^p([A-Za-z0-9]+)\]/g;
/**
* Parse a section body into text/citation segments. Marker syntax is
* [^pN] where N is the passage id suffix (p1, p2, …). Unmatched or
* malformed markers remain as plain text.
*/
export function parseReportBody(body: string): ReportBodySegment[] {
const segments: ReportBodySegment[] = [];
let last = 0;
MARKER_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MARKER_RE.exec(body)) !== null) {
if (match.index > last) {
segments.push({ kind: "text", text: body.slice(last, match.index) });
}
segments.push({ kind: "citation", passageId: `p${match[1]}` });
last = match.index + match[0].length;
}
if (last < body.length) {
segments.push({ kind: "text", text: body.slice(last) });
}
return segments;
}
+446
View File
@@ -0,0 +1,446 @@
/**
* ResearchX domain types (T-1.3, REQ-53).
*
* The vocabulary of the ApiClient contract. These types are the mock/
* backend boundary: v0.2 swaps MockApiClient for the OpenAPI-generated
* client and these shapes absorb any drift — components never change.
*/
/* ------------------------------------------------------------------ */
/* Dials */
/* ------------------------------------------------------------------ */
/**
* The 12 methodology dials. Every run snapshots the resolved values,
* making runs reproducible and auditable (REQ-04 vocabulary, surfaced
* in the prototype via the Advanced dials drawer).
*/
export interface DialValues {
/** Which sources may be consulted — "web", "academic", "corpus", … */
sourceUniverse: string;
/** How queries are routed — "web", "academic", "hybrid", … */
searchRouting: string;
/** How queries are formed — "broad", "targeted", "iterative" */
queryStrategy: string;
/** How many parallel sub-questions / branches (1–5) */
breadth: number;
/** How many synthesis-reflection loops (1–5) */
depth: number;
/** Evidence quality bar — "standard", "strict" */
qualityGates: string;
/** Citation strictness — "per-claim", "section", "loose" */
citationRigor: string;
/** Post-run verification passes (0–3) */
verificationPasses: number;
/** Prose voice — "encyclopedic", "analytical", "memo" */
synthesisStyle: string;
/** Document shape — "report", "memo", "matrix", "dossier" */
outputStructure: string;
/** Working memory — "none", "project" */
memoryScope: string;
/** Human checkpoint points — "plan-only", "plan+draft" */
checkpoints: string;
}
/** Static metadata for one dial — drives the Advanced drawer UI. */
export interface DialDefinition {
key: keyof DialValues;
label: string;
description: string;
default: DialValues[keyof DialValues];
}
/** All 12 dials with label + one-line description + default value. */
export const DIALS: readonly DialDefinition[] = [
{
key: "sourceUniverse",
label: "Source universe",
description: "Which sources may be consulted — web, academic, corpus.",
default: "web",
},
{
key: "searchRouting",
label: "Search routing",
description: "How queries are routed across search backends.",
default: "web",
},
{
key: "queryStrategy",
label: "Query strategy",
description: "How search queries are formed and refined.",
default: "targeted",
},
{
key: "breadth",
label: "Breadth",
description: "Number of parallel sub-questions explored (1–5).",
default: 2,
},
{
key: "depth",
label: "Depth",
description: "Synthesis-refinement loops the engine runs (1–5).",
default: 3,
},
{
key: "qualityGates",
label: "Quality gates",
description: "Evidence bar sources must clear to be cited.",
default: "standard",
},
{
key: "citationRigor",
label: "Citation rigor",
description: "How densely claims are anchored to passages.",
default: "per-claim",
},
{
key: "verificationPasses",
label: "Verification passes",
description: "Post-run liveness and quote-anchor passes (0–3).",
default: 1,
},
{
key: "synthesisStyle",
label: "Synthesis style",
description: "Prose voice of the final document.",
default: "analytical",
},
{
key: "outputStructure",
label: "Output structure",
description: "Overall document shape the writer targets.",
default: "report",
},
{
key: "memoryScope",
label: "Memory scope",
description: "Whether prior project knowledge is consulted.",
default: "none",
},
{
key: "checkpoints",
label: "Checkpoints",
description: "Where the run pauses for human review.",
default: "plan-only",
},
] as const;
/* ------------------------------------------------------------------ */
/* Methodology */
/* ------------------------------------------------------------------ */
/** How thoroughly the trust loop verifies this methodology's output. */
export type VerificationLevel = "per-claim" | "full" | "standard";
/**
* A locked methodology: a named dial bundle plus the card-facing
* metadata (ETA, cost, why-recommended) rendered on /choose.
*/
export interface Methodology {
slug: string;
name: string;
tagline: string;
description: string;
/** Approximate wall-clock duration, in minutes (mock heuristic). */
etaMinutes: number;
/** Human-readable cost estimate, e.g. "$0.25 – $0.80". */
costEstimate: string;
/** Question-shaped guidance: when to pick this methodology. */
whyRecommended: string;
/** True only on the default pick (Deep Report in the prototype). */
recommended: boolean;
verification: VerificationLevel;
/** 3–5 human-readable default summaries, e.g. "Breadth: 2 · Depth: 3". */
dialSummary: string[];
/** The dial bundle this methodology resolves to. */
defaultDials: DialValues;
/** Optional corner badge, e.g. "Fastest". */
badge?: string;
}
/* ------------------------------------------------------------------ */
/* Run */
/* ------------------------------------------------------------------ */
export type RunStatus =
| "planning"
| "awaiting_approval"
| "searching"
| "extracting"
| "reflecting"
| "synthesizing"
| "verifying"
| "done"
| "cancelled";
/** The plan the engine halts on until approved (REQ-14 vocabulary). */
export interface RunPlan {
id: string;
question: string;
context?: string;
methodologySlug: string;
subQuestions: string[];
sourceUniverse: string[];
outline: string[];
}
/** A research run — the root aggregate the API speaks in. */
export interface ResearchRun {
id: string;
status: RunStatus;
plan: RunPlan;
startedAt: string;
events?: TraceEvent[];
}
/** One line of the live trace stream (SSE in v0.2). */
export interface TraceEvent {
id: string;
ts: string;
type:
| "query"
| "source_visited"
| "passage_extracted"
| "synthesis"
| "verification"
| "plan"
| "status";
message: string;
detail?: string;
}
/* ------------------------------------------------------------------ */
/* Trust loop */
/* ------------------------------------------------------------------ */
/** Post-run URL liveness classification (REQ-19 vocabulary). */
export type CitationHealth = "live" | "stale" | "dead";
/** A page-anchored citation: exact quote + stable passage ID. */
export interface Citation {
id: string;
url: string;
title: string;
site: string;
passageId: string;
/** Verbatim quote from the stored passage — hover shows exactly this. */
quote: string;
verification: CitationHealth;
/** 1 = primary/authoritative, 2 = reputable, 3 = context. */
authorityTier: 1 | 2 | 3;
}
/** The per-run audit record of every consulted source (REQ-21). */
export interface CitationLedger {
runId: string;
citations: Citation[];
generatedAt: string;
}
/* ------------------------------------------------------------------ */
/* Report */
/* ------------------------------------------------------------------ */
/** A report section — body may carry [^passageId] citation markers. */
export interface ReportSection {
heading: string;
body: string;
}
/** The final document the Reading Room renders. */
export interface Report {
id: string;
runId: string;
title: string;
sections: ReportSection[];
citations: Citation[];
}
/* ------------------------------------------------------------------ */
/* v2 surfaces (library mockups — typed early, rendered in P3) */
/* ------------------------------------------------------------------ */
/** A node in the cross-project graph (v2 vision vocabulary). */
export interface GraphNode {
id: string;
topic: string;
/** Source titles/passages shared with other projects. */
linkedSources: string[];
/** Findings this project contributed. */
findings: string[];
}
/** A cross-link between two projects — evidence of shared grounding. */
export interface GraphEdge {
fromId: string;
toId: string;
sharedSourceCount: number;
}
/** The project graph mockup payload (maps to GET /api/graph in v0.2). */
export interface ProjectGraph {
nodes: GraphNode[];
edges: GraphEdge[];
}
/** An inbox follow-up extracted from a past run (v2 task vocabulary). */
export interface InboxItem {
id: string;
question: string;
sourceRunId: string;
status: TaskStatus;
}
/* ------------------------------------------------------------------ */
/* Tasks (v2 task inbox vocabulary — typed early, rendered in P3) */
/* ------------------------------------------------------------------ */
export type TaskStatus = "open" | "in_progress" | "done";
export interface Task {
id: string;
title: string;
status: TaskStatus;
/** Where the task came from — e.g. a run id or "inbox". */
source?: string;
}
/* ------------------------------------------------------------------ */
/* App shell (P5 vocabulary — REQ-63..66) */
/* ------------------------------------------------------------------ */
/* ---- Stored reports & folders (REQ-64) --------------------------- */
/** A stored report as the library sees it — summary, not full body. */
export interface ReportSummary {
id: string;
runId: string;
title: string;
question: string;
methodologySlug: string;
/** Auto-assigned category — the folder the report is filed under. */
category: string;
/** ISO date the report was generated. */
generatedAt: string;
/** Citation count — shown on the library card. */
citationCount: number;
}
/** Sort keys offered by the library's report list. */
export type ReportSortKey = "recent" | "title";
/** A folder in the library's folder-structure storage view. */
export interface ReportFolder {
/** Stable folder id — matches `ReportSummary.category`. */
id: string;
name: string;
description: string;
}
/** The library payload: folders + the reports filed in them. */
export interface ReportLibrary {
folders: ReportFolder[];
reports: ReportSummary[];
}
/* ---- Cross-report connections (REQ-64) ---------------------------- */
/** Why two reports are connected — shared sources or related topic. */
export type ReportConnectionKind = "shared-sources" | "related-topic";
/** An edge between two stored reports in the connections panel. */
export interface ReportConnection {
fromReportId: string;
toReportId: string;
kind: ReportConnectionKind;
/** Human label, e.g. "2 shared sources" or "Adjacent topic". */
label: string;
}
/* ---- Settings (REQ-65) --------------------------------------------- */
/** One configurable API-key slot — masked at rest, never echoed raw. */
export interface ApiKeySetting {
/** Setting id, e.g. "model", "search". */
id: string;
label: string;
/** Where the key is used — e.g. "Ollama cloud (ollama.com/v1)". */
endpoint: string;
/** Masked value — "sk-…3f2a" shape; raw keys never reach the UI. */
maskedValue: string;
/** True when a key is present. */
configured: boolean;
}
/** Adapter-slot health as the settings/connection surface sees it. */
export interface ConnectionSlot {
/** Adapter slot — one of the five pluggable slots. */
slot: "model" | "search" | "storage" | "memory" | "secrets";
label: string;
/** Adapter status — v0.2 probes report live/misconfigured/preview. */
status: "connected" | "fallback" | "preview";
/** Human detail, e.g. "Ollama cloud — key detected" or "Preview until v0.2". */
detail: string;
}
/** The whole settings payload (maps to GET/PUT /api/config in v0.2). */
export interface Settings {
apiKeys: ApiKeySetting[];
/** Local directory reports are written to (REQ-10 vocabulary). */
storageDirectory: string;
/** Default methodology slug for new runs. */
defaultMethodologySlug: string;
/** Resolved default dials — the REQ-04 reproducibility display. */
defaultDials: DialValues;
/** Appearance slot — light-only today; preview-marked. */
theme: "paper";
}
/** The local single-user profile behind the avatar button (REQ-66). */
export interface UserProfile {
/** Display name — local profile, no auth (single-user constraint). */
name: string;
/** Two-letter initials rendered inside the avatar disc. */
initials: string;
/** Role line under the name in the menu. */
tagline: string;
}
/* ------------------------------------------------------------------ */
/* Ask intelligence (P7 vocabulary — REQ-67, REQ-68) */
/* ------------------------------------------------------------------ */
/**
* A follow-up query suggestion on the New Research view — derived
* from an unanswered open question (task-inbox item) of a past run.
*/
export interface SuggestedQuery {
id: string;
/** The suggested question text — loaded into the question box. */
question: string;
/** The run the open question came from. */
sourceRunId: string;
/** Source report title for attribution ("from your research"). */
sourceReportTitle: string;
}
/** One clarifying question in the refine dialogue. */
export interface RefinementQuestion {
id: string;
/** The clarifying question shown to the user. */
prompt: string;
/** Input hint for the answer box. */
placeholder: string;
/** True when an answer may be skipped (the composer tolerates it). */
optional: boolean;
}
/** The composed result of a completed refine exchange. */
export interface RefinedQuestion {
/** The refined research question. */
question: string;
/** Suggested context assembled from the answers. */
context: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
/**
* T-1.1 smoke test — verifies the Vitest + jsdom + Testing Library wiring
* is functional before the real test suites land (mock-client, ask, choose).
*/
describe("test harness", () => {
it("runs in a jsdom environment", () => {
expect(window).toBeDefined();
expect(document).toBeDefined();
expect(typeof document.createElement).toBe("function");
});
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+20
View File
@@ -0,0 +1,20 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
css: false,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", ".next/**"],
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});