diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 01d7bea..a94adce 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -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" -} \ No newline at end of file + "updated_at": "2026-09-27T03:05:00Z" +} diff --git a/.ciagent/PLAN.md b/.ciagent/PLAN.md index 14185c9..8f2da8a 100644 --- a/.ciagent/PLAN.md +++ b/.ciagent/PLAN.md @@ -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 `
` 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. \ No newline at end of file +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). \ No newline at end of file diff --git a/.ciagent/REQUIREMENTS.md b/.ciagent/REQUIREMENTS.md index 4274b60..a4bba93 100644 --- a/.ciagent/REQUIREMENTS.md +++ b/.ciagent/REQUIREMENTS.md @@ -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 | diff --git a/.ciagent/ROADMAP.md b/.ciagent/ROADMAP.md index 87b4a69..eb77607 100644 --- a/.ciagent/ROADMAP.md +++ b/.ciagent/ROADMAP.md @@ -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 \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 499420b..6b2184c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,8 @@ pnpm install pnpm dev ``` -Open — you'll land on `/ask`. +Open — 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/frontend/src/app/ask/page.tsx b/frontend/src/app/ask/page.tsx index 003a2b6..a5e8ccd 100644 --- a/frontend/src/app/ask/page.tsx +++ b/frontend/src/app/ask/page.tsx @@ -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 ( -
+
-

- ResearchX -

-

+

