P5: App shell + research library + settings + avatar (REQ-63..66) #5
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user