ship: plan & run + reading room v0 (P2) #2

Merged
coreci-bot merged 9 commits from phase/02-plan-run-reading-room into milestone/v0.1-prototype 2026-09-27 01:36:03 +00:00
13 changed files with 1108 additions and 144 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+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 (
<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);
});
});
+250
View File
@@ -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>
);
}
+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");
});
});
+295 -54
View File
@@ -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 &amp; 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>
);
}
+12 -1
View File
@@ -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.
+100 -2
View File
@@ -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);
});
});
+94 -26
View File
@@ -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),
};
}
+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;
}
+2 -1
View File
@@ -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 {