Ask a question.
Get a report you can trust. @@ -28,6 +26,6 @@ export default function AskPage() {

Prototype: runs on scripted mock data — no API keys, no live search.

-

+ ); } \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e87c1d0..dfd50f8 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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
landmark per route (a11y). + */ export const metadata: Metadata = { title: "ResearchX", @@ -14,7 +25,15 @@ export default function RootLayout({ }>) { return ( - {children} + +
+ +
+ +
{children}
+
+
+ ); } \ No newline at end of file diff --git a/frontend/src/app/library/page.tsx b/frontend/src/app/library/page.tsx index 10cc8f5..c5172cc 100644 --- a/frontend/src/app/library/page.tsx +++ b/frontend/src/app/library/page.tsx @@ -7,8 +7,8 @@ export const metadata: Metadata = { export default function LibraryPage() { return ( -
+
-
+ ); } \ No newline at end of file diff --git a/frontend/src/app/run/[id]/page.tsx b/frontend/src/app/run/[id]/page.tsx index 50b39c8..a50ad21 100644 --- a/frontend/src/app/run/[id]/page.tsx +++ b/frontend/src/app/run/[id]/page.tsx @@ -15,11 +15,8 @@ export default async function RunPage({ }) { const { id } = await params; return ( -
-

- ResearchX · Run -

+
-
+ ); } \ No newline at end of file diff --git a/frontend/src/app/run/[id]/report/page.tsx b/frontend/src/app/run/[id]/report/page.tsx index 5795804..265c145 100644 --- a/frontend/src/app/run/[id]/report/page.tsx +++ b/frontend/src/app/run/[id]/report/page.tsx @@ -7,8 +7,8 @@ export default async function ReportPage({ }) { const { id } = await params; return ( -
+
-
+ ); } \ No newline at end of file diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..87d0067 --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -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 ( +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/app-shell.test.tsx b/frontend/src/components/app-shell.test.tsx new file mode 100644 index 0000000..db91008 --- /dev/null +++ b/frontend/src/components/app-shell.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} 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( {}} onDismiss={() => {}} />); + expect(await screen.findByText("Researcher")).toBeInTheDocument(); + expect(screen.getByText("Local workspace — single user")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000..aa228ee --- /dev/null +++ b/frontend/src/components/app-sidebar.tsx @@ -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 ( + + {label} + + ); +} + +/** 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 ( +
  • +
  • + ); +} + +export function AppSidebar() { + const [slots, setSlots] = useState({ 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 ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/app-top-bar.tsx b/frontend/src/components/app-top-bar.tsx new file mode 100644 index 0000000..aa946ea --- /dev/null +++ b/frontend/src/components/app-top-bar.tsx @@ -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(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 ( +
    +

    + {pathLabel(pathname)} +

    + +
    + + + setMenuOpen((v) => !v)} /> +
    + + {menuOpen && ( + setMenuOpen(false)} + onDismiss={() => setMenuOpen(false)} + /> + )} + + {paletteOpen && ( + { + setPaletteOpen(false); + router.push(href); + }} + onDismiss={() => setPaletteOpen(false)} + /> + )} +
    + ); +} + +/** The avatar disc — initials from the profile fixture. */ +function AvatarButton({ + menuOpen, + onToggle, +}: { + menuOpen: boolean; + onToggle: () => void; +}) { + const [profile, setProfile] = useState(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 ( + + ); +} + +/** 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"; +} \ No newline at end of file diff --git a/frontend/src/components/choose-view.tsx b/frontend/src/components/choose-view.tsx index 47f43bc..429f766 100644 --- a/frontend/src/components/choose-view.tsx +++ b/frontend/src/components/choose-view.tsx @@ -32,12 +32,9 @@ export function ChooseView({ methodologies }: { methodologies: Methodology[] }) const question = ask?.question ?? ""; return ( -
    +
    -

    - ResearchX -

    -

    +

    How deep should this go?

    {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.

    -
    + ); } \ No newline at end of file diff --git a/frontend/src/components/command-palette.tsx b/frontend/src/components/command-palette.tsx new file mode 100644 index 0000000..e48f312 --- /dev/null +++ b/frontend/src/components/command-palette.tsx @@ -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(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 ( +
    +
    event.stopPropagation()} + > + 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" + /> +
      + {filtered.map((entry, index) => ( +
    • + +
    • + ))} + {filtered.length === 0 && ( +
    • + Nothing matches “{query}”. +
    • + )} +
    +

    + Navigation only in the prototype — full search ships in v0.2. +

    +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/src/components/library-view.test.tsx b/frontend/src/components/library-view.test.tsx index 6506c25..e06eb93 100644 --- a/frontend/src/components/library-view.test.tsx +++ b/frontend/src/components/library-view.test.tsx @@ -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(); + + 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(); + + 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(); + 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(); + 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(); 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(); + 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(); - 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(); }); }); \ No newline at end of file diff --git a/frontend/src/components/library-view.tsx b/frontend/src/components/library-view.tsx index f8a6bcf..7fc1769 100644 --- a/frontend/src/components/library-view.tsx +++ b/frontend/src/components/library-view.tsx @@ -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({ kind: "loading" }); + const [folderId, setFolderId] = useState("all"); + const [sortKey, setSortKey] = useState("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

    {state.message}

    ; } + 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(); + for (const report of library.reports) { + counts.set(report.category, (counts.get(report.category) ?? 0) + 1); + } + return (
    -

    - v2 preview — visual mockups, not yet functional -

    -

    Library

    +

    Library

    - 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.

    - - +
    + + +
    +
    + + +
    ); } /* ------------------------------------------------------------------ */ -/* Project graph panel */ +/* Stored reports — folder tree + sortable cards (REQ-64) */ +/* ------------------------------------------------------------------ */ + +function StoredReportsPanel({ + library, + counts, + folderId, + sortKey, + onFolderChange, + onSortChange, + visibleReports, +}: { + library: ReportLibrary; + counts: Map; + folderId: string | "all"; + sortKey: ReportSortKey; + onFolderChange: (id: string | "all") => void; + onSortChange: (key: ReportSortKey) => void; + visibleReports: ReportSummary[]; +}) { + return ( +
    +
    +

    + Stored reports +

    +
    + Sort reports + Sort + +
    +
    + + {/* Folder tree — "All reports" + one entry per auto-category. */} +
      +
    • + +
    • + {library.folders.map((folder) => ( +
    • + +
    • + ))} +
    + + {/* Report cards */} +
      + {visibleReports.map((report) => ( +
    • + +
    • + ))} + {visibleReports.length === 0 && ( +
    • + No reports in this folder yet. +
    • + )} +
    +
    + ); +} + +function ReportCard({ report }: { report: ReportSummary }) { + return ( + +

    {report.title}

    +

    + {report.question} +

    +

    + {formatDate(report.generatedAt)} + · {report.methodologySlug} + + · {report.citationCount} citation{report.citationCount === 1 ? "" : "s"} + +

    + + ); +} + +/** 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 ( +
    +

    + Connections +

    +

    + How your reports draw on each other — shared sources and adjacent topics. +

    + +
      + {connections.map((edge) => ( +
    • +

      + + + {shortTitle(titleById.get(edge.fromReportId))} + + ↔ + + {shortTitle(titleById.get(edge.toReportId))} + +

      +

      + {edge.kind === "shared-sources" + ? `Shared sources — ${edge.label}` + : `Related topic — ${edge.label}`} +

      +
    • + ))} +
    +
    + ); +} + +/** 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[] }) { diff --git a/frontend/src/components/settings-view.test.tsx b/frontend/src/components/settings-view.test.tsx new file mode 100644 index 0000000..5f42042 --- /dev/null +++ b/frontend/src/components/settings-view.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/settings-view.tsx b/frontend/src/components/settings-view.tsx new file mode 100644 index 0000000..93572f9 --- /dev/null +++ b/frontend/src/components/settings-view.tsx @@ -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({ 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

    Loading settings…

    ; + } + if (state.kind === "error") { + return

    {state.message}

    ; + } + + 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 ( +
    +

    Settings

    +

    + Model and search keys, where reports are stored, and the defaults + new research runs start from. +

    + +
    + {/* ---- API keys --------------------------------------------- */} +
    +

    + API keys +

    +

    + Keys stay on this machine — stored masked, never displayed raw. +

    +
      + {settings.apiKeys.map((key) => ( +
    • +
      +

      {key.label}

      +

      {key.endpoint}

      +
      + + {key.configured ? key.maskedValue : "Not configured"} + +
    • + ))} +
    +

    + Key entry lands with the v0.2 adapter layer — the prototype + displays status only. +

    +
    + + {/* ---- Storage ---------------------------------------------- */} +
    +

    + Storage +

    +

    + Local directory finished reports are written to — no upload + quotas, plain Markdown and JSON on disk. +

    + +
    + + {/* ---- Methodology defaults ---------------------------------- */} +
    +

    + Methodology defaults +

    +

    + The methodology new runs start from, and the dials it resolves + to — every run snapshots these (REQ-04). +

    + + + +
    + + {/* ---- Appearance -------------------------------------------- */} +
    +

    + Appearance +

    +

    + Theme — one careful light theme today. +

    +
    + + Paper (light) + + + preview + +
    +

    + Dark mode is a deliberate post-MVP pass — the reading + typography comes first. +

    +
    + + {/* ---- About ------------------------------------------------ */} +
    +

    + About & version +

    +

    + ResearchX — deep-research studio. Methodology-first selection, + visited-only citations, three clicks to a trustworthy report. +

    +

    + v0.1.5 · prototype — runs on scripted mock data; the engine, + adapters, and trust loop arrive in v0.2. +

    +
    + + {/* ---- Save -------------------------------------------------- */} +
    + + {saveState === "saved" && ( +

    + Saved — stored on this machine. +

    + )} + {saveState === "error" && ( +

    + Save failed — try again. +

    + )} +
    +
    +
    + ); +} + +/** 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 ( +
    + {rows.map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + ); +} \ No newline at end of file diff --git a/frontend/src/components/user-menu.tsx b/frontend/src/components/user-menu.tsx new file mode 100644 index 0000000..b4e24cd --- /dev/null +++ b/frontend/src/components/user-menu.tsx @@ -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(null); + const menuRef = useRef(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 ( +
    +
    + +
    +

    + {profile?.name ?? "Researcher"} +

    +

    + {profile?.tagline ?? "Local workspace"} +

    +
    +
    + +
      +
    • + + Settings + +
    • +
    • + + About & version + +
    • +
    + +

    + Local profile — accounts and sync land post-MVP. +

    +
    + ); +} \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index cf36826..2edffff 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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; + + /** + * The stored-reports library: folders + report summaries + * (maps to GET /api/library in v0.2 — REQ-64). + */ + getReportLibrary(): Promise; + + /** + * Cross-report connections — shared-source and topical edges + * between stored reports (maps to GET /api/library/connections + * in v0.2 — REQ-64). + */ + getReportConnections(): Promise; + + /** The local single-user profile behind the avatar (REQ-66). */ + getUserProfile(): Promise; + + /** Adapter-slot connection status for the shell + settings (REQ-65). */ + getConnectionStatus(): Promise; + + /** The settings payload (maps to GET /api/config in v0.2 — REQ-65). */ + getSettings(): Promise; + + /** + * 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): Promise; } /** diff --git a/frontend/src/lib/api/mock/mock-client.ts b/frontend/src/lib/api/mock/mock-client.ts index ba3cca4..41d06af 100644 --- a/frontend/src/lib/api/mock/mock-client.ts +++ b/frontend/src/lib/api/mock/mock-client.ts @@ -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 { + return structuredClone(reportLibraryFixture); + } + + /** Cross-report connections for the library's connections panel (REQ-64). */ + async getReportConnections(): Promise { + return structuredClone(reportConnectionsFixture); + } + + /** The local single-user profile behind the avatar (REQ-66). */ + async getUserProfile(): Promise { + return structuredClone(userProfileFixture); + } + + /** Adapter-slot connection status (REQ-65). */ + async getConnectionStatus(): Promise { + return structuredClone(connectionStatusFixture); + } + + /** The settings payload (REQ-65). */ + async getSettings(): Promise { + 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): Promise { + 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({ diff --git a/frontend/src/lib/api/mock/shell-boundary.test.ts b/frontend/src/lib/api/mock/shell-boundary.test.ts new file mode 100644 index 0000000..473b56f --- /dev/null +++ b/frontend/src/lib/api/mock/shell-boundary.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api/mock/shell-fixtures.ts b/frontend/src/lib/api/mock/shell-fixtures.ts new file mode 100644 index 0000000..8afab30 --- /dev/null +++ b/frontend/src/lib/api/mock/shell-fixtures.ts @@ -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", +}; \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4c9195c..fe29b26 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; } \ No newline at end of file