feat(P05): app shell + research library + settings + avatar (#5)

Squash-merge of phase/05-app-shell-reports-settings into milestone/v0.1-prototype. REQ-63..66 covered. Verify: PASS (72/72 tests, P1 main-landmark fix in-phase). Ships as v0.1.5.
This commit was merged in pull request #5.
This commit is contained in:
2026-09-27 03:23:42 +00:00
parent 0fa0b07429
commit b44bffcd7e
26 changed files with 2178 additions and 143 deletions
+5 -5
View File
@@ -1,8 +1,8 @@
{
"phase": 4,
"stage": "approval_pending",
"phase": 5,
"stage": "execute",
"milestone": "v0.1",
"phase_role": "final",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-27T02:45:00Z"
}
"updated_at": "2026-09-27T03:05:00Z"
}
+32 -29
View File
@@ -1,50 +1,53 @@
# PLAN — Milestone v0.1 Phase 4 (FINAL): Review + Owner Approval Gate + Ship
# PLAN — Milestone v0.1 Phase 5: App Shell + Research Library + Settings + Avatar
---
phase: 4
phase: 5
milestone: v0.1
status: executed
status: planned
must_haves:
- "Full-milestone review: PASS-WITH-FIXES verdict — all 5 P0s fixed and verified (drawer separators, empty-question guard, border-hairline token, fixture URL honesty, README accuracy)"
- "Audit: PASS — reconstruction test, file discipline, branch hygiene, commit discipline, tag verification, critical-issue check all green"
- "Final phase patch v0.1.4 shipped (pending owner approval for milestone merge to main)"
- "Persistent app shell (sidebar + top bar + avatar menu) on every screen, adding zero required clicks to the happy path (REQ-63, REQ-66)"
- "Research library with folder-structure storage view, auto-categorization, sorting, and cross-report connection drawing in the project graph (REQ-64)"
- "Settings screen — API keys (masked), storage endpoint, methodology defaults, appearance slot, about/version — all through the ApiClient boundary (REQ-65)"
- "All new data access extends the ApiClient interface + MockApiClient fixtures; zero throwaway for v0.2 (REQ-53/54 discipline)"
personas:
- frontend-engineer
- ci-lead-developer
requirements: [REQ-62]
requirements: [REQ-63, REQ-64, REQ-65, REQ-66]
---
## User-Facing Surface
The prototype deliverable presented for owner approval: the full 5-screen click-path (Ask → Choose → Plan & Run → Reading Room → Library preview), README quickstart, releases v0.1.0–v0.1.3, and this phase's review-driven polish (P0 fixes) as v0.1.4-pending.
Five routes gain a persistent shell; two new surfaces ship:
## Happy Path (owner review script)
1. **App shell** (all screens): left sidebar — New research (Ask), Library, Settings nav + future slots (connection-status dots previewing v0.2 adapter health, methodology-defaults preview); top bar — search/⌘K slot (navigation-only command palette: screens + stored reports) + avatar button (right) opening a user menu (profile, Settings, About). Honest "preview" pills on non-functional slots, per the established library-banner pattern.
2. **Research library** (`/library`): folder-structure view of stored reports (auto-categorized by topic + methodology, sortable by recency/title), report cards linking to Reading Room, project graph extended with cross-report edges (shared-source ⇄ and topical → connections), task inbox retained.
3. **Settings** (`/settings`): API keys section (model: Ollama cloud/local; search: Tavily — masked values, save wired through the mock boundary), storage endpoint (local directory), methodology defaults (resolved 12-dial display), appearance (theme slot, marked preview — paper palette is light-only), about/version (v0.1.5, prototype notice).
1. `cd frontend && pnpm install && pnpm dev` → http://localhost:3000
2. Ask → question → Research this (click 1)
3. Choose → Deep Report recommended card → Start research (click 2)
4. Plan & Run → Approve plan (click 3) → live trace → pause/resume/cancel
5. Read the report → hover [2] → exact quote; ledger panel; export Markdown/JSON
6. Library (v2 preview) → graph + inbox
7. Owner decision: approve → v0.1.4 + milestone merge to main; or request changes
## Happy Path
## What this phase did
1. **Review** (multi-persona, full milestone diff `main..HEAD`): verdict PASS-WITH-FIXES — 5 P0s (all owner-visible) + 2 P1s + P2 notes. All P0s fixed in-phase: dials separator rendering (P1-1 flag from P1), empty-question guard (P1-2 flag from P1), border-hairline Tailwind token (21 surfaces were near-black), fixture URL switched from a real arXiv link (resolving to an unrelated paper) to example.org, README screens table rewritten for all 5 routes. P1s: report title hardcodes Deep Report shape (documented prototype limitation), getRun fallback lacks a demo-run caption (documented). P2 notes logged for v0.2 hardening.
2. **Audit**: AUDIT PASS — tags v0.1.0–v0.1.3 verified annotated + on milestone branch; REQ-52..61 spot-checked as genuinely delivered; secrets never committed; branch hygiene clean; commit discipline PASS (2 zero-loss deviations noted); gates re-run green 42/42.
3. **Ship (this commit sequence)**: PR #4 (phase/04 → milestone), squash-merge, tag v0.1.4, release with full milestone summary. **Milestone merge to main happens ONLY after recorded owner approval (REQ-62)** — the pipeline halts at the gate.
1. `cd frontend && pnpm install && pnpm dev` → http://localhost:3000 → shell renders (sidebar + top bar) on /ask
2. New research → question → Research this (click 1) → Choose → Start research (click 2) → Approve plan (click 3) — ≤3 clicks, shell adds zero
3. Sidebar → Library → browse folder tree → open a stored report → Reading Room
4. Library → Connections panel → cross-report edges visible (⇄ shared sources, → related topic)
5. Top bar avatar → user menu → Settings → API keys + storage + methodology defaults render; save persists (mock)
6. ⌘K (top bar search slot) → type "lib" → navigate to Library
## UX Acceptance Criteria
- Owner demo script runs clean on the shipped code
- No placeholder/stub text in owner-visible surfaces
- The 3-click criterion still pinned green post-fixes
- Shell on every screen; sidebar/top bar never break the 68ch reading measure (Reading Room unaffected)
- 3-click happy path pinned green post-shell (three-click-path.test.tsx extended)
- No placeholder/stub text beyond honest "preview" pills; typography care per vision §4 on all new surfaces
- All new data through `api.*` — no component imports MockApiClient; v0.2 OpenAPI swap unaffected
- Avatar menu keyboard-accessible; focus ring in accent (global :focus-visible standard)
## Verification
## Waves
- Gates: typecheck ✓ lint ✓ tests 42/42 ✓ build ✓ (re-run after P0 fixes, plus by audit)
- border-hairline utility verified present in compiled production CSS
- Review P0 worklist: 5/5 closed with tests where feasible (dials unit test, empty-question guard test)
1. **T-5.1 Boundary first**: types (ReportSummary, ReportFolder, Settings, UserProfile, ConnectionStatus, SavedSettings) → ApiClient gains getReports, getSettings, saveSettings, getUserProfile, getConnectionStatus → MockApiClient + fixtures (stored-reports tree, masked keys, adapter health, profile) → co-located tests
2. **T-5.2 Shell chrome**: sidebar + top bar + palette + avatar components; layout restructure (all 5 routes under the shell); dedupe per-page `<main>` wrappers; regression tests
3. **T-5.3 Library**: folder tree + sortable cards + cross-report graph edges; extract reusable panels; tests
4. **T-5.4 Settings**: 5 sections, save flow through the boundary; tests
5. **T-5.5 Polish**: README screens table, typography pass, 3-click re-pin, gates green
## Scope Guard
Milestone merge to main, v0.1.4 release publication, v0.2 unblocking, and all v0.2 P1 work wait on the owner approval gate (REQ-62, prototype_approval escalation hook). The pipeline does not proceed autonomously past this commit.
P6 (final review + approval re-presentation) follows P5 ship. Milestone merge to main remains gated on the owner's recorded approval (REQ-62, prototype_approval escalation hook). No backend work, no v0.2 scope, no auth/multi-tenancy (local single-user profile only).
+8 -1
View File
@@ -19,6 +19,12 @@
- **REQ-61**: ≤3-click happy path on defaults (Ask → Choose → Run); typography care pass per vision §4
- **REQ-62**: Owner approval gate: prototype ships only after recorded owner approval (prototype_approval escalation hook); MVP milestone v0.2 begins only after v0.1.4 is tagged
### App Shell Extension (P5 — owner gate feedback, directed 2026-09-27)
- **REQ-63**: Persistent app shell on all screens: sidebar (primary nav + future-option slots) and top bar (search/command-palette slot + avatar button); shell adds zero required clicks to the ≤3-click happy path
- **REQ-64**: Research library: reports stored in a folder-structure view, auto-categorized (topic + methodology), sortable by recency/title; cross-report connection drawing — project graph links reports to each other (shared-source and topical edges)
- **REQ-65**: Settings surface behind the ApiClient boundary: API keys (model/search, masked), storage endpoint (local directory), methodology defaults (resolved dial display), appearance slot, about/version
- **REQ-66**: Avatar button (top bar) opening a user menu: local single-user profile (no auth), links to settings and about; future slots visible but honest (preview-marked)
## MVP (Milestone v0.2 — core loop, gated on prototype approval)
### Methodology Framework
@@ -109,7 +115,8 @@
| REQ-57 (Plan & Run) | v0.1 | P2 | complete |
| REQ-58, REQ-59 (Reading Room, exports) | v0.1 | P2 | complete |
| REQ-60, REQ-61 (v2 mockups, 3-click) | v0.1 | P3 | complete |
| REQ-62 (approval gate) | v0.1 | P4 | in_progress (awaiting owner decision) |
| REQ-62 (approval gate) | v0.1 | P6 | in_progress (awaiting owner decision on extended prototype) |
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | in_progress |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | blocked |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | blocked |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | blocked |
+19 -3
View File
@@ -33,12 +33,26 @@ Two milestones, owner-directed 2026-09-27. **Milestone v0.1 — Prototype**: the
- **Depends on**: 2
- **Status**: complete (PR #3 squash-merged, tag v0.1.3, release created; two P1 fixed in-phase)
### Phase 4 — Review + Owner Approval Gate + Ship (FINAL — shipped, gate open)
### Phase 4 — Review + Owner Approval Gate + Ship (shipped v0.1.4 — superseded by extension)
- **Description**: Review + audit, then the **owner approval gate**: prototype presented for owner review; approval recorded as a binding decision commit. Only after approval: milestone merge to main, tag v0.1.4 — the prototype release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: milestone gate (REQ-52..61 approved)
- **Success criteria**: owner approval recorded; v0.1.4 tagged; milestone branches deleted per ship workflow
- **Depends on**: 3
- **Status**: complete-pending-approval (PR #4 squash-merged, tag v0.1.4 + release created; review 5 P0s fixed; audit PASS; milestone merge to main HALTED at the REQ-62 owner approval gate)
- **Status**: shipped (PR #4 squash-merged, tag v0.1.4 + release created; review 5 P0s fixed; audit PASS; owner returned gate feedback instead of approve/reject → binding extension cycle P5/P6 before the merge to main)
### Phase 5 — App Shell + Research Library + Settings + Avatar (owner gate feedback)
- **Description**: Persistent app shell on every screen: sidebar (nav + future-option slots: connection status, methodology defaults preview) + top bar (⌘K navigation palette slot + avatar button with user menu). Research library upgraded: folder-structure report storage with auto-categorization, sorting, and cross-report connection drawing in the project graph. Settings screen: API keys (masked), storage endpoint, methodology defaults, appearance slot, about/version. All data through the ApiClient boundary (mock-backed); zero added clicks to the ≤3-click happy path.
- **Requirements covered**: REQ-63, REQ-64, REQ-65, REQ-66
- **Success criteria**: shell renders on all screens with sidebar + top bar + avatar menu; library shows folder tree + sortable report cards + cross-report graph edges; settings sections all functional on mock data; 3-click path pinned green post-shell; gates green
- **Depends on**: 4
- **Status**: in_progress
### Phase 6 — Final Review + Approval Re-presentation + Ship (FINAL)
- **Description**: Multi-persona review of the full milestone diff (now including P5), audit, then re-present the **extended prototype** at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.6 — the milestone release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..66
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.6 tagged + release; pipeline halts at the approval gate for the owner's recorded decision
- **Depends on**: 5
- **Status**: planned
## Milestone v0.2 — MVP (feature) — BLOCKED until prototype approval
@@ -73,7 +87,9 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone):
- **Status**: blocked
## Milestone Releases
- **v0.1.4 = approved prototype release** (feature milestone; final phase patch)
- **v0.1.4 = extended-prototype candidate** (feature milestone; final phase patch — superseded by the P5/P6 extension cycle)
- **v0.1.5 = app shell + research library + settings + avatar** (P5 patch)
- **v0.1.6 = milestone release** (P6 final phase patch — the approved extended prototype release, pending owner decision)
- **v0.2.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
- If MVP scope extends: additional 0.x minor milestones (v0.3, …), each a full milestone cycle
- **v1.0.0**: owner-approved release only — not created by the pipeline autonomously
+33 -11
View File
@@ -15,7 +15,8 @@ pnpm install
pnpm dev
```
Open <http://localhost:3000> — you'll land on `/ask`.
Open <http://localhost:3000> — you'll land on `/ask`, inside the app
shell (left sidebar for navigation, top bar with search and your avatar).
The happy path:
@@ -31,6 +32,22 @@ The happy path:
plus the citation ledger and **Export Markdown/JSON** buttons that
download real files with citations as real links
The shell adds zero required clicks to that path. Everything else it
hosts is wayfinding and accumulated research:
- **Library** (`/library`) — every report you've generated, filed into
auto-categorized folders; sort by recency or title; open any card to
re-enter its Reading Room. The **Connections** panel draws the links
between reports — shared sources (⇄) and adjacent topics (→). The
project graph and task inbox remain v2 previews.
- **Settings** (`/settings`) — API-key status (masked, never raw),
the local directory reports are stored in, the default methodology
and the 12 dials it resolves to, appearance, and about/version.
- **⌘K** (top bar) — jump to any screen or stored report. Navigation
only; full search arrives with the v0.2 engine.
- **Avatar** (top bar, right) — local single-user profile, links to
Settings and About.
## Scripts
| Script | What it does |
@@ -50,8 +67,12 @@ The happy path:
never touch `MockApiClient` directly. v0.2 phase 3 swaps the binding
to the OpenAPI-generated backend client with no component rewrites.
- The mock layer lives in `src/lib/api/mock/`: fixture data for all 8
locked methodologies and a scripted Deep Report run (trace events,
report with `[^passageId]` citation markers, citation ledger).
locked methodologies, a scripted Deep Report run (trace events,
report with `[^passageId]` citation markers, citation ledger), the
stored-reports library with folders and cross-report connections,
adapter connection status, settings, and the local user profile.
- The app shell (sidebar + top bar) lives in the root layout
(`src/app/layout.tsx`); every screen renders inside it.
- Design tokens (editorial type scale, warm paper palette, spacing) are
defined as Tailwind v4 `@theme` tokens in `src/app/globals.css`.
- Question + context survive the `/ask` → `/choose` navigation via a
@@ -61,15 +82,16 @@ The happy path:
```
src/
app/ App Router routes (ask, choose, run/[id], run/[id]/report, library)
components/ UI components (server by default, client where interactive)
app/ App Router routes (ask, choose, run/[id], run/[id]/report,
library, settings) — all inside the shell layout
components/ UI components (shell chrome, screens, tests co-located)
lib/ Domain types, ApiClient boundary, mock data, stores
test/ Test harness setup
```
Prototype scope note: ETAs, cost estimates, and the scripted run are
realistic fixture data — not live research. The trace stream replays a
deterministic Deep Report run with pause/resume/cancel controls; the
Reading Room's report, ledger, and exports are generated from the same
fixture. The `/library` project graph and task inbox are v2 preview
mockups behind the same ApiClient boundary.
Prototype scope note: ETAs, cost estimates, the scripted run, the stored
reports, connection edges, and adapter statuses are realistic fixture
data — not live research. The trace stream replays a deterministic Deep
Report run with pause/resume/cancel controls; the Reading Room's report,
ledger, and exports are generated from the same fixture. API-key entry,
live adapter probing, and full search land with the v0.2 backend.
+4 -6
View File
@@ -3,15 +3,13 @@ import { AskForm } from "@/components/ask-form";
/**
* /ask (T-3.1, REQ-55) — the front door: one question box, one optional
* context box, one primary action. Generous whitespace, no competition.
* The app shell (sidebar + top bar) lives in the root layout.
*/
export default function AskPage() {
return (
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 py-section">
<div className="mx-auto flex max-w-3xl flex-col px-6 py-section">
<header className="mb-block">
<p className="text-caption font-medium tracking-wide text-accent uppercase">
ResearchX
</p>
<h1 className="mt-3 text-display font-semibold text-ink">
<h1 className="text-display font-semibold text-ink">
Ask a question.
<br />
Get a report you can trust.
@@ -28,6 +26,6 @@ export default function AskPage() {
<p className="mt-block text-caption text-ink-faint">
Prototype: runs on scripted mock data — no API keys, no live search.
</p>
</main>
</div>
);
}
+20 -1
View File
@@ -1,5 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
/**
* Root layout (T-5.2, REQ-63) — the persistent app shell.
*
* Every screen renders inside the shell: left sidebar (nav + future
* slots) and top bar (⌘K palette + avatar). The shell adds zero
* required clicks to the happy path — it is pure wayfinding chrome.
* Page content lands in one <main> landmark per route (a11y).
*/
export const metadata: Metadata = {
title: "ResearchX",
@@ -14,7 +25,15 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body>{children}</body>
<body>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
</body>
</html>
);
}
+2 -2
View File
@@ -7,8 +7,8 @@ export const metadata: Metadata = {
export default function LibraryPage() {
return (
<main className="mx-auto max-w-6xl px-6 py-section">
<div className="mx-auto max-w-6xl px-6 py-section">
<LibraryView />
</main>
</div>
);
}
+2 -5
View File
@@ -15,11 +15,8 @@ export default async function RunPage({
}) {
const { id } = await params;
return (
<main className="mx-auto max-w-3xl px-6 py-section">
<p className="text-caption font-medium tracking-wide text-accent uppercase">
ResearchX · Run
</p>
<div className="mx-auto max-w-3xl px-6 py-section">
<RunView id={id} />
</main>
</div>
);
}
+2 -2
View File
@@ -7,8 +7,8 @@ export default async function ReportPage({
}) {
const { id } = await params;
return (
<main className="mx-auto max-w-6xl px-6 py-section">
<div className="mx-auto max-w-6xl px-6 py-section">
<ReadingRoom id={id} />
</main>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { SettingsView } from "@/components/settings-view";
export const metadata: Metadata = {
title: "Settings — ResearchX",
};
export default function SettingsPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-section">
<SettingsView />
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
/**
* App-shell chrome tests (T-5.2, REQ-63, REQ-66).
*
* Sidebar: nav entries render, connection dots hydrate from the
* boundary. Top bar: ⌘K opens the palette, entries navigate, avatar
* opens the user menu. The shell adds zero required clicks to the
* happy path — that criterion stays pinned in three-click-path.test.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
import { CommandPalette } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/ask",
}));
afterEach(() => {
pushMock.mockClear();
cleanup();
});
describe("AppSidebar (REQ-63)", () => {
it("renders primary nav with the three entries", () => {
render(<AppSidebar />);
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeTruthy();
expect(screen.getByRole("link", { name: "New research" })).toHaveAttribute(
"href",
"/ask",
);
expect(screen.getByRole("link", { name: "Library" })).toHaveAttribute(
"href",
"/library",
);
expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute(
"href",
"/settings",
);
});
it("hydrates all five adapter connection dots", async () => {
render(<AppSidebar />);
const list = await screen.findByRole("list", {
name: "Adapter connection status",
});
expect(list).toBeTruthy();
for (const label of ["Model", "Search", "Storage", "Memory", "Secrets"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("marks the active nav entry with aria-current", () => {
render(<AppSidebar />);
expect(screen.getByRole("link", { name: "New research" })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: "Library" })).not.toHaveAttribute(
"aria-current",
);
});
});
describe("AppTopBar (REQ-63, REQ-66)", () => {
it("shows the search slot with its ⌘K affordance", () => {
render(<AppTopBar />);
expect(
screen.getByRole("button", { name: /Search or jump to/i }),
).toBeInTheDocument();
expect(screen.getByText("⌘K")).toBeInTheDocument();
});
it("⌘K opens the palette with screens + stored reports", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.keyboard("{Meta>}k{/Meta}");
const dialog = await screen.findByRole("dialog", { name: "Search or jump to" });
expect(dialog).toBeTruthy();
// Screen entries
expect(screen.getByText("New research — Ask")).toBeInTheDocument();
expect(screen.getByText("Library")).toBeInTheDocument();
// Stored-report entries hydrate from the boundary
expect(
await screen.findByText("Solid-State Battery Advances: A Deep Report"),
).toBeInTheDocument();
});
it("avatar button opens the user menu with profile + links", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.click(screen.getByRole("button", { name: "User menu" }));
const menu = await screen.findByRole("menu", { name: "User menu" });
expect(menu).toBeTruthy();
expect(screen.getByText("Researcher")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Settings" })).toHaveAttribute(
"href",
"/settings",
);
expect(
screen.getByRole("menuitem", { name: "About & version" }),
).toHaveAttribute("href", "/settings#about");
});
it("Escape closes the user menu", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.click(screen.getByRole("button", { name: "User menu" }));
await screen.findByRole("menu", { name: "User menu" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu", { name: "User menu" })).toBeNull();
});
});
describe("CommandPalette (REQ-63)", () => {
const entries = [
{ kind: "screen" as const, label: "New research — Ask", href: "/ask" },
{ kind: "screen" as const, label: "Library", href: "/library" },
{
kind: "report" as const,
label: "Lithium Refining Capacity Through 2030",
href: "/run/run-materials/report",
},
];
it("filters entries by query", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<CommandPalette entries={entries} onNavigate={onNavigate} onDismiss={() => {}} />,
);
await user.type(screen.getByRole("textbox", { name: "Search or jump to" }), "lith");
expect(screen.getByText("Lithium Refining Capacity Through 2030")).toBeInTheDocument();
expect(screen.queryByText("New research — Ask")).toBeNull();
});
it("Enter navigates to the highlighted entry", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<CommandPalette entries={entries} onNavigate={onNavigate} onDismiss={() => {}} />,
);
const input = screen.getByRole("textbox", { name: "Search or jump to" });
await user.type(input, "library");
await user.keyboard("{Enter}");
expect(onNavigate).toHaveBeenCalledWith("/library");
});
it("shows an honest empty state", async () => {
const user = userEvent.setup();
render(
<CommandPalette entries={entries} onNavigate={() => {}} onDismiss={() => {}} />,
);
await user.type(
screen.getByRole("textbox", { name: "Search or jump to" }),
"zzzz",
);
expect(screen.getByText(/Nothing matches/i)).toBeInTheDocument();
});
});
describe("UserMenu (REQ-66)", () => {
it("renders the local profile from the boundary", async () => {
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
});
});
+134
View File
@@ -0,0 +1,134 @@
"use client";
/**
* AppSidebar (T-5.2, REQ-63) — the persistent left rail.
*
* Primary nav (New research, Library, Settings) + future-option slots:
* adapter connection-status dots (v0.2 preview) and honest preview
* pills where a slot is not yet functional. Server-renderable static
* structure; the connection dots hydrate from the ApiClient boundary.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { api, type ConnectionSlot } from "@/lib/api/client";
type SlotsState =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; slots: ConnectionSlot[] };
/** One nav entry — active state derives from the current pathname. */
function NavItem({
href,
label,
exact,
}: {
href: string;
label: string;
exact: boolean;
}) {
const pathname = usePathname() ?? "";
const active = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`block rounded-card px-3 py-2 text-small font-medium transition-colors ${
active
? "bg-accent-wash text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken hover:text-ink"
}`}
>
{label}
</Link>
);
}
/** Status dot + label for one adapter slot (v0.2 preview). */
function ConnectionDot({ slot }: { slot: ConnectionSlot }) {
const dotColor =
slot.status === "connected"
? "bg-success"
: slot.status === "fallback"
? "bg-warn"
: "bg-ink-faint";
return (
<li className="flex items-center gap-2" title={slot.detail}>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-pill ${dotColor}`}
/>
<span className="text-caption text-ink-soft">{slot.label}</span>
{slot.status === "preview" && (
<span className="rounded-full border border-hairline px-1.5 text-caption text-ink-faint">
preview
</span>
)}
</li>
);
}
export function AppSidebar() {
const [slots, setSlots] = useState<SlotsState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
api
.getConnectionStatus()
.then((result) => {
if (!cancelled) setSlots({ kind: "ready", slots: result });
})
.catch(() => {
if (!cancelled) setSlots({ kind: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<aside
aria-label="Primary"
className="flex w-60 shrink-0 flex-col gap-6 border-r border-hairline bg-paper-raised px-4 py-6"
>
<Link href="/ask" className="px-3">
<p className="text-small font-semibold tracking-wide text-ink">
ResearchX
</p>
<p className="text-caption text-ink-faint">Deep-research studio</p>
</Link>
<nav aria-label="Main navigation" className="flex flex-col gap-1">
<NavItem href="/ask" label="New research" exact />
<NavItem href="/library" label="Library" exact />
<NavItem href="/settings" label="Settings" exact />
</nav>
<div>
<p className="px-3 text-caption font-medium uppercase tracking-wide text-ink-faint">
Connections
</p>
{slots.kind === "ready" ? (
<ul className="mt-2 space-y-1.5" aria-label="Adapter connection status">
{slots.slots.map((slot) => (
<ConnectionDot key={slot.slot} slot={slot} />
))}
</ul>
) : (
<p className="mt-2 px-3 text-caption text-ink-faint">
{slots.kind === "loading" ? "Checking…" : "Status unavailable"}
</p>
)}
<p className="mt-2 px-3 text-caption text-ink-faint">
Adapter health lands in v0.2
</p>
</div>
<p className="mt-auto px-3 text-caption text-ink-faint">
v0.1 · prototype — mock data
</p>
</aside>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
/**
* AppTopBar (T-5.2, REQ-63, REQ-66) — the persistent top rail.
*
* Left: search/⌘K slot opening the navigation palette (screens + stored
* reports — navigation only; full search ships in v0.2). Right: avatar
* button opening the user menu (profile, Settings, About). All data
* flows through the ApiClient boundary; the menu is keyboard-accessible
* (Escape closes, focus ring per global standard).
*/
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
api,
type ReportLibrary,
type UserProfile,
} from "@/lib/api/client";
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
export function AppTopBar() {
const router = useRouter();
const pathname = usePathname() ?? "";
const [paletteOpen, setPaletteOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [library, setLibrary] = useState<ReportLibrary | null>(null);
// ⌘K / Ctrl-K opens the palette from anywhere in the app.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((open) => !open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Stored reports feed the palette's report section.
useEffect(() => {
let cancelled = false;
api
.getReportLibrary()
.then((result) => {
if (!cancelled) setLibrary(result);
})
.catch(() => {
/* palette falls back to screen entries when the library is unavailable */
});
return () => {
cancelled = true;
};
}, []);
const paletteEntries: PaletteEntry[] = [
{ kind: "screen", label: "New research — Ask", href: "/ask" },
{ kind: "screen", label: "Library", href: "/library" },
{ kind: "screen", label: "Settings", href: "/settings" },
...(library?.reports ?? []).map((report) => ({
kind: "report" as const,
label: report.title,
href: `/run/${report.runId}/report`,
})),
];
return (
<header className="sticky top-0 z-20 flex items-center gap-4 border-b border-hairline bg-paper-raised/95 px-6 py-3 backdrop-blur">
<p className="text-caption font-medium uppercase tracking-wide text-ink-faint">
{pathLabel(pathname)}
</p>
<div className="ml-auto flex items-center gap-3">
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="flex items-center gap-2 rounded-card border border-hairline bg-paper-sunken px-3 py-1.5 text-caption text-ink-faint transition-colors hover:border-ink-faint"
aria-label="Search or jump to — Command K"
>
<span aria-hidden="true">⌕</span>
<span>Search or jump to…</span>
<kbd className="rounded border border-hairline bg-paper-raised px-1.5 font-sans text-caption text-ink-faint">
⌘K
</kbd>
</button>
<AvatarButton menuOpen={menuOpen} onToggle={() => setMenuOpen((v) => !v)} />
</div>
{menuOpen && (
<UserMenu
onNavigate={() => setMenuOpen(false)}
onDismiss={() => setMenuOpen(false)}
/>
)}
{paletteOpen && (
<CommandPalette
entries={paletteEntries}
onNavigate={(href) => {
setPaletteOpen(false);
router.push(href);
}}
onDismiss={() => setPaletteOpen(false)}
/>
)}
</header>
);
}
/** The avatar disc — initials from the profile fixture. */
function AvatarButton({
menuOpen,
onToggle,
}: {
menuOpen: boolean;
onToggle: () => void;
}) {
const [profile, setProfile] = useState<UserProfile | null>(null);
useEffect(() => {
let cancelled = false;
api
.getUserProfile()
.then((result) => {
if (!cancelled) setProfile(result);
})
.catch(() => {
/* menu renders a fallback profile when unavailable */
});
return () => {
cancelled = true;
};
}, []);
return (
<button
type="button"
onClick={onToggle}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="User menu"
className="flex h-9 w-9 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised transition-colors hover:bg-accent-strong"
>
{profile?.initials ?? "··"}
</button>
);
}
/** Screen label shown top-left — quiet wayfinding, not a breadcrumb. */
function pathLabel(pathname: string): string {
if (pathname === "/ask") return "New research";
if (pathname.startsWith("/choose")) return "Choose methodology";
if (pathname === "/library") return "Library";
if (pathname === "/settings") return "Settings";
const runMatch = pathname.match(/^\/run\/([^/]+)/);
if (runMatch) {
return pathname.endsWith("/report") ? "Reading room" : "Plan & run";
}
return "ResearchX";
}
+3 -6
View File
@@ -32,12 +32,9 @@ export function ChooseView({ methodologies }: { methodologies: Methodology[] })
const question = ask?.question ?? "";
return (
<main className="mx-auto max-w-6xl px-6 py-section">
<div className="mx-auto max-w-6xl px-6 py-section">
<header className="mb-block">
<p className="text-caption font-medium tracking-wide text-accent uppercase">
ResearchX
</p>
<h1 className="mt-2 text-headline font-semibold text-ink">
<h1 className="text-headline font-semibold text-ink">
How deep should this go?
</h1>
{mounted && question !== "" ? (
@@ -82,6 +79,6 @@ export function ChooseView({ methodologies }: { methodologies: Methodology[] })
Prototype: ETAs and costs are mock heuristics; “Start research”
replays a scripted run.
</p>
</main>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* CommandPalette (T-5.2, REQ-63) — the ⌘K navigation surface.
*
* Navigation-only in the prototype: jump to screens or open a stored
* report in the Reading Room. Full search (projects, sources, claims)
* ships with the v0.2 engine. Keyboard-first: type to filter, Up/Down
* to move, Enter to go, Escape to close.
*/
import { useEffect, useRef, useState } from "react";
/** One navigable palette row. */
export interface PaletteEntry {
kind: "screen" | "report";
label: string;
href: string;
}
export function CommandPalette({
entries,
onNavigate,
onDismiss,
}: {
entries: PaletteEntry[];
onNavigate: (href: string) => void;
onDismiss: () => void;
}) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = entries.filter((entry) =>
entry.label.toLowerCase().includes(query.trim().toLowerCase()),
);
// Keep the highlighted row in range as the filter narrows.
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape") {
onDismiss();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setActive((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (event.key === "Enter") {
const entry = filtered[active];
if (entry) onNavigate(entry.href);
}
};
return (
<div
role="dialog"
aria-label="Search or jump to"
className="fixed inset-0 z-50 flex items-start justify-center bg-ink/25 pt-[14vh] backdrop-blur-sm"
onClick={onDismiss}
>
<div
className="w-full max-w-xl overflow-hidden rounded-card border border-hairline bg-paper-raised shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onKeyDown}
placeholder="Jump to a screen or report…"
aria-label="Search or jump to"
className="w-full border-b border-hairline bg-transparent px-5 py-4 text-body text-ink outline-none placeholder:text-ink-faint"
/>
<ul className="max-h-80 overflow-y-auto py-2" role="listbox">
{filtered.map((entry, index) => (
<li key={`${entry.kind}|${entry.href}|${entry.label}`}>
<button
type="button"
role="option"
aria-selected={index === active}
onMouseEnter={() => setActive(index)}
onClick={() => onNavigate(entry.href)}
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-small ${
index === active ? "bg-accent-wash text-accent-ink" : "text-ink"
}`}
>
<span
aria-hidden="true"
className={`rounded-full border border-hairline px-2 py-0.5 text-caption ${
entry.kind === "report" ? "text-accent-ink" : "text-ink-faint"
}`}
>
{entry.kind === "report" ? "Report" : "Screen"}
</span>
<span className="measure-narrow truncate">{entry.label}</span>
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-5 py-6 text-center text-small text-ink-faint">
Nothing matches “{query}”.
</li>
)}
</ul>
<p className="border-t border-hairline px-5 py-2 text-caption text-ink-faint">
Navigation only in the prototype — full search ships in v0.2.
</p>
</div>
</div>
);
}
+111 -51
View File
@@ -1,83 +1,143 @@
/**
* Library mockup tests (T-3.2, REQ-60).
* Research library tests (T-5.3, REQ-64; REQ-60 panels retained).
*
* - 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
* - Folder tree renders "All reports" + every fixture folder with counts
* - Report cards link into the Reading Room with library-card metadata
* - Folder filter narrows the visible cards; empty folder is honest
* - Sorting flips recency ↔ title order
* - Connections panel draws shared-source ⇄ and related-topic → edges
* - Project graph + task inbox (v2 preview) still render (REQ-60)
*/
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { LibraryView } from "@/components/library-view";
import { api } from "@/lib/api/client";
describe("LibraryView (v2 mockups)", () => {
const ALL_REPORTS = 4;
describe("LibraryView — stored reports (REQ-64)", () => {
afterEach(cleanup);
it("renders the v2-preview banner and both panels from fixtures", async () => {
it("renders the folder tree with All reports + fixture folders + counts", async () => {
render(<LibraryView />);
const allButton = await screen.findByRole("button", { name: /All reports/i });
expect(allButton).toBeInTheDocument();
expect(screen.getByText(String(ALL_REPORTS))).toBeInTheDocument();
// Fixture folders with their report counts.
expect(screen.getByRole("button", { name: /Energy storage/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^📁 Transport/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Policy & supply chains/i }),
).toBeInTheDocument();
});
it("report cards link into the Reading Room with metadata", async () => {
render(<LibraryView />);
const link = await screen.findByRole("link", {
name: /Solid-State Battery Advances: A Deep Report/i,
});
expect(link).toHaveAttribute("href", "/run/run-solid-state/report");
expect(screen.getByText(/7 citations/i)).toBeInTheDocument();
expect(screen.getAllByText(/deep-report/i).length).toBeGreaterThanOrEqual(1);
});
it("folder filter narrows cards; empty folder state is honest", async () => {
const user = userEvent.setup();
render(<LibraryView />);
await screen.findByRole("button", { name: /All reports/i });
// Transport folder holds exactly one fixture report.
await user.click(screen.getByRole("button", { name: /^📁 Transport/i }));
expect(screen.getByRole("link", { name: /Automotive Solid-State Pilot Production/i })).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /Solid-State Battery Advances/i }),
).toBeNull();
// Back to all — every report is visible again.
await user.click(screen.getByRole("button", { name: /^📁 All reports/i }));
expect(screen.getByRole("link", { name: /Solid-State Battery Advances/i })).toBeInTheDocument();
});
it("sorting flips between recency and title order", async () => {
const user = userEvent.setup();
render(<LibraryView />);
const list = await screen.findByRole("list", { name: "Reports" });
const cards = () => within(list).getAllByRole("link");
// Default: most recent first — the 2026-09-25 materials report.
expect(cards()[0]).toHaveTextContent(/Lithium Refining Capacity/i);
const sort = screen.getByRole("combobox");
await user.selectOptions(sort, "title");
expect(cards()[0]).toHaveTextContent(/Automotive Solid-State Pilot/i);
await user.selectOptions(sort, "recent");
expect(cards()[0]).toHaveTextContent(/Lithium Refining Capacity/i);
});
});
describe("LibraryView — connections (REQ-64)", () => {
afterEach(cleanup);
it("draws shared-source and related-topic edges between reports", async () => {
render(<LibraryView />);
expect(
await screen.findByText(/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"),
await screen.findByText(/Shared sources — 2 shared sources/i),
).toBeInTheDocument();
expect(
screen.getByText("Grid-scale storage economics"),
).toBeInTheDocument();
screen.getAllByText(/Related topic/i).length,
).toBeGreaterThanOrEqual(1);
});
// Cross-links render with shared-source counts.
it("every connection edge references reports that exist", async () => {
const [library, connections] = await Promise.all([
api.getReportLibrary(),
api.getReportConnections(),
]);
const ids = new Set(library.reports.map((r) => r.id));
for (const edge of connections) {
expect(ids).toContain(edge.fromReportId);
expect(ids).toContain(edge.toReportId);
}
});
});
describe("LibraryView — v2 preview panels (REQ-60, retained)", () => {
afterEach(cleanup);
it("project graph renders nodes with shared-source cross-links", async () => {
render(<LibraryView />);
expect(await screen.findByText("Project graph")).toBeInTheDocument();
expect(
await screen.findByText("Solid-state batteries"),
).toBeInTheDocument();
expect(
screen.getByText(/⇄ Critical materials supply chains \(2\)/),
).toBeInTheDocument();
// 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 () => {
it("task inbox lists items; toggling flips the 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.
const question =
"Which solid-state startups have shipped pilot cells to automakers?";
const inboxPanel = await screen.findByRole("region", { name: "Task inbox" });
const openQuestion = await within(inboxPanel).findByText(question);
expect(openQuestion.className).not.toContain("line-through");
const checkbox = screen.getByRole("checkbox", {
name: "Which solid-state startups have shipped pilot cells to automakers?",
});
expect(checkbox).not.toBeChecked();
const checkbox = within(inboxPanel).getByRole("checkbox", { name: question });
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);
expect(
screen.getByText(/open questions worth following up · 2 open/i),
).toBeInTheDocument();
});
});
+287 -21
View File
@@ -1,36 +1,61 @@
"use client";
/**
* LibraryView (T-3.2, REQ-60) — the v2-surface mockup page.
* LibraryView (T-5.3, REQ-64, REQ-60) — the research library.
*
* 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
* Four panels rendered from fixture data behind the ApiClient boundary:
* - Stored reports: folder-structure view (auto-categorized folders)
* with sortable report cards linking into the Reading Room
* - Connections: cross-report edges — shared sources ⇄ and related
* topics → — drawn between stored reports
* - Project graph: cross-linked research topics (v2 preview, REQ-60)
* - Task inbox: open questions as checkable follow-ups (REQ-60)
*
* Clearly marked as v2 preview mockups — muted styling, banner, and
* zero pretense of functionality beyond the visible toggles.
* Report storage + connections are the P5 deliverable; graph + inbox
* remain v2-preview mockups.
*/
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type InboxItem, type ProjectGraph } from "@/lib/api/client";
import {
api,
type InboxItem,
type ProjectGraph,
type ReportConnection,
type ReportLibrary,
type ReportSortKey,
type ReportSummary,
} from "@/lib/api/client";
type LibraryState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; graph: ProjectGraph; inbox: InboxItem[] };
| {
kind: "ready";
library: ReportLibrary;
connections: ReportConnection[];
graph: ProjectGraph;
inbox: InboxItem[];
};
export function LibraryView() {
const [state, setState] = useState<LibraryState>({ kind: "loading" });
const [folderId, setFolderId] = useState<string | "all">("all");
const [sortKey, setSortKey] = useState<ReportSortKey>("recent");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [graph, inbox] = await Promise.all([
const [library, connections, graph, inbox] = await Promise.all([
api.getReportLibrary(),
api.getReportConnections(),
api.getProjectGraph(),
api.getTaskInbox(),
]);
if (!cancelled) setState({ kind: "ready", graph, inbox });
if (!cancelled) {
setState({ kind: "ready", library, connections, graph, inbox });
}
} catch (cause) {
if (!cancelled) {
setState({
@@ -52,28 +77,269 @@ export function LibraryView() {
return <p role="alert" className="mt-8 text-small text-danger">{state.message}</p>;
}
const { library, connections, graph, inbox } = state;
const visible =
folderId === "all"
? library.reports
: library.reports.filter((r) => r.category === folderId);
const sorted = sortReports(visible, sortKey);
const counts = new Map<string, number>();
for (const report of library.reports) {
counts.set(report.category, (counts.get(report.category) ?? 0) + 1);
}
return (
<div>
<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>
<h1 className="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.
Everything you have researched, filed by folder — with the
connections your reports draw between each other.
</p>
<div className="mt-block grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
<ProjectGraphPanel graph={state.graph} />
<TaskInboxPanel initialItems={state.inbox} />
<div className="space-y-8">
<StoredReportsPanel
library={library}
counts={counts}
folderId={folderId}
sortKey={sortKey}
onFolderChange={setFolderId}
onSortChange={setSortKey}
visibleReports={sorted}
/>
<ConnectionsPanel
connections={connections}
reports={library.reports}
/>
</div>
<div className="space-y-8">
<ProjectGraphPanel graph={graph} />
<TaskInboxPanel initialItems={inbox} />
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Project graph panel */
/* Stored reports — folder tree + sortable cards (REQ-64) */
/* ------------------------------------------------------------------ */
function StoredReportsPanel({
library,
counts,
folderId,
sortKey,
onFolderChange,
onSortChange,
visibleReports,
}: {
library: ReportLibrary;
counts: Map<string, number>;
folderId: string | "all";
sortKey: ReportSortKey;
onFolderChange: (id: string | "all") => void;
onSortChange: (key: ReportSortKey) => void;
visibleReports: ReportSummary[];
}) {
return (
<section
aria-label="Stored reports"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Stored reports
</h2>
<fieldset className="flex items-center gap-2 text-caption text-ink-faint">
<legend className="sr-only">Sort reports</legend>
<span id="sort-label">Sort</span>
<select
aria-labelledby="sort-label"
value={sortKey}
onChange={(event) => onSortChange(event.target.value as ReportSortKey)}
className="rounded-card border border-hairline bg-paper px-2 py-1 text-caption text-ink"
>
<option value="recent">Most recent</option>
<option value="title">Title A–Z</option>
</select>
</fieldset>
</div>
{/* Folder tree — "All reports" + one entry per auto-category. */}
<ul className="mt-4 space-y-1" aria-label="Report folders">
<li>
<button
type="button"
aria-current={folderId === "all" ? "true" : undefined}
onClick={() => onFolderChange("all")}
className={`flex w-full items-center justify-between rounded-card px-3 py-2 text-small transition-colors ${
folderId === "all"
? "bg-accent-wash font-medium text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken"
}`}
>
<span>📁 All reports</span>
<span className="text-caption text-ink-faint">
{library.reports.length}
</span>
</button>
</li>
{library.folders.map((folder) => (
<li key={folder.id}>
<button
type="button"
aria-current={folderId === folder.id ? "true" : undefined}
onClick={() => onFolderChange(folder.id)}
className={`flex w-full items-center justify-between rounded-card px-3 py-2 text-left text-small transition-colors ${
folderId === folder.id
? "bg-accent-wash font-medium text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken"
}`}
>
<span>
📁 {folder.name}
<span className="mt-0.5 block text-caption text-ink-faint">
{folder.description}
</span>
</span>
<span className="text-caption text-ink-faint">
{counts.get(folder.id) ?? 0}
</span>
</button>
</li>
))}
</ul>
{/* Report cards */}
<ul className="mt-5 space-y-3" aria-label="Reports">
{visibleReports.map((report) => (
<li key={report.id}>
<ReportCard report={report} />
</li>
))}
{visibleReports.length === 0 && (
<li className="text-caption text-ink-faint">
No reports in this folder yet.
</li>
)}
</ul>
</section>
);
}
function ReportCard({ report }: { report: ReportSummary }) {
return (
<Link
href={`/run/${report.runId}/report`}
className="block rounded-card border border-hairline bg-paper px-4 py-3 transition-colors hover:border-ink-faint"
>
<p className="text-body font-medium text-ink">{report.title}</p>
<p className="measure-reading mt-1 text-caption text-ink-soft">
{report.question}
</p>
<p className="mt-2 flex flex-wrap gap-x-3 text-caption text-ink-faint">
<span>{formatDate(report.generatedAt)}</span>
<span>· {report.methodologySlug}</span>
<span>
· {report.citationCount} citation{report.citationCount === 1 ? "" : "s"}
</span>
</p>
</Link>
);
}
/** Sort by recency (newest first) or title (A–Z, locale-aware). */
function sortReports(
reports: ReportSummary[],
key: ReportSortKey,
): ReportSummary[] {
const copy = [...reports];
if (key === "recent") {
copy.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
} else {
copy.sort((a, b) => a.title.localeCompare(b.title));
}
return copy;
}
/** "Sep 20, 2026" — short, unambiguous, no time clutter on cards. */
function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/* ------------------------------------------------------------------ */
/* Cross-report connections (REQ-64) */
/* ------------------------------------------------------------------ */
function ConnectionsPanel({
connections,
reports,
}: {
connections: ReportConnection[];
reports: ReportSummary[];
}) {
const titleById = new Map(reports.map((r) => [r.id, r.title] as const));
return (
<section
aria-label="Report connections"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Connections
</h2>
<p className="mt-1 text-caption text-ink-soft">
How your reports draw on each other — shared sources and adjacent topics.
</p>
<ul className="mt-4 space-y-2">
{connections.map((edge) => (
<li
key={`${edge.fromReportId}|${edge.toReportId}|${edge.kind}`}
className="rounded-card border border-hairline bg-paper px-3 py-2"
>
<p className="text-small text-ink">
<span aria-hidden="true">{edge.kind === "shared-sources" ? "⇄" : "→"} </span>
<span className="font-medium">
{shortTitle(titleById.get(edge.fromReportId))}
</span>
<span className="text-ink-faint"> ↔ </span>
<span className="font-medium">
{shortTitle(titleById.get(edge.toReportId))}
</span>
</p>
<p
className={`mt-0.5 text-caption ${
edge.kind === "shared-sources" ? "text-accent-ink" : "text-ink-faint"
}`}
>
{edge.kind === "shared-sources"
? `Shared sources — ${edge.label}`
: `Related topic — ${edge.label}`}
</p>
</li>
))}
</ul>
</section>
);
}
/** Card-safe truncation for connection labels. */
function shortTitle(title: string | undefined): string {
if (title === undefined) return "Report";
return title.length > 34 ? `${title.slice(0, 33)}…` : title;
}
/* ------------------------------------------------------------------ */
/* Project graph panel (v2 preview, REQ-60) */
/* ------------------------------------------------------------------ */
function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
@@ -147,7 +413,7 @@ function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
}
/* ------------------------------------------------------------------ */
/* Task inbox panel */
/* Task inbox panel (REQ-60) */
/* ------------------------------------------------------------------ */
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
@@ -0,0 +1,95 @@
/**
* Settings screen tests (T-5.4, REQ-65).
*
* - All five sections render from the boundary: API keys (masked,
* configured state), storage directory, methodology defaults with
* the resolved 12-dial display, appearance (preview), about/version
* - Editing + saving round-trips through api.saveSettings
* - Save button is disabled until dirty; status feedback after save
*/
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { SettingsView } from "@/components/settings-view";
import { api } from "@/lib/api/client";
describe("SettingsView (REQ-65)", () => {
afterEach(cleanup);
it("renders the API-keys section with masked values and honest status", async () => {
render(<SettingsView />);
expect(await screen.findByText("API keys")).toBeInTheDocument();
expect(await screen.findByText("Model API key")).toBeInTheDocument();
expect(screen.getByText("Ollama cloud (ollama.com/v1)")).toBeInTheDocument();
// Configured key shows its mask — never a raw key.
expect(screen.getByText("sk-…7f2a")).toBeInTheDocument();
// Unconfigured key is honest about it.
expect(screen.getByText("Not configured")).toBeInTheDocument();
});
it("renders storage directory from the boundary and allows edits", async () => {
const user = userEvent.setup();
render(<SettingsView />);
const input = await screen.findByRole("textbox", { name: /Reports directory/i });
expect(input).toHaveValue("~/researchx/reports");
await user.clear(input);
await user.type(input, "~/researchx/archive");
expect(input).toHaveValue("~/researchx/archive");
});
it("renders methodology defaults with the resolved 12-dial display", async () => {
render(<SettingsView />);
const section = await screen.findByRole("region", { name: /Methodology defaults/i });
expect(within(section).getByLabelText(/Default methodology/i)).toBeTruthy();
// The resolved dials of the default methodology (deep-report):
// fixture defaults — depth 3, breadth 2, per-claim citations.
const dialRow = (label: string) =>
within(section)
.getAllByText(label)
.filter((el) => el.tagName === "DT")[0];
expect(dialRow("Depth")).toBeInTheDocument();
expect(dialRow("Breadth")).toBeInTheDocument();
expect(dialRow("Citation rigor")).toBeInTheDocument();
expect(within(section).getByText("per-claim")).toBeInTheDocument();
expect(within(section).getByText("Depth")).toBeInTheDocument();
});
it("save is disabled until dirty; saving round-trips through the boundary", async () => {
const user = userEvent.setup();
render(<SettingsView />);
const save = await screen.findByRole("button", { name: /Save changes/i });
expect(save).toBeDisabled();
const input = screen.getByRole("textbox", { name: /Reports directory/i });
await user.clear(input);
await user.type(input, "~/researchx/archive");
expect(save).toBeEnabled();
await user.click(save);
expect(
await screen.findByText(/Saved — stored on this machine/i),
).toBeInTheDocument();
// Round-trip: a fresh boundary read sees the saved value.
const settings = await api.getSettings();
expect(settings.storageDirectory).toBe("~/researchx/archive");
// Restore the fixture default for other suites.
await api.saveSettings({ storageDirectory: "~/researchx/reports" });
});
it("renders appearance preview and about/version sections", async () => {
render(<SettingsView />);
expect(await screen.findByText("Appearance")).toBeInTheDocument();
expect(screen.getByText("Paper (light)")).toBeInTheDocument();
expect(screen.getByText("preview", { exact: true })).toBeInTheDocument();
expect(screen.getByText(/About & version|About and version/i)).toBeInTheDocument();
expect(screen.getByText(/v0\.1\.5 · prototype/i)).toBeInTheDocument();
});
});
+308
View File
@@ -0,0 +1,308 @@
"use client";
/**
* SettingsView (T-5.4, REQ-65) — the /settings screen.
*
* Five sections, all through the ApiClient boundary:
* - API keys: masked values (raw keys never reach the UI), endpoint
* labels, configured state
* - Storage: local directory reports are written to
* - Methodology defaults: default methodology + resolved 12-dial
* display (the REQ-04 reproducibility vocabulary)
* - Appearance: theme slot — preview-marked (paper palette is
* light-only today)
* - About & version: prototype notice + version
*
* Save persists through api.saveSettings; the mock round-trips it.
*/
import { useEffect, useState } from "react";
import {
api,
type Methodology,
type Settings,
} from "@/lib/api/client";
type SettingsState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; settings: Settings; methodologies: Methodology[] };
export function SettingsView() {
const [state, setState] = useState<SettingsState>({ kind: "loading" });
const [storage, setStorage] = useState("");
const [defaultSlug, setDefaultSlug] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [settings, methodologies] = await Promise.all([
api.getSettings(),
api.getMethodologies(),
]);
if (!cancelled) {
setState({ kind: "ready", settings, methodologies });
setStorage(settings.storageDirectory);
setDefaultSlug(settings.defaultMethodologySlug);
}
} catch (cause) {
if (!cancelled) {
setState({
kind: "error",
message: cause instanceof Error ? cause.message : "Settings unavailable",
});
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (state.kind === "loading") {
return <p className="mt-8 text-small text-ink-faint">Loading settings…</p>;
}
if (state.kind === "error") {
return <p role="alert" className="mt-8 text-small text-danger">{state.message}</p>;
}
const { settings, methodologies } = state;
const dirty =
storage !== settings.storageDirectory ||
defaultSlug !== settings.defaultMethodologySlug;
const save = async () => {
setSaveState("saving");
try {
const saved = await api.saveSettings({
storageDirectory: storage,
defaultMethodologySlug: defaultSlug,
});
setStorage(saved.storageDirectory);
setDefaultSlug(saved.defaultMethodologySlug);
setSaveState("saved");
} catch {
setSaveState("error");
}
};
return (
<div>
<h1 className="text-display font-semibold text-ink">Settings</h1>
<p className="measure-reading mt-2 text-body text-ink-soft">
Model and search keys, where reports are stored, and the defaults
new research runs start from.
</p>
<div className="mt-block space-y-8">
{/* ---- API keys --------------------------------------------- */}
<section
aria-label="API keys"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
API keys
</h2>
<p className="mt-1 text-caption text-ink-soft">
Keys stay on this machine — stored masked, never displayed raw.
</p>
<ul className="mt-4 space-y-3">
{settings.apiKeys.map((key) => (
<li
key={key.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-card border border-hairline bg-paper px-4 py-3"
>
<div>
<p className="text-small font-medium text-ink">{key.label}</p>
<p className="text-caption text-ink-faint">{key.endpoint}</p>
</div>
<span
className={`rounded-pill px-2.5 py-0.5 text-caption ${
key.configured
? "bg-success-wash text-success"
: "bg-warn-wash text-warn"
}`}
>
{key.configured ? key.maskedValue : "Not configured"}
</span>
</li>
))}
</ul>
<p className="mt-3 text-caption text-ink-faint">
Key entry lands with the v0.2 adapter layer — the prototype
displays status only.
</p>
</section>
{/* ---- Storage ---------------------------------------------- */}
<section
aria-label="Storage"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Storage
</h2>
<p className="mt-1 text-caption text-ink-soft">
Local directory finished reports are written to — no upload
quotas, plain Markdown and JSON on disk.
</p>
<label className="mt-4 block">
<span className="text-small font-medium text-ink">Reports directory</span>
<input
type="text"
value={storage}
onChange={(event) => {
setStorage(event.target.value);
setSaveState("idle");
}}
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
/>
</label>
</section>
{/* ---- Methodology defaults ---------------------------------- */}
<section
aria-label="Methodology defaults"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Methodology defaults
</h2>
<p className="mt-1 text-caption text-ink-soft">
The methodology new runs start from, and the dials it resolves
to — every run snapshots these (REQ-04).
</p>
<label className="mt-4 block">
<span className="text-small font-medium text-ink">Default methodology</span>
<select
value={defaultSlug}
onChange={(event) => {
setDefaultSlug(event.target.value);
setSaveState("idle");
}}
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper px-3 py-2 text-body text-ink"
>
{methodologies.map((m) => (
<option key={m.slug} value={m.slug}>
{m.name}
</option>
))}
</select>
</label>
<DialDisplay settings={settings} methodologies={methodologies} />
</section>
{/* ---- Appearance -------------------------------------------- */}
<section
aria-label="Appearance"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Appearance
</h2>
<p className="mt-1 text-caption text-ink-soft">
Theme — one careful light theme today.
</p>
<div className="mt-4 flex items-center gap-3">
<span className="rounded-pill border border-hairline bg-paper-sunken px-3 py-1 text-small text-ink">
Paper (light)
</span>
<span className="rounded-full border border-hairline px-2 text-caption text-ink-faint">
preview
</span>
</div>
<p className="mt-2 text-caption text-ink-faint">
Dark mode is a deliberate post-MVP pass — the reading
typography comes first.
</p>
</section>
{/* ---- About ------------------------------------------------ */}
<section
id="about"
aria-label="About and version"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
About &amp; version
</h2>
<p className="mt-2 text-body text-ink">
ResearchX — deep-research studio. Methodology-first selection,
visited-only citations, three clicks to a trustworthy report.
</p>
<p className="mt-2 text-caption text-ink-faint">
v0.1.5 · prototype — runs on scripted mock data; the engine,
adapters, and trust loop arrive in v0.2.
</p>
</section>
{/* ---- Save -------------------------------------------------- */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={save}
disabled={!dirty || saveState === "saving"}
className="rounded-pill bg-accent px-5 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{saveState === "saving" ? "Saving…" : "Save changes"}
</button>
{saveState === "saved" && (
<p role="status" className="text-caption text-success">
Saved — stored on this machine.
</p>
)}
{saveState === "error" && (
<p role="alert" className="text-caption text-danger">
Save failed — try again.
</p>
)}
</div>
</div>
</div>
);
}
/** The resolved default-dial display — labels from the DIALS registry. */
function DialDisplay({
settings,
methodologies,
}: {
settings: Settings;
methodologies: Methodology[];
}) {
const chosen =
methodologies.find((m) => m.slug === settings.defaultMethodologySlug) ??
methodologies[0];
const dials = chosen?.defaultDials ?? settings.defaultDials;
const rows: [string, string][] = [
["Source universe", dials.sourceUniverse],
["Search routing", dials.searchRouting],
["Query strategy", dials.queryStrategy],
["Breadth", String(dials.breadth)],
["Depth", String(dials.depth)],
["Quality gates", dials.qualityGates],
["Citation rigor", dials.citationRigor],
["Verification passes", String(dials.verificationPasses)],
["Synthesis style", dials.synthesisStyle],
["Output structure", dials.outputStructure],
["Memory scope", dials.memoryScope],
["Checkpoints", dials.checkpoints],
];
return (
<dl className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 rounded-card border border-hairline bg-paper px-4 py-4 sm:grid-cols-2">
{rows.map(([label, value]) => (
<div key={label} className="flex items-baseline justify-between gap-2">
<dt className="text-caption text-ink-faint">{label}</dt>
<dd className="text-caption font-medium text-ink">{value}</dd>
</div>
))}
</dl>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
/**
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
*
* Local single-user profile (no auth — single-user constraint), links to
* Settings and About (the settings page's about section). Escape and
* outside clicks dismiss. Rendered as a menu for assistive tech.
*/
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { api, type UserProfile } from "@/lib/api/client";
export function UserMenu({
onNavigate,
onDismiss,
}: {
onNavigate: () => void;
onDismiss: () => void;
}) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
api
.getUserProfile()
.then((result) => {
if (!cancelled) setProfile(result);
})
.catch(() => {
/* fallback initials render below */
});
return () => {
cancelled = true;
};
}, []);
// Escape closes; outside clicks close. Focus stays in the header.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onDismiss();
};
const onClick = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onDismiss();
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onClick);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousedown", onClick);
};
}, [onDismiss]);
return (
<div
ref={menuRef}
role="menu"
aria-label="User menu"
className="absolute top-14 right-6 z-30 w-64 rounded-card border border-hairline bg-paper-raised p-4 shadow-lg"
>
<div className="flex items-center gap-3 border-b border-hairline pb-3">
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
>
{profile?.initials ?? "··"}
</span>
<div>
<p className="text-small font-medium text-ink">
{profile?.name ?? "Researcher"}
</p>
<p className="text-caption text-ink-faint">
{profile?.tagline ?? "Local workspace"}
</p>
</div>
</div>
<ul className="pt-2">
<li>
<Link
role="menuitem"
href="/settings"
onClick={onNavigate}
className="block rounded-card px-2 py-2 text-small text-ink transition-colors hover:bg-paper-sunken"
>
Settings
</Link>
</li>
<li>
<Link
role="menuitem"
href="/settings#about"
onClick={onNavigate}
className="block rounded-card px-2 py-2 text-small text-ink transition-colors hover:bg-paper-sunken"
>
About &amp; version
</Link>
</li>
</ul>
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
Local profile — accounts and sync land post-MVP.
</p>
</div>
);
}
+43
View File
@@ -9,13 +9,18 @@
import type {
CitationLedger,
ConnectionSlot,
DialValues,
InboxItem,
Methodology,
ProjectGraph,
Report,
ReportConnection,
ReportLibrary,
ResearchRun,
Settings,
TraceEvent,
UserProfile,
} from "@/lib/types";
import { MockApiClient } from "@/lib/api/mock/mock-client";
@@ -23,17 +28,26 @@ export type { Report, ReportSection } from "@/lib/types";
export type {
Citation,
CitationLedger,
ConnectionSlot,
DialValues,
GraphEdge,
GraphNode,
InboxItem,
Methodology,
ProjectGraph,
ReportConnection,
ReportConnectionKind,
ReportFolder,
ReportLibrary,
ReportSortKey,
ReportSummary,
ResearchRun,
RunPlan,
RunStatus,
Settings,
Task,
TraceEvent,
UserProfile,
} from "@/lib/types";
/** Input to createRun — the Ask + Choose screens' combined payload. */
@@ -105,6 +119,35 @@ export interface ApiClient {
/** The task inbox mockup (maps to GET /api/tasks in v0.2). */
getTaskInbox(): Promise<InboxItem[]>;
/**
* The stored-reports library: folders + report summaries
* (maps to GET /api/library in v0.2 — REQ-64).
*/
getReportLibrary(): Promise<ReportLibrary>;
/**
* Cross-report connections — shared-source and topical edges
* between stored reports (maps to GET /api/library/connections
* in v0.2 — REQ-64).
*/
getReportConnections(): Promise<ReportConnection[]>;
/** The local single-user profile behind the avatar (REQ-66). */
getUserProfile(): Promise<UserProfile>;
/** Adapter-slot connection status for the shell + settings (REQ-65). */
getConnectionStatus(): Promise<ConnectionSlot[]>;
/** The settings payload (maps to GET /api/config in v0.2 — REQ-65). */
getSettings(): Promise<Settings>;
/**
* Persist a settings update; returns the saved settings
* (maps to PUT /api/config in v0.2). Raw keys never appear in
* responses — masked values only.
*/
saveSettings(update: Partial<Settings>): Promise<Settings>;
}
/**
+57
View File
@@ -12,6 +12,13 @@ import {
projectGraphFixture,
taskInboxFixture,
} from "@/lib/api/mock/library-fixtures";
import {
connectionStatusFixture,
reportConnectionsFixture,
reportLibraryFixture,
settingsFixture,
userProfileFixture,
} from "@/lib/api/mock/shell-fixtures";
import {
buildScriptedLedger,
buildScriptedReport,
@@ -20,12 +27,17 @@ import {
} from "@/lib/api/mock/scripted-run";
import type {
CitationLedger,
ConnectionSlot,
InboxItem,
Methodology,
ProjectGraph,
Report,
ReportConnection,
ReportLibrary,
ResearchRun,
Settings,
TraceEvent,
UserProfile,
} from "@/lib/types";
/** Handle returned by getRunEvents — EventSource-shaped, pause-aware. */
@@ -212,6 +224,51 @@ export class MockApiClient implements ApiClient {
return structuredClone(taskInboxFixture);
}
/** The stored-reports library: folders + summaries (REQ-64). */
async getReportLibrary(): Promise<ReportLibrary> {
return structuredClone(reportLibraryFixture);
}
/** Cross-report connections for the library's connections panel (REQ-64). */
async getReportConnections(): Promise<ReportConnection[]> {
return structuredClone(reportConnectionsFixture);
}
/** The local single-user profile behind the avatar (REQ-66). */
async getUserProfile(): Promise<UserProfile> {
return structuredClone(userProfileFixture);
}
/** Adapter-slot connection status (REQ-65). */
async getConnectionStatus(): Promise<ConnectionSlot[]> {
return structuredClone(connectionStatusFixture);
}
/** The settings payload (REQ-65). */
async getSettings(): Promise<Settings> {
return structuredClone(this.#settings);
}
/**
* Persist a settings update (REQ-65). Raw key values never round-trip:
* apiKeys are fixture-owned (masking is the backend's job in v0.2),
* and only storage/default-methodology/dials fields persist here.
*/
async saveSettings(update: Partial<Settings>): Promise<Settings> {
const next: Settings = {
...this.#settings,
...update,
// Identity fields never accept client-side overwrites:
apiKeys: structuredClone(this.#settings.apiKeys),
theme: "paper",
};
this.#settings = next;
return structuredClone(next);
}
/** Session-persisted settings (mock-local). */
#settings: Settings = structuredClone(settingsFixture);
/** The scripted run for the prototype's demo question. */
#defaultRun(): ResearchRun {
return buildScriptedRun({
@@ -0,0 +1,127 @@
/**
* Boundary tests for the P5 shell surface (T-5.1, REQ-63..66).
*
* Exercises the NEW ApiClient methods against the mock binding —
* library payload shape, connection edges, settings round-trip,
* masked-key honesty, profile, and connection status. Runs against
* the real `api` singleton typed as ApiClient (house pattern: no
* component test ever mocks the boundary).
*/
import { describe, expect, it } from "vitest";
import { api } from "@/lib/api/client";
import type { ApiClient } from "@/lib/api/client";
const client: ApiClient = api;
describe("getReportLibrary (REQ-64)", () => {
it("returns folders and reports with consistent categories", async () => {
const library = await client.getReportLibrary();
const folderIds = new Set(library.folders.map((f) => f.id));
expect(folderIds.size).toBeGreaterThan(0);
for (const report of library.reports) {
expect(folderIds).toContain(report.category);
}
});
it("report summaries carry library-card fields", async () => {
const library = await client.getReportLibrary();
const first = library.reports[0];
if (first === undefined) throw new Error("no reports in fixture");
expect(first.title.length).toBeGreaterThan(0);
expect(first.question.length).toBeGreaterThan(0);
expect(first.methodologySlug).toBeTruthy();
expect(first.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(first.citationCount).toBeGreaterThanOrEqual(0);
});
});
describe("getReportConnections (REQ-64)", () => {
it("edges reference reports that exist in the library", async () => {
const [library, connections] = await Promise.all([
client.getReportLibrary(),
client.getReportConnections(),
]);
const ids = new Set(library.reports.map((r) => r.id));
expect(connections.length).toBeGreaterThan(0);
for (const edge of connections) {
expect(ids).toContain(edge.fromReportId);
expect(ids).toContain(edge.toReportId);
}
});
it("every edge declares a kind and a human label", async () => {
const connections = await client.getReportConnections();
for (const edge of connections) {
expect(["shared-sources", "related-topic"]).toContain(edge.kind);
expect(edge.label.length).toBeGreaterThan(0);
}
});
});
describe("getUserProfile (REQ-66)", () => {
it("returns a local single-user profile with initials", async () => {
const profile = await client.getUserProfile();
expect(profile.name.length).toBeGreaterThan(0);
expect(profile.initials).toMatch(/^[A-Z]{2}$/);
expect(profile.tagline.length).toBeGreaterThan(0);
});
});
describe("getConnectionStatus (REQ-65)", () => {
it("covers all five adapter slots with honest statuses", async () => {
const slots = await client.getConnectionStatus();
const seen = new Set(slots.map((s) => s.slot));
expect(seen).toEqual(
new Set(["model", "search", "storage", "memory", "secrets"]),
);
for (const slot of slots) {
expect(["connected", "fallback", "preview"]).toContain(slot.status);
expect(slot.detail.length).toBeGreaterThan(0);
}
});
});
describe("getSettings / saveSettings (REQ-65)", () => {
it("returns masked API keys — never raw", async () => {
const settings = await client.getSettings();
for (const key of settings.apiKeys) {
// Masked shape: "sk-…XXXX" tail or empty for unconfigured.
expect(key.maskedValue).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
}
});
it("persists storage directory + default methodology updates", async () => {
const before = await client.getSettings();
const saved = await client.saveSettings({
storageDirectory: "~/researchx/archive",
defaultMethodologySlug: "rapid-answer",
});
expect(saved.storageDirectory).toBe("~/researchx/archive");
expect(saved.defaultMethodologySlug).toBe("rapid-answer");
// Round-trip: a fresh read sees the same values.
const reread = await client.getSettings();
expect(reread.storageDirectory).toBe("~/researchx/archive");
// Restore the fixture default for other test suites.
await client.saveSettings({
storageDirectory: before.storageDirectory,
defaultMethodologySlug: before.defaultMethodologySlug,
});
});
it("ignores client-side apiKeys/theme overwrites — backend owns masking", async () => {
const saved = await client.saveSettings({
apiKeys: [
{
id: "model",
label: "Model API key",
endpoint: "evil.example",
maskedValue: "sk-rawkeyexfiltrated",
configured: true,
},
],
});
expect(saved.apiKeys[0]?.endpoint).not.toBe("evil.example");
expect(saved.theme).toBe("paper");
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* App-shell fixtures (T-5.1, REQ-63..66) — shell/library/settings data.
*
* Stored reports with folder categorization, cross-report connections,
* masked API-key settings, adapter-slot connection status, and the local
* single-user profile. Clearly example content, consistent with the
* battery-research domain used across the prototype fixtures.
*/
import type { Settings } from "@/lib/types";
import type {
ReportConnection,
ReportLibrary,
UserProfile,
} from "@/lib/types";
/* ------------------------------------------------------------------ */
/* Stored reports & folders (REQ-64) */
/* ------------------------------------------------------------------ */
export const reportLibraryFixture: ReportLibrary = {
folders: [
{
id: "energy-storage",
name: "Energy storage",
description: "Battery chemistry, grid storage, and materials research.",
},
{
id: "transport",
name: "Transport",
description: "Vehicle electrification and deployment timelines.",
},
{
id: "policy",
name: "Policy & supply chains",
description: "Critical-materials outlooks and industrial strategy.",
},
],
reports: [
{
id: "rep-solid-state",
runId: "run-solid-state",
title: "Solid-State Battery Advances: A Deep Report",
question: "What are the latest advances in solid-state batteries?",
methodologySlug: "deep-report",
category: "energy-storage",
generatedAt: "2026-09-20T14:32:00Z",
citationCount: 7,
},
{
id: "rep-electrolytes",
runId: "run-electrolytes",
title: "Sulfide vs. Oxide Electrolytes at Low Temperature",
question: "Do sulfide electrolytes outperform oxide ceramics below room temperature?",
methodologySlug: "rapid-answer",
category: "energy-storage",
generatedAt: "2026-09-22T09:14:00Z",
citationCount: 3,
},
{
id: "rep-pilots",
runId: "run-transport",
title: "Automotive Solid-State Pilot Production",
question: "Which solid-state startups have shipped pilot cells to automakers?",
methodologySlug: "rapid-answer",
category: "transport",
generatedAt: "2026-09-23T16:45:00Z",
citationCount: 4,
},
{
id: "rep-materials",
runId: "run-materials",
title: "Lithium Refining Capacity Through 2030",
question: "What is the lithium refining capacity expansion pipeline through 2030?",
methodologySlug: "deep-report",
category: "policy",
generatedAt: "2026-09-25T11:03:00Z",
citationCount: 9,
},
],
};
/* ------------------------------------------------------------------ */
/* Cross-report connections (REQ-64) */
/* ------------------------------------------------------------------ */
export const reportConnectionsFixture: ReportConnection[] = [
{
fromReportId: "rep-solid-state",
toReportId: "rep-electrolytes",
kind: "shared-sources",
label: "2 shared sources",
},
{
fromReportId: "rep-solid-state",
toReportId: "rep-pilots",
kind: "related-topic",
label: "Adjacent topic — pilot deployment",
},
{
fromReportId: "rep-solid-state",
toReportId: "rep-materials",
kind: "shared-sources",
label: "1 shared source",
},
{
fromReportId: "rep-electrolytes",
toReportId: "rep-materials",
kind: "related-topic",
label: "Adjacent topic — materials supply",
},
{
fromReportId: "rep-pilots",
toReportId: "rep-materials",
kind: "related-topic",
label: "Adjacent topic — 2027 targets",
},
];
/* ------------------------------------------------------------------ */
/* Settings (REQ-65) */
/* ------------------------------------------------------------------ */
export const settingsFixture: Settings = {
apiKeys: [
{
id: "model",
label: "Model API key",
endpoint: "Ollama cloud (ollama.com/v1)",
maskedValue: "sk-…7f2a",
configured: true,
},
{
id: "search",
label: "Search API key",
endpoint: "Tavily (api.tavily.com)",
maskedValue: "",
configured: false,
},
],
storageDirectory: "~/researchx/reports",
defaultMethodologySlug: "deep-report",
defaultDials: {
sourceUniverse: "web",
searchRouting: "web",
queryStrategy: "targeted",
breadth: 2,
depth: 3,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "analytical",
outputStructure: "report",
memoryScope: "none",
checkpoints: "plan-only",
},
theme: "paper",
};
/* ------------------------------------------------------------------ */
/* Connection status (REQ-65 — adapter-slot health) */
/* ------------------------------------------------------------------ */
import type { ConnectionSlot } from "@/lib/types";
export const connectionStatusFixture: ConnectionSlot[] = [
{
slot: "model",
label: "Model",
status: "connected",
detail: "Ollama cloud — key detected",
},
{
slot: "search",
label: "Search",
status: "fallback",
detail: "Tavily key missing — keyless DuckDuckGo fallback",
},
{
slot: "storage",
label: "Storage",
status: "connected",
detail: "Local filesystem — ~/researchx/reports",
},
{
slot: "memory",
label: "Memory",
status: "preview",
detail: "Stubbed off — preview until v0.2",
},
{
slot: "secrets",
label: "Secrets",
status: "preview",
detail: "Env-file backed — OpenBao preview until v0.2",
},
];
/* ------------------------------------------------------------------ */
/* User profile (REQ-66) */
/* ------------------------------------------------------------------ */
export const userProfileFixture: UserProfile = {
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
};
+101
View File
@@ -304,4 +304,105 @@ export interface Task {
status: TaskStatus;
/** Where the task came from — e.g. a run id or "inbox". */
source?: string;
}
/* ------------------------------------------------------------------ */
/* App shell (P5 vocabulary — REQ-63..66) */
/* ------------------------------------------------------------------ */
/* ---- Stored reports & folders (REQ-64) --------------------------- */
/** A stored report as the library sees it — summary, not full body. */
export interface ReportSummary {
id: string;
runId: string;
title: string;
question: string;
methodologySlug: string;
/** Auto-assigned category — the folder the report is filed under. */
category: string;
/** ISO date the report was generated. */
generatedAt: string;
/** Citation count — shown on the library card. */
citationCount: number;
}
/** Sort keys offered by the library's report list. */
export type ReportSortKey = "recent" | "title";
/** A folder in the library's folder-structure storage view. */
export interface ReportFolder {
/** Stable folder id — matches `ReportSummary.category`. */
id: string;
name: string;
description: string;
}
/** The library payload: folders + the reports filed in them. */
export interface ReportLibrary {
folders: ReportFolder[];
reports: ReportSummary[];
}
/* ---- Cross-report connections (REQ-64) ---------------------------- */
/** Why two reports are connected — shared sources or related topic. */
export type ReportConnectionKind = "shared-sources" | "related-topic";
/** An edge between two stored reports in the connections panel. */
export interface ReportConnection {
fromReportId: string;
toReportId: string;
kind: ReportConnectionKind;
/** Human label, e.g. "2 shared sources" or "Adjacent topic". */
label: string;
}
/* ---- Settings (REQ-65) --------------------------------------------- */
/** One configurable API-key slot — masked at rest, never echoed raw. */
export interface ApiKeySetting {
/** Setting id, e.g. "model", "search". */
id: string;
label: string;
/** Where the key is used — e.g. "Ollama cloud (ollama.com/v1)". */
endpoint: string;
/** Masked value — "sk-…3f2a" shape; raw keys never reach the UI. */
maskedValue: string;
/** True when a key is present. */
configured: boolean;
}
/** Adapter-slot health as the settings/connection surface sees it. */
export interface ConnectionSlot {
/** Adapter slot — one of the five pluggable slots. */
slot: "model" | "search" | "storage" | "memory" | "secrets";
label: string;
/** Adapter status — v0.2 probes report live/misconfigured/preview. */
status: "connected" | "fallback" | "preview";
/** Human detail, e.g. "Ollama cloud — key detected" or "Preview until v0.2". */
detail: string;
}
/** The whole settings payload (maps to GET/PUT /api/config in v0.2). */
export interface Settings {
apiKeys: ApiKeySetting[];
/** Local directory reports are written to (REQ-10 vocabulary). */
storageDirectory: string;
/** Default methodology slug for new runs. */
defaultMethodologySlug: string;
/** Resolved default dials — the REQ-04 reproducibility display. */
defaultDials: DialValues;
/** Appearance slot — light-only today; preview-marked. */
theme: "paper";
}
/** The local single-user profile behind the avatar button (REQ-66). */
export interface UserProfile {
/** Display name — local profile, no auth (single-user constraint). */
name: string;
/** Two-letter initials rendered inside the avatar disc. */
initials: string;
/** Role line under the name in the menu. */
tagline: string;
}