docs(P03): complete v2 mockups + polish + 3-click validation phase
Phase 3: /library v2-preview mockups (project graph, task inbox) behind the ApiClient boundary; typography pass; 3-click criterion pinned by test. REQ-60, REQ-61 covered. Gates green (37/37). Verify: PASS zero P0, two P1 fixed in-phase.
This commit was merged in pull request #3.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"stage": "complete",
|
||||
"phase": 3,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.1",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-27T01:40:00Z"
|
||||
"updated_at": "2026-09-27T02:00:00Z"
|
||||
}
|
||||
+32
-46
@@ -1,78 +1,64 @@
|
||||
# PLAN — Milestone v0.1 Phase 2: Plan & Run + Reading Room v0
|
||||
# PLAN — Milestone v0.1 Phase 3: v2 Surface Mockups + Polish + 3-Click Validation
|
||||
|
||||
---
|
||||
phase: 2
|
||||
phase: 3
|
||||
milestone: v0.1
|
||||
status: planned
|
||||
must_haves:
|
||||
- "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"
|
||||
- "Project graph mockup panel: cross-linked sources and findings across projects, rendered from mock data (REQ-60)"
|
||||
- "Task inbox mockup panel: open questions become checkable follow-ups, rendered from mock data (REQ-60)"
|
||||
- "Typography care pass: reader experience per vision §4 — report body measure/leading, section rhythm, small-caps/labels audit across all screens (REQ-61)"
|
||||
- "≤3-click happy path validated on defaults: Ask → Choose → Run initiated in 3 clicks, verified by test (REQ-61)"
|
||||
personas:
|
||||
- frontend-engineer
|
||||
requirements: [REQ-57, REQ-58, REQ-59]
|
||||
requirements: [REQ-60, REQ-61]
|
||||
---
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
- **`/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.
|
||||
- **`/library`** — the v2-surface mockup page: two panels rendered from fixture data — (1) **project graph**: nodes for research topics/projects with cross-links to shared sources and findings (visual mockup: SVG or CSS positioned nodes with labeled edges, clearly marked "v2 preview"); (2) **task inbox**: open questions extracted from past runs as checkable follow-up items with status toggles. A page-level banner explains these are v2 vision mockups, not yet functional.
|
||||
- All existing screens get the typography care pass: consistent section rhythm, reading measure enforced on long-form text, caption/label treatment unified.
|
||||
- Navigation affordance: the Reading Room and run screens gain a "Library (v2 preview)" link so the mockup page is discoverable in the demo.
|
||||
|
||||
## Happy Path
|
||||
|
||||
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)
|
||||
1. Owner runs the full P1+P2 path (ask → choose → approve → trace → report)
|
||||
2. From the report screen, clicks "Library (v2 preview)" → sees project graph with 4–6 topic nodes sharing source edges + task inbox with 3–5 checkable items
|
||||
3. Toggles a task's checkbox → item visibly flips status (client state only)
|
||||
4. Back on /ask: with defaults (no context, Deep Report pre-recommended), the owner reaches a streaming run in exactly 3 clicks: Research this → (arrive /choose) Start research → (arrive /run) Approve plan & start research
|
||||
5. The 3-click criterion is pinned by an automated test, not just manual belief
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
- 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
|
||||
- Graph: nodes labeled with topic names; edges show shared-source counts; hover on node highlights it (title attribute acceptable); visually distinct from production screens ("v2 preview" banner + muted styling)
|
||||
- Inbox: items show question text + originating run status; checkboxes toggle open↔done; at least one item pre-checked
|
||||
- Typography: body text ≥16px, reading measure ≤68ch on report/ask/choose long-form, consistent heading scale, no clipped or truncated labels
|
||||
- 3-click test: automated test counts discrete user clicks from /ask to a streaming run using default methodology — must be ≤3
|
||||
- Quality gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green
|
||||
|
||||
## Tasks (wave-ordered; persona: frontend-engineer unless noted)
|
||||
|
||||
### 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 1 — Fixtures
|
||||
- **T-3.1**: Library fixtures in mock layer: `getProjectGraph()` (nodes: topic/project + linked sources + findings) and `getTaskInbox()` (items: question, source run, status) — extend ApiClient interface (both map cleanly to v0.2 GET /api/graph + GET /api/tasks) (REQ-60)
|
||||
|
||||
### 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 2 — Library page
|
||||
- **T-3.2**: `/library` route with v2-preview banner; ProjectGraph panel (SVG/CSS nodes + labeled edges from fixtures); TaskInbox panel with toggleable checkboxes (client state) (REQ-60)
|
||||
- **T-3.3**: Navigation links: Reading Room + run screens gain "Library (v2 preview)" link; root nav hint if layout has one (REQ-60)
|
||||
|
||||
### 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 3 — Typography pass
|
||||
- **T-3.4**: Audit + fix typography across screens: reading measure on long-form, section rhythm (spacing-section usage), caption/label letter-spacing consistency, heading scale order (display → headline → body) (REQ-61)
|
||||
|
||||
### 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 4 — 3-click validation
|
||||
- **T-3.5**: Automated 3-click test: /ask → Research this (1) → /choose → Start research on recommended default (2) → /run → Approve plan (3) → assert trace streaming; document the count in the test name (REQ-61)
|
||||
|
||||
### 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
|
||||
- **T-3.6**: Boundary review + gate run; P1-flag fixes deferred to P4 are NOT pulled forward (P3 stays scoped)
|
||||
|
||||
## Verification
|
||||
|
||||
- `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)
|
||||
- Gates green; happy path walk passes; REQ-60, REQ-61 covered
|
||||
- 3-click criterion pinned by test
|
||||
|
||||
## Scope Guard
|
||||
|
||||
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.
|
||||
P3 only. The P4 final phase handles: review, audit, P1 flags (P1-1 drawer hyphen rendering from P1, P1-2 empty-question guard from P1, P2's routed P2 notes), owner approval gate, milestone merge. No v0.2 work.
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LibraryView } from "@/components/library-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Library — ResearchX",
|
||||
};
|
||||
|
||||
export default function LibraryPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-section">
|
||||
<LibraryView />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Library mockup tests (T-3.2, REQ-60).
|
||||
*
|
||||
* - v2-preview banner renders (mockup is clearly marked)
|
||||
* - Project graph renders nodes from fixtures with cross-links
|
||||
* - Task inbox lists items; toggling a checkbox flips its status
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { LibraryView } from "@/components/library-view";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
describe("LibraryView (v2 mockups)", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders the v2-preview banner and both panels from fixtures", async () => {
|
||||
render(<LibraryView />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/v2 preview — visual mockups/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Project graph")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task inbox")).toBeInTheDocument();
|
||||
|
||||
// Graph nodes render from fixtures with their topics.
|
||||
expect(
|
||||
screen.getByText("Solid-state batteries"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Grid-scale storage economics"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Cross-links render with shared-source counts.
|
||||
expect(
|
||||
screen.getByText(/⇄ Critical materials supply chains \(2\)/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Inbox items render.
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Do sulfide electrolytes outperform oxide ceramics below room temperature?",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("task inbox: toggling a checkbox flips the item's visible status", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LibraryView />);
|
||||
|
||||
const openQuestion =
|
||||
await screen.findByText(
|
||||
"Which solid-state startups have shipped pilot cells to automakers?",
|
||||
);
|
||||
// in_progress items are not struck through.
|
||||
expect(openQuestion.className).not.toContain("line-through");
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", {
|
||||
name: "Which solid-state startups have shipped pilot cells to automakers?",
|
||||
});
|
||||
expect(checkbox).not.toBeChecked();
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(openQuestion.className).toContain("line-through");
|
||||
|
||||
// The open count follows: fixture has 3 open/in-progress initially.
|
||||
expect(screen.getByText(/open questions worth following up · 2 open/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fixtures flow through the ApiClient boundary (graph + inbox)", async () => {
|
||||
const [graph, inbox] = await Promise.all([
|
||||
api.getProjectGraph(),
|
||||
api.getTaskInbox(),
|
||||
]);
|
||||
expect(graph.nodes.length).toBeGreaterThanOrEqual(4);
|
||||
expect(graph.edges.length).toBeGreaterThanOrEqual(4);
|
||||
const edge = graph.edges[0];
|
||||
expect(edge).toHaveProperty("sharedSourceCount");
|
||||
expect(inbox.length).toBeGreaterThanOrEqual(3);
|
||||
expect(["open", "in_progress", "done"]).toContain(inbox[0]?.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LibraryView (T-3.2, REQ-60) — the v2-surface mockup page.
|
||||
*
|
||||
* Two panels rendered from fixture data behind the ApiClient boundary:
|
||||
* - Project graph: cross-linked research topics with shared-source edges
|
||||
* - Task inbox: open questions from past runs as checkable follow-ups
|
||||
*
|
||||
* Clearly marked as v2 preview mockups — muted styling, banner, and
|
||||
* zero pretense of functionality beyond the visible toggles.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type InboxItem, type ProjectGraph } from "@/lib/api/client";
|
||||
|
||||
type LibraryState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; graph: ProjectGraph; inbox: InboxItem[] };
|
||||
|
||||
export function LibraryView() {
|
||||
const [state, setState] = useState<LibraryState>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [graph, inbox] = await Promise.all([
|
||||
api.getProjectGraph(),
|
||||
api.getTaskInbox(),
|
||||
]);
|
||||
if (!cancelled) setState({ kind: "ready", graph, inbox });
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: cause instanceof Error ? cause.message : "Library unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state.kind === "loading") {
|
||||
return <p className="mt-8 text-small text-ink-faint">Loading library…</p>;
|
||||
}
|
||||
if (state.kind === "error") {
|
||||
return <p role="alert" className="mt-8 text-small text-danger">{state.message}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="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">
|
||||
v2 preview — visual mockups, not yet functional
|
||||
</p>
|
||||
<h1 className="mt-3 text-display font-semibold text-ink">Library</h1>
|
||||
<p className="measure-reading mt-2 text-body text-ink-soft">
|
||||
How finished research accumulates: a cross-project graph of what
|
||||
you have investigated and an inbox of open questions worth
|
||||
following up.
|
||||
</p>
|
||||
|
||||
<div className="mt-block grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<ProjectGraphPanel graph={state.graph} />
|
||||
<TaskInboxPanel initialItems={state.inbox} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Project graph panel */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
|
||||
return (
|
||||
<section aria-label="Project graph" className="rounded-card border border-hairline bg-paper-raised px-5 py-5">
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Project graph
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Topics you have researched, linked where they share sources.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{graph.nodes.map((node) => {
|
||||
const nodeEdges = graph.edges.filter(
|
||||
(e) => e.fromId === node.id || e.toId === node.id,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-card border border-hairline bg-paper px-4 py-3"
|
||||
>
|
||||
<p className="text-body font-medium text-ink" title={node.topic}>
|
||||
{node.topic}
|
||||
</p>
|
||||
{node.findings.length > 0 && (
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
{node.findings[0]}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{node.linkedSources.map((source) => (
|
||||
<span
|
||||
key={source}
|
||||
className="rounded-full border border-hairline bg-paper-raised px-2.5 py-0.5 text-caption text-ink-faint"
|
||||
>
|
||||
{source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="Cross-links">
|
||||
{nodeEdges.map((edge) => {
|
||||
const otherId = edge.fromId === node.id ? edge.toId : edge.fromId;
|
||||
const other = graph.nodes.find((n) => n.id === otherId);
|
||||
return (
|
||||
<li
|
||||
key={`${node.id}|${otherId}|${edge.sharedSourceCount}`}
|
||||
className="rounded-full bg-accent-wash px-2.5 py-0.5 text-caption text-accent-ink"
|
||||
title={
|
||||
edge.sharedSourceCount > 0
|
||||
? `${edge.sharedSourceCount} shared source${edge.sharedSourceCount === 1 ? "" : "s"}`
|
||||
: "topical link — no shared sources yet"
|
||||
}
|
||||
>
|
||||
{edge.sharedSourceCount > 0
|
||||
? `⇄ ${other?.topic ?? otherId} (${edge.sharedSourceCount})`
|
||||
: `→ ${other?.topic ?? otherId}`}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{nodeEdges.length === 0 && (
|
||||
<li className="text-caption text-ink-faint">No links</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Task inbox panel */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: item.status === "done" ? "open" : "done" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openCount = items.filter((i) => i.status !== "done").length;
|
||||
|
||||
return (
|
||||
<section aria-label="Task inbox" className="rounded-card border border-hairline bg-paper-raised px-5 py-5">
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Task inbox
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Open questions worth following up · {openCount} open
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.status === "done"}
|
||||
onChange={() => toggle(item.id)}
|
||||
aria-label={item.question}
|
||||
className="mt-1 h-4 w-4 accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span
|
||||
className={`measure-narrow text-small ${
|
||||
item.status === "done" ? "text-ink-faint line-through" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{item.question}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,10 @@ export function ReadingRoom({ id }: { id: string }) {
|
||||
<Link href={`/run/${report.runId}`} className="text-accent underline underline-offset-2">
|
||||
← Back to run
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/library" className="text-accent underline underline-offset-2">
|
||||
Library (v2 preview)
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="measure-reading mt-3 text-display font-semibold text-ink">
|
||||
{report.title}
|
||||
|
||||
@@ -216,6 +216,11 @@ export function RunView({ id }: { id: string }) {
|
||||
Read the report
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-3 text-caption">
|
||||
<Link href="/library" className="text-accent underline underline-offset-2">
|
||||
Library (v2 preview)
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 3-click happy-path validation (T-3.5, REQ-61).
|
||||
*
|
||||
* With defaults only, a new user reaches a streaming research run in
|
||||
* at most 3 discrete clicks:
|
||||
* 1. "Research this" (Ask)
|
||||
* 2. "Start research" (Choose — Deep Report, the recommended default)
|
||||
* 3. "Approve plan" (Plan & Run)
|
||||
* The test drives the REAL components against the REAL mock binding
|
||||
* and counts the clicks — the criterion is pinned, not assumed.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AskForm } from "@/components/ask-form";
|
||||
import { MethodologyCard } from "@/components/methodology-card";
|
||||
import { RunView } from "@/components/run-view";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { clearAskState, getAskState } from "@/lib/ask-store";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
const QUESTION = "What are the latest advances in solid-state batteries?";
|
||||
|
||||
describe("3-click happy path (REQ-61)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
pushMock.mockClear();
|
||||
clearAskState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("reaches a streaming run in ≤3 clicks on defaults", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let clicks = 0;
|
||||
|
||||
/* Screen 1 — Ask ***********************************************/
|
||||
const { unmount: unmountAsk } = render(<AskForm />);
|
||||
const questionBox = screen.getByLabelText(/what do you want to know\?/i);
|
||||
await user.type(questionBox, QUESTION);
|
||||
// Click 1: Research this — defaults mean empty context is fine.
|
||||
await user.click(screen.getByRole("button", { name: "Research this" }));
|
||||
clicks += 1;
|
||||
// State carried: the question survives navigation to Choose.
|
||||
const carried = getAskState();
|
||||
if (carried === undefined) throw new Error("ask state not carried to /choose");
|
||||
expect(carried.question).toBe(QUESTION);
|
||||
unmountAsk();
|
||||
|
||||
/* Screen 2 — Choose ********************************************/
|
||||
const methodologies = await api.getMethodologies();
|
||||
const recommended = methodologies.find((m) => m.recommended);
|
||||
if (recommended === undefined) throw new Error("no recommended default");
|
||||
const { unmount: unmountChoose } = render(
|
||||
<MethodologyCard methodology={recommended} question={QUESTION} />,
|
||||
);
|
||||
// Click 2: Start research — on the recommended default card,
|
||||
// no browsing or drawer interaction needed.
|
||||
await user.click(screen.getByRole("button", { name: "Start research" }));
|
||||
clicks += 1;
|
||||
const runRoute = pushMock.mock.calls
|
||||
.flat()
|
||||
.find((c): c is string => typeof c === "string" && c.startsWith("/run/"));
|
||||
if (runRoute === undefined) throw new Error("no run route pushed");
|
||||
expect(runRoute).toMatch(/^\/run\/run-[a-z0-9]+$/);
|
||||
unmountChoose();
|
||||
|
||||
/* Screen 3 — Plan & Run ******************************************/
|
||||
const runId = runRoute.replace("/run/", "");
|
||||
render(<RunView id={runId} />);
|
||||
// Click 3: Approve plan & start research — trace starts streaming.
|
||||
const approve = await screen.findByRole("button", {
|
||||
name: /Approve plan & start research/i,
|
||||
});
|
||||
await user.click(approve);
|
||||
clicks += 1;
|
||||
|
||||
// Criterion: a streaming run reached with at most 3 clicks.
|
||||
expect(clicks).toBeLessThanOrEqual(3);
|
||||
// And the stream is live: the first trace event has arrived.
|
||||
expect(await screen.findByText(/Plan drafted/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /⏸ Pause/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@
|
||||
import type {
|
||||
CitationLedger,
|
||||
DialValues,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ProjectGraph,
|
||||
Report,
|
||||
ResearchRun,
|
||||
TraceEvent,
|
||||
@@ -22,7 +24,11 @@ export type {
|
||||
Citation,
|
||||
CitationLedger,
|
||||
DialValues,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ProjectGraph,
|
||||
ResearchRun,
|
||||
RunPlan,
|
||||
RunStatus,
|
||||
@@ -93,6 +99,12 @@ export interface ApiClient {
|
||||
id: string,
|
||||
format: "markdown" | "json",
|
||||
): Promise<{ filename: string; content: string }>;
|
||||
|
||||
/** The cross-project graph mockup (maps to GET /api/graph in v0.2). */
|
||||
getProjectGraph(): Promise<ProjectGraph>;
|
||||
|
||||
/** The task inbox mockup (maps to GET /api/tasks in v0.2). */
|
||||
getTaskInbox(): Promise<InboxItem[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Library fixtures (T-3.1, REQ-60) — the v2 surface mockup data.
|
||||
*
|
||||
* Project graph: cross-linked research topics sharing sources/findings.
|
||||
* Task inbox: open questions from past runs as checkable follow-ups.
|
||||
* Realistic fixture domains, clearly example content.
|
||||
*/
|
||||
|
||||
import type { InboxItem, ProjectGraph } from "@/lib/types";
|
||||
|
||||
export const projectGraphFixture: ProjectGraph = {
|
||||
nodes: [
|
||||
{
|
||||
id: "proj-batteries",
|
||||
topic: "Solid-state batteries",
|
||||
linkedSources: [
|
||||
"Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
|
||||
"Critical Materials Outlook: Lithium and Beyond",
|
||||
],
|
||||
findings: [
|
||||
"Interface degradation — not bulk conductivity — is the commercial bottleneck.",
|
||||
"Lithium-metal anodes re-concentrate supply risk on refining capacity.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "proj-grid-storage",
|
||||
topic: "Grid-scale storage economics",
|
||||
linkedSources: [
|
||||
"Critical Materials Outlook: Lithium and Beyond",
|
||||
"Thermal Safety of Next-Generation Battery Chemistries",
|
||||
],
|
||||
findings: [
|
||||
"Safety advantages shift the siting calculus toward dense urban deployments.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "proj-materials",
|
||||
topic: "Critical materials supply chains",
|
||||
linkedSources: [
|
||||
"Critical Materials Outlook: Lithium and Beyond",
|
||||
"Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
|
||||
],
|
||||
findings: [
|
||||
"Refining capacity — not reserves — is the choke point for solid-state scale.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "proj-transport",
|
||||
topic: "Automotive electrification timelines",
|
||||
linkedSources: [
|
||||
"Automakers Begin Pilot Production of Solid-State Cells",
|
||||
"Venture Funding Flows Into Solid-State Startups",
|
||||
],
|
||||
findings: [
|
||||
"Pilot-line announcements cluster around 2027 vehicle deployment targets.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "proj-safety",
|
||||
topic: "Battery safety testing standards",
|
||||
linkedSources: [
|
||||
"Thermal Safety of Next-Generation Battery Chemistries",
|
||||
"Automakers Begin Pilot Production of Solid-State Cells",
|
||||
],
|
||||
findings: [
|
||||
"Abuse testing shows reduced heat release; dendrite persistence at high rates.",
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ fromId: "proj-batteries", toId: "proj-materials", sharedSourceCount: 2 },
|
||||
{ fromId: "proj-batteries", toId: "proj-grid-storage", sharedSourceCount: 1 },
|
||||
{ fromId: "proj-batteries", toId: "proj-transport", sharedSourceCount: 0 },
|
||||
{ fromId: "proj-grid-storage", toId: "proj-safety", sharedSourceCount: 1 },
|
||||
{ fromId: "proj-materials", toId: "proj-transport", sharedSourceCount: 0 },
|
||||
{ fromId: "proj-safety", toId: "proj-transport", sharedSourceCount: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
export const taskInboxFixture: InboxItem[] = [
|
||||
{
|
||||
id: "task-001",
|
||||
question: "Do sulfide electrolytes outperform oxide ceramics below room temperature?",
|
||||
sourceRunId: "run-solid-state",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "task-002",
|
||||
question: "Which solid-state startups have shipped pilot cells to automakers?",
|
||||
sourceRunId: "run-solid-state",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
id: "task-003",
|
||||
question: "How do 2027 deployment targets compare across Japanese and EU consortia?",
|
||||
sourceRunId: "run-transport",
|
||||
status: "done",
|
||||
},
|
||||
{
|
||||
id: "task-004",
|
||||
question: "What is the lithium refining capacity expansion pipeline through 2030?",
|
||||
sourceRunId: "run-materials",
|
||||
status: "open",
|
||||
},
|
||||
];
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
|
||||
import { methodologyFixtures, findMethodologyBySlug } from "@/lib/api/mock/fixtures";
|
||||
import {
|
||||
projectGraphFixture,
|
||||
taskInboxFixture,
|
||||
} from "@/lib/api/mock/library-fixtures";
|
||||
import {
|
||||
buildScriptedLedger,
|
||||
buildScriptedReport,
|
||||
@@ -16,7 +20,9 @@ import {
|
||||
} from "@/lib/api/mock/scripted-run";
|
||||
import type {
|
||||
CitationLedger,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ProjectGraph,
|
||||
Report,
|
||||
ResearchRun,
|
||||
TraceEvent,
|
||||
@@ -196,6 +202,16 @@ export class MockApiClient implements ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** The cross-project graph mockup (v2 preview, REQ-60). */
|
||||
async getProjectGraph(): Promise<ProjectGraph> {
|
||||
return structuredClone(projectGraphFixture);
|
||||
}
|
||||
|
||||
/** The task inbox mockup (v2 preview, REQ-60). */
|
||||
async getTaskInbox(): Promise<InboxItem[]> {
|
||||
return structuredClone(taskInboxFixture);
|
||||
}
|
||||
|
||||
/** The scripted run for the prototype's demo question. */
|
||||
#defaultRun(): ResearchRun {
|
||||
return buildScriptedRun({
|
||||
|
||||
@@ -257,6 +257,41 @@ export interface Report {
|
||||
citations: Citation[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* v2 surfaces (library mockups — typed early, rendered in P3) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** A node in the cross-project graph (v2 vision vocabulary). */
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
topic: string;
|
||||
/** Source titles/passages shared with other projects. */
|
||||
linkedSources: string[];
|
||||
/** Findings this project contributed. */
|
||||
findings: string[];
|
||||
}
|
||||
|
||||
/** A cross-link between two projects — evidence of shared grounding. */
|
||||
export interface GraphEdge {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
sharedSourceCount: number;
|
||||
}
|
||||
|
||||
/** The project graph mockup payload (maps to GET /api/graph in v0.2). */
|
||||
export interface ProjectGraph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
/** An inbox follow-up extracted from a past run (v2 task vocabulary). */
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
question: string;
|
||||
sourceRunId: string;
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tasks (v2 task inbox vocabulary — typed early, rendered in P3) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user