ship: plan & run + reading room v0 (P2) #2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"stage": "complete",
|
||||
"phase": 2,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.1",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-27T00:55:00Z"
|
||||
"updated_at": "2026-09-27T01:30:00Z"
|
||||
}
|
||||
+50
-52
@@ -1,80 +1,78 @@
|
||||
# PLAN — Milestone v0.1 Phase 1: Frontend Scaffold + Ask + Choose
|
||||
# PLAN — Milestone v0.1 Phase 2: Plan & Run + Reading Room v0
|
||||
|
||||
---
|
||||
phase: 1
|
||||
phase: 2
|
||||
milestone: v0.1
|
||||
status: planned
|
||||
must_haves:
|
||||
- "Next.js App Router scaffold with TypeScript strict, Tailwind, Vitest — typecheck/lint/test wired and green"
|
||||
- "ApiClient interface + MockApiClient covering all 8 methodologies + scripted Deep Report run"
|
||||
- "/ask and /choose routes rendering from mock data with zero API keys"
|
||||
- "Plan & Run screen: plan approval UI (sub-questions, source universe, outline), scripted live trace stream, pause/cancel controls that visibly halt/restart the replay"
|
||||
- "Reading Room v0: report reader with page-anchored citations (hover shows exact quoted passage), citation ledger panel with live/stale verification statuses"
|
||||
- "Functional exports: real downloadable Markdown/JSON files generated from mock data; citations survive as real links"
|
||||
- "Mock store upgrade: multi-run Map (P1 flag P1-3) so browser-back to earlier runs no longer 404s"
|
||||
personas:
|
||||
- frontend-engineer
|
||||
requirements: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56]
|
||||
requirements: [REQ-57, REQ-58, REQ-59]
|
||||
---
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
The visible deliverable of this phase is the first half of the prototype click-path:
|
||||
- **`/ask`** — the front door: a single question box + one optional context box, a "Research this" primary action, and nothing else competing for attention. Submitting stores question+context and routes to `/choose`.
|
||||
- **`/choose`** — the methodology chooser: all 8 locked methodology cards (Rapid Answer, Deep Report, SOTA Survey, Systematic Review, Fact-Check Dossier, Multi-Perspective Dossier, Competitive Landscape, Corpus Interrogation), each showing mock ETA, cost estimate, why-recommended, and a dial summary strip; a "Recommended" badge on Deep Report; an **Advanced dials drawer** per card (progressive disclosure) exposing the 12 dials with their defaults; "Start research" routes to `/run/[id]`.
|
||||
|
||||
Later phases (2–3) deliver `/run/[id]` and `/library`; phase 4 is the owner approval gate. A README quickstart (`pnpm install && pnpm dev`) is part of this phase's surface so the owner can run it locally.
|
||||
- **`/run/[id]`** — the Plan & Run screen, replacing P1's placeholder. Two-stage flow mirroring the real engine: (1) **plan approval** — the run halts showing its plan (sub-questions, source universe, outline) with Approve/Edit-acknowledge actions; (2) **live trace** — on approval, scripted TraceEvents stream into a scrolling terminal-like panel (query fired → source visited → passage extracted → … → verification), with Pause/Resume and Cancel controls that visibly halt and restart the event stream. When the run reaches `done`, a link opens the Reading Room.
|
||||
- **`/run/[id]/report`** — Reading Room v0: the rendered report (title, sections, per-claim citation markers), a citation ledger panel listing every source with URL, verification status (live/stale), authority tier, and passage quotes; hovering/focusing a citation marker in the body reveals the exact quoted passage (tooltip/inline popover); Export buttons download real Markdown and JSON files (citations as real links).
|
||||
- README updated with the two new screens.
|
||||
|
||||
## Happy Path
|
||||
|
||||
End-to-end scenario, executable immediately after this phase:
|
||||
|
||||
1. Owner runs `pnpm install && pnpm dev` (README quickstart, REQ-52)
|
||||
2. Opens `http://localhost:3000` → redirected to `/ask`
|
||||
3. Types "What are the latest advances in solid-state batteries?" into the question box; leaves context empty
|
||||
4. Clicks "Research this" → lands on `/choose`
|
||||
5. Sees 8 methodology cards; Deep Report is badged "Recommended" with ETA ~12 min, mock cost, why-recommended text
|
||||
6. Opens the Advanced drawer on any card → the 12 dials with defaults are visible (progressive disclosure, REQ-56)
|
||||
7. Clicks "Start research" on Deep Report → routes to `/run/[id]` (placeholder in this phase: "Plan & Run ships in phase 2")
|
||||
|
||||
Three clicks from cold start to a chosen methodology with research initiated — the ≤3-click criterion (REQ-61) exercised on its first two legs.
|
||||
1. Owner follows the P1 path: `/ask` → question → `/choose` → Deep Report → Start research
|
||||
2. Lands on `/run/[id]` in AWAITING_APPROVAL: sees the plan (3 sub-questions, source universe, outline) and an Approve button
|
||||
3. Clicks **Approve plan** — trace events begin streaming live (visible one-by-one arrival)
|
||||
4. Mid-stream, clicks **Pause** — stream visibly halts (button becomes Resume); clicks **Resume** — stream continues
|
||||
5. Stream completes → status DONE, link to **Read the report**
|
||||
6. In Reading Room: hovers citation marker `[3]` → exact quoted passage appears with source title + verification badge
|
||||
7. Clicks **Export Markdown** → real `.md` file downloads; opens it: citations are real links
|
||||
8. Browser-back to an earlier run from the same session → it still renders (P1-3 fixed)
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
- **Ask screen**: exactly one question box + one optional context box; no other inputs; clear primary action. Question+context survive navigation to Choose (no loss on route change).
|
||||
- **Choose screen**: all 8 cards visible without scrolling failure states; each card shows ETA, cost, why-recommended, dial summary; "Recommended" badge on exactly one card; Advanced drawer opens/closes per card without layout shift (CLS-safe).
|
||||
- **Progressive disclosure**: dial details hidden by default; the surface shows only what a 3-click user needs (D-018 typography tokens applied to both screens).
|
||||
- **Trust affordance**: mock verification badges and confidence labels visible in card metadata where the methodology's dials demand them — the UI language of the trust loop appears from the first screen.
|
||||
- **Zero keys**: the app runs with no env vars, no network calls beyond static assets.
|
||||
- **Accessibility**: citation-bearing interactive elements are keyboard-reachable; focus states visible.
|
||||
- **Quality gates**: `pnpm typecheck`, `pnpm lint`, `pnpm test` all green; MockApiClient fixture data drives all rendered content.
|
||||
- Plan stage: sub-questions/source universe/outline clearly presented before any trace activity; Approve is the sole primary action
|
||||
- Trace: events appear progressively (not all at once); each event shows type, timestamp, message; pause/cancel reachable at all times during streaming; cancel returns to a safe state (run marked cancelled, link back to Choose)
|
||||
- Reading Room: report body has reading-measure (~68ch); citation markers are keyboard-focusable, hover AND focus reveal the quote; ledger lists all 6 sources with live/stale badges; verification language visible (trust affordance)
|
||||
- Exports: Markdown has title, sections, and a Sources section where each citation is a real `[title](url)` link; JSON is valid (parseable) with full report + ledger structure
|
||||
- All data flows through the `ApiClient` interface (`approvePlan`, `getRunEvents`, `getReport`, `getLedger`, `exportReport`) — no component imports MockApiClient
|
||||
- Quality gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green
|
||||
|
||||
## Tasks (wave-ordered; persona: frontend-engineer unless noted)
|
||||
|
||||
### Wave 1 — Foundation
|
||||
- **T-1.1**: Scaffold Next.js App Router app in `frontend/` (TS strict, ESLint, Tailwind v4, Vitest); package `@researchx/frontend`; scripts `dev/build/lint/typecheck/test` (REQ-52)
|
||||
- **T-1.2**: Design tokens: typography scale (size/leading/measure per vision §4), color, spacing as Tailwind theme extension (D-018)
|
||||
- **T-1.3**: Domain types: Methodology, DialValues, RunPlan, ResearchRun, TraceEvent, Citation, CitationLedger, Task — the vocabulary of the ApiClient contract (REQ-53)
|
||||
- **T-1.4**: ApiClient interface: `getMethodologies()`, `createRun()`, `getRun()`, `approvePlan()`, `getRunEvents()` (EventSource-shaped), `getReport()`, `getLedger()`, `exportReport()` (REQ-53)
|
||||
### Wave 1 — Mock engine upgrade
|
||||
- **T-2.1**: MockApiClient multi-run store: `Map<string, ResearchRun>` keyed by run id; createRun registers; getRun resolves from map (P1 flag P1-3); approvePlan persists status transitions (awaiting_approval → searching → …) (REQ-57)
|
||||
- **T-2.2**: getRunEvents replay upgrade: pause/resume-aware subscription — the EventSource-shaped handle gains pause()/resume(); events stream on interval; cancel() stops and clears timers (no leaks) (REQ-57)
|
||||
- **T-2.3**: Report/ledger/export fixtures: exportReport('markdown') returns full report md with Sources section (real links); exportReport('json') returns parseable JSON of report+ledger (REQ-59)
|
||||
|
||||
### Wave 2 — Mock data layer
|
||||
- **T-2.1**: MockApiClient: fixture data for all 8 methodologies (ETA/cost/why-recommended/dial defaults per ROADMAP) (REQ-53, REQ-56)
|
||||
- **T-2.2**: Scripted Deep Report run: plan → trace events → report with ~6 sources, page-anchored citations with exact quotes, verification statuses (live/stale), confidence labels (REQ-53, D-012)
|
||||
- **T-2.3**: Vitest coverage: fixtures typecheck; MockApiClient satisfies ApiClient (REQ-53)
|
||||
### Wave 2 — Plan & Run screen
|
||||
- **T-2.4**: `/run/[id]` plan approval stage: sub-questions list, source universe chips, outline; Approve button → api.approvePlan → transitions to trace stage (REQ-57)
|
||||
- **T-2.5**: Trace stream UI: progressive event rendering (query/source/passage/synthesis/verification types with distinct visual treatment), auto-scroll, Pause/Resume + Cancel controls wired to subscription (REQ-57)
|
||||
- **T-2.6**: Run completion → DONE state with link to `/run/[id]/report` (REQ-57)
|
||||
|
||||
### Wave 3 — Screens
|
||||
- **T-3.1**: `/ask` — question + optional context, "Research this" → `/choose` with state carried (REQ-55)
|
||||
- **T-3.2**: `/choose` — 8 cards, Recommended badge, dial summary strips, Advanced drawer (12 dials, defaults), "Start research" (REQ-56)
|
||||
- **T-3.3**: Component tests: Ask state carry, Choose renders 8 cards from MockApiClient, drawer disclosure (REQ-55, REQ-56)
|
||||
- **T-3.4**: Root redirect → `/ask`; README quickstart section (REQ-52)
|
||||
### Wave 3 — Reading Room v0
|
||||
- **T-2.7**: Report reader: title, sections, citation markers rendered from Report data; reading-measure typography (REQ-58)
|
||||
- **T-2.8**: Citation interaction: hover/focus on marker → popover with exact quote, source title, verification badge (live/stale), authority tier; ledger panel listing all sources (REQ-58)
|
||||
- **T-2.9**: Export buttons: download Markdown + JSON via exportReport; real files, citations as links (REQ-59)
|
||||
- **T-2.10**: README update: new screens section (REQ-57..59)
|
||||
|
||||
### Wave 4 — Gate (persona: ci-lead-developer)
|
||||
- **T-4.1**: Boundary review: no component imports MockApiClient directly (REQ-54); typecheck/lint/test green; commit discipline verified
|
||||
### Wave 4 — Tests (persona: frontend-engineer)
|
||||
- **T-2.11**: Plan approval flow test (approve → status change → trace begins); pause/resume test; cancel test (REQ-57)
|
||||
- **T-2.12**: Reading Room tests: markers render, popover shows quote on hover/focus, ledger lists sources; export content tests (markdown contains links, JSON parses) (REQ-58, REQ-59)
|
||||
- **T-2.13**: Mock store regression: two runs created, both retrievable (P1-3 closure) (REQ-57)
|
||||
|
||||
### Wave 5 — Gate (persona: ci-lead-developer)
|
||||
- **T-2.14**: Boundary review: no component imports mock layer; timer cleanup verified (no dangling intervals); typecheck/lint/test/build green
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm typecheck && pnpm lint && pnpm test` green
|
||||
- Manual happy-path walk (above) passes its 7 steps
|
||||
- Requirement coverage: REQ-52 ✓, REQ-53 ✓, REQ-54 ✓ (boundary review), REQ-55 ✓, REQ-56 ✓
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green
|
||||
- Happy-path walk (8 steps above) passes
|
||||
- Requirement coverage: REQ-57 ✓, REQ-58 ✓, REQ-59 ✓
|
||||
- P1 flags closed: P1-3 (multi-run store) closed by T-2.1/T-2.13; P1-4 partially (run-view now exercised by T-2.11..13)
|
||||
|
||||
## Delegation (execute workflow)
|
||||
## Scope Guard
|
||||
|
||||
Phase 1 executes via the frontend-engineer persona subagent (waves 1–3), ci-lead-developer for wave 4 boundary review. Task commits use `feat(P01-NN): …` with `---ci---` blocks carrying `phase: 1`, `milestone: v0.1`, `persona`, `task`, `requirements.covered`. Ship delegates to the ship workflow (tag v0.1.1, PR phase/01 → milestone/v0.1-prototype).
|
||||
|
||||
**Scope guard**: this plan covers v0.1 P1 only. `/run/[id]`, `/library`, exports, v2 mockups are P2/P3 plans written at their phase boundaries (P0 of this checkpoint does not plan them in detail). No MVP (v0.2) work is planned or executed before v0.1.4 + owner approval.
|
||||
This plan covers v0.1 P2 only. v2 mockups (project graph, task inbox), typography pass, 3-click validation are P3 (REQ-60, REQ-61). P1-flags P1-1/P1-2 (drawer hyphen rendering, empty-question guard) remain routed to P4 pre-demo fixes. No MVP (v0.2) work.
|
||||
+14
-5
@@ -22,8 +22,14 @@ 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]` (a placeholder in this phase: the
|
||||
Plan & Run screen ships in phase 2)
|
||||
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
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -55,12 +61,15 @@ The happy path:
|
||||
|
||||
```
|
||||
src/
|
||||
app/ App Router routes (ask, choose, run/[id])
|
||||
app/ App Router routes (ask, choose, run/[id], run/[id]/report)
|
||||
components/ UI components (server by default, client where interactive)
|
||||
lib/ Domain types, ApiClient boundary, mock data, stores
|
||||
test/ Test harness setup
|
||||
```
|
||||
|
||||
Prototype scope note: ETAs, cost estimates, and the scripted run are
|
||||
realistic fixture data — not live research. The Reading Room, live
|
||||
trace, and exports ship in later phases of the milestone.
|
||||
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. The v2 surface mockups (project graph, task inbox) ship in the
|
||||
next phase.
|
||||
@@ -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 (
|
||||
<main className="mx-auto max-w-6xl px-6 py-section">
|
||||
<ReadingRoom id={id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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("arxiv.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://arxiv.org/abs/2401.00001)");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
"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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,66 +1,173 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RunView (T-3.2b, client half) — the /run/[id] placeholder body.
|
||||
* RunView (T-2.4..T-2.6, REQ-57) — the /run/[id] Plan & Run screen.
|
||||
*
|
||||
* Reads the run through the ApiClient on the CLIENT because the mock
|
||||
* binding keeps its scripted-run state in browser module state
|
||||
* (createRun was called there). Phase 2 replaces this with the real
|
||||
* server-rendered Plan & Run screen backed by GET /api/runs/{id}.
|
||||
* 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 { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { Methodology, ResearchRun } from "@/lib/types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type RunEventSubscription } from "@/lib/api/client";
|
||||
import type { Methodology, ResearchRun, TraceEvent } from "@/lib/types";
|
||||
|
||||
type RunState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "ready"; run: ResearchRun; methodology: Methodology | undefined }
|
||||
| { phase: "error"; message: string };
|
||||
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 [state, setState] = useState<RunState>({ phase: "loading" });
|
||||
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 function load() {
|
||||
(async () => {
|
||||
try {
|
||||
const [run, methodologies] = await Promise.all([
|
||||
api.getRun(id),
|
||||
api.getMethodologies(),
|
||||
]);
|
||||
const run = await api.getRun(id);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
phase: "ready",
|
||||
run,
|
||||
methodology: methodologies.find(
|
||||
(m) => m.slug === run.plan.methodologySlug,
|
||||
),
|
||||
});
|
||||
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) return;
|
||||
setState({
|
||||
phase: "error",
|
||||
message: cause instanceof Error ? cause.message : "Run not found",
|
||||
});
|
||||
if (!cancelled) {
|
||||
setStage({
|
||||
kind: "error",
|
||||
message: cause instanceof Error ? cause.message : "Run not found",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
void load();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
subscriptionRef.current?.cancel();
|
||||
subscriptionRef.current = null;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (state.phase === "loading") {
|
||||
// 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 (state.phase === "error") {
|
||||
if (stage.kind === "error") {
|
||||
return (
|
||||
<div>
|
||||
<p role="alert" className="mt-8 text-small text-danger">
|
||||
{state.message}
|
||||
{stage.message}
|
||||
</p>
|
||||
<p className="mt-block text-caption">
|
||||
<Link href="/ask" className="text-accent underline underline-offset-2">
|
||||
@@ -71,7 +178,49 @@ export function RunView({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const { run, methodology } = state;
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { run, methodology } = stage;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -92,25 +241,117 @@ export function RunView({ id }: { id: string }) {
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-block rounded-card border border-hairline bg-accent-wash px-5 py-4">
|
||||
<p className="text-body font-medium text-accent-ink">
|
||||
Plan & Run ships in phase 2
|
||||
</p>
|
||||
<p className="measure-reading mt-1 text-small text-ink-soft">
|
||||
In the prototype, the live trace stream, plan approval, and
|
||||
pause/cancel controls are the next milestone phase. Your run id is{" "}
|
||||
<code className="rounded bg-paper-raised px-1.5 py-0.5 text-caption text-ink">
|
||||
{run.id}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{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}
|
||||
|
||||
<p className="mt-block text-caption">
|
||||
<Link href="/ask" className="text-accent underline underline-offset-2">
|
||||
Ask another question
|
||||
</Link>
|
||||
</p>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -38,9 +38,17 @@ export interface CreateRunInput {
|
||||
dialOverrides?: Partial<DialValues>;
|
||||
}
|
||||
|
||||
/** Subscription handle for the live trace stream (EventSource-shaped). */
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +68,9 @@ export interface ApiClient {
|
||||
/** 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.
|
||||
|
||||
@@ -138,8 +138,15 @@ describe("MockApiClient (bound as `api`)", () => {
|
||||
const run = await client.createRun({ question, methodologySlug: "deep-report" });
|
||||
const json = await client.exportReport(run.id, "json");
|
||||
expect(json.filename).toMatch(/\.json$/);
|
||||
const parsed = JSON.parse(json.content) as { runId: string };
|
||||
expect(parsed.runId).toBe(run.id);
|
||||
// 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 () => {
|
||||
@@ -171,4 +178,95 @@ describe("MockApiClient (bound as `api`)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -22,17 +22,37 @@ import type {
|
||||
TraceEvent,
|
||||
} from "@/lib/types";
|
||||
|
||||
/** Handle returned by getRunEvents — EventSource-shaped cancel(). */
|
||||
/** 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 {
|
||||
/** The most recent run created in this session (getRun/getReport/… key off it). */
|
||||
#lastRun: ResearchRun | undefined;
|
||||
/**
|
||||
* 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];
|
||||
@@ -47,30 +67,51 @@ export class MockApiClient implements ApiClient {
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
this.#lastRun = run;
|
||||
return run;
|
||||
const runAwaitingApproval: ResearchRun = { ...run, status: "awaiting_approval" };
|
||||
this.#runs.set(run.id, runAwaitingApproval);
|
||||
return structuredClone(runAwaitingApproval);
|
||||
}
|
||||
|
||||
async getRun(id: string): Promise<ResearchRun> {
|
||||
// A run id that matches the scripted question hash, or the most
|
||||
// recent created run — either resolves to the same scripted data.
|
||||
if (this.#lastRun?.id === id || this.#lastRun === undefined) {
|
||||
return structuredClone(this.#lastRun ?? this.#defaultRun());
|
||||
const stored = this.#runs.get(id);
|
||||
if (stored) {
|
||||
return structuredClone(stored);
|
||||
}
|
||||
if (id === this.#defaultRun().id) {
|
||||
return structuredClone(this.#defaultRun());
|
||||
}
|
||||
throw new Error(`MockApiClient: run "${id}" not found`);
|
||||
// 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);
|
||||
return { ...run, status: "searching" };
|
||||
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;
|
||||
}
|
||||
|
||||
getRunEvents(
|
||||
@@ -81,21 +122,47 @@ export class MockApiClient implements ApiClient {
|
||||
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.
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
if (index >= events.length) {
|
||||
// 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);
|
||||
onDone?.();
|
||||
return;
|
||||
timer = undefined;
|
||||
}
|
||||
onEvent(events[index] as TraceEvent);
|
||||
index += 1;
|
||||
}, EVENT_REPLAY_INTERVAL_MS);
|
||||
};
|
||||
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;
|
||||
clearInterval(timer);
|
||||
stopTimer();
|
||||
},
|
||||
pause(): void {
|
||||
paused = true;
|
||||
},
|
||||
resume(): void {
|
||||
paused = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -116,14 +183,15 @@ export class MockApiClient implements ApiClient {
|
||||
): Promise<{ filename: string; content: string }> {
|
||||
const run = await this.getRun(id);
|
||||
const report = buildScriptedReport(run, run.plan.question);
|
||||
const ledger = buildScriptedLedger(run);
|
||||
if (format === "json") {
|
||||
return {
|
||||
filename: `${report.runId}-report.json`,
|
||||
content: JSON.stringify(report, null, 2),
|
||||
filename: `researchx-${report.runId}.json`,
|
||||
content: JSON.stringify({ report, ledger }, null, 2),
|
||||
};
|
||||
}
|
||||
return {
|
||||
filename: `${report.runId}-report.md`,
|
||||
filename: `researchx-${report.runId}.md`,
|
||||
content: renderReportMarkdown(report),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -171,7 +171,8 @@ export type RunStatus =
|
||||
| "reflecting"
|
||||
| "synthesizing"
|
||||
| "verifying"
|
||||
| "done";
|
||||
| "done"
|
||||
| "cancelled";
|
||||
|
||||
/** The plan the engine halts on until approved (REQ-14 vocabulary). */
|
||||
export interface RunPlan {
|
||||
|
||||
Reference in New Issue
Block a user