diff --git a/.gitignore b/.gitignore index 36679a8e..3dcf33f3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,6 @@ config.json CMakeUserPresets.json .merak/ .claude/ -/docs/ -.superpowers/ # Dependencies node_modules/ @@ -34,5 +32,9 @@ Thumbs.db # Build artifacts dist/ +# Documentation (generated) +/docs/ +.superpowers/ + # Worktrees .worktrees/ diff --git a/.superpowers/sdd/task-7-report.md b/.superpowers/sdd/task-7-report.md new file mode 100644 index 00000000..c16228df --- /dev/null +++ b/.superpowers/sdd/task-7-report.md @@ -0,0 +1,113 @@ +# Task 7 Report: Sessions Workbench + +## Status + +Implemented the lazy `sessions` route as a three-column sessions workbench using the existing session history, conversation, composer, SSE, and inspector components. + +## RED + +1. Baseline command: + `npm test -- --run src/__tests__/UserFlow.test.tsx src/__tests__/components.test.tsx` +2. Baseline result: 48 passed, 2 failed. +3. Preserved baseline failures: + - `World time control` / `Current: Day 4, dusk` did not match the approved rendered labels `世界时间` / `当前:Day 4, dusk`. + - The removed `Foreshadowing` sub-tab did not exist; `Create` is the approved accessible top-level tab. +4. New page test RED: + `npm test -- --run src/__tests__/UserFlow.test.tsx` +5. New page test result: failed because `../pages/SessionsPage` did not exist. + +## GREEN + +1. Focused command: + `npm test -- --run src/__tests__/UserFlow.test.tsx src/__tests__/components.test.tsx` +2. Result: 51 passed, 0 failed across 2 test files. +3. Build command: `npm run build` +4. Result: TypeScript and Vite production build passed; Vite emitted a pre-existing large main-chunk advisory. +5. Lint command: `npm run lint` +6. Result: 0 errors and 12 pre-existing unused-type warnings in `src/api/client.ts`. + +## Preserved Workflows + +- `SessionList` remains the sole owner of create, select, rename, generated-title, archive, and restore behavior. +- `ChatTimeline` and `Composer` remain mounted once through `MainPanel`, preserving messages, send, cancel, approvals, SSE status, and tool/run rendering. +- `InspectorPanel` remains the sole owner of Story, Files, Agents, Create, and Run content, preserving generated files, editor entry points, creation dialogs, run replay, and audit behavior. +- `App` still owns SSE setup and keeps help, setup, chapter review, ask, creation request, and export overlays outside the page route. +- Editor routing and close protection remain unchanged. + +## Structure + +- Left: current-world session history using real `state.sessions` data. +- Center: selected session title, connection status, chat timeline, and composer. +- Right: existing inspector with approved `Story`, `Files`, `Agents`, `Create`, and `Run` labels. +- Responsive behavior retains the history and inspector toggles without duplicating stateful workbench components. + +## Self-review + +- Confirmed `SessionsPage` is emitted as a separate lazy build chunk. +- Confirmed the legacy workbench remains available for routes not migrated in this task. +- Confirmed overlays remain siblings of the lazy page and were not moved or duplicated. +- Confirmed no fixture content was added to production UI. +- No task-specific lint errors remain. + +## Review Fixes + +### RED + +Command: +`npm test -- --run src/__tests__/UserFlow.test.tsx src/__tests__/components.test.tsx src/__tests__/AppState.test.ts` + +The new regressions demonstrated that: + +- `SET_SESSION` retained the previous agent when the selected session had no `agent_id`. +- The sessions page exposed no `aria-hidden` state and desktop/tablet toggles did not control panel visibility. +- A typed composer prompt could enable the send button without a selected session. +- The App phase policy did not admit `sessions` during `no_agent`. + +### GREEN + +Focused command: +`npm test -- --run src/__tests__/UserFlow.test.tsx src/__tests__/components.test.tsx src/__tests__/AppState.test.ts` + +Result: 98 passed, 0 failed across 3 test files. + +Build command: `npm run build` + +Result: TypeScript and Vite production build passed. The existing main-chunk size advisory remains; `SessionsPage` remains a separate lazy chunk. + +Lint command: `npm run lint` + +Result: 0 errors and the same 12 pre-existing unused-type warnings in `src/api/client.ts`. + +### Preserved constraints + +- `no_world` still renders onboarding before any sessions route can mount. +- `no_agent` permits the sessions route so a user can select or create a session. +- Selecting or creating a session atomically sets its agent, clears a missing agent, and moves the app to ready. +- The composer send action stays disabled until both prompt text and a session ID exist; its handler guard remains intact. +- Desktop and tablet toggles now collapse grid columns, remove hidden panels from interaction, and expose matching `aria-hidden` / `aria-expanded` state. + +## Final Review Fixes + +### RED + +Command: +`npm test -- --run src/__tests__/components.test.tsx src/__tests__/UserFlow.test.tsx` + +Result: 2 new failures, confirming that the base sessions grid required a 420px center track and legacy `MainPanel` sidebar mode referenced the sessions-only `session-history-panel` ID. + +### GREEN + +Command: +`npm test -- --run src/__tests__/components.test.tsx src/__tests__/UserFlow.test.tsx` + +Result: 57 passed, 0 failed across 2 test files. + +Build command: `npm run build` + +Result: TypeScript and Vite production build passed. The existing main-chunk size advisory remains. + +### Preserved constraints + +- The base three-column grid now uses `minmax(0, 1fr)`, so a 1180-1293px viewport can retain the inspector without exceeding the shell content width. +- The existing `1179px` inspector-overlay breakpoint remains, preserving the 960px minimum-window layout. +- Sessions mode retains `aria-controls="session-history-panel"`; legacy sidebar mode emits no reference to that sessions-only ID. diff --git a/docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md b/docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md new file mode 100644 index 00000000..38b40cce --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md @@ -0,0 +1,723 @@ +# Merak Windows Desktop Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the ten-page Merak Windows Desktop workbench from the approved screenshots, backed only by real API responses or explicitly derived data. + +**Architecture:** Keep the existing React, AppState, SSE, CSS Modules, and Tauri boundaries. Add a lazy-loaded desktop shell and page modules, split API access by resource domain, and migrate existing working components into page-specific layouts without creating a parallel application. + +**Tech Stack:** React 19, TypeScript 5.8, Vite 6, Vitest, Testing Library, CSS Modules, Lucide React, Tauri 2. + +## Global Constraints + +- Implement ten top-level pages: overview, sessions, world, characters, chapters, scenes, foreshadowing, secrets, files, settings. +- Use real HTTP API data or deterministic selectors over real responses; never ship fabricated dashboard or task data. +- Keep Tauri responsible only for local Runtime lifecycle, OS paths, and diagnostics. +- Preserve existing editor, approval, export, configuration, and desktop recovery workflows. +- Match the supplied `1672 x 941` screenshots and verify `1280 x 820` and `960 x 640` viewports. +- Keep React 19, TypeScript 5.8, Vite 6, CSS Modules, and Lucide React; add no remote-state or component-library dependency. +- Respect `prefers-reduced-motion` and keep all primary workflows keyboard reachable. +- Treat the two existing Inspector test failures as regressions to fix, not accepted baseline failures. +- Do not require `GET /v1/runs/:id/audit`; the backend does not implement it. + +--- + +## File Structure + +Create these focused modules: + +- `webui/src/api/http.ts`: base URL, request helpers, API error normalization. +- `webui/src/api/runtime.ts`: runtime, session, run, approval, SSE URL operations. +- `webui/src/api/worldbuilding.ts`: world, dashboard, agent, narrative, location, faction, knowledge, graph, timeline operations. +- `webui/src/api/files.ts`: workspace file and world file-link operations. +- `webui/src/api/config.ts`: LLM and preference operations. +- `webui/src/api/index.ts`: compatibility `api` facade used during migration. +- `webui/src/hooks/useResource.ts`: cancellable page-level GET lifecycle. +- `webui/src/shell/DesktopShell.tsx`: global navigation, top bar, page outlet, responsive rails. +- `webui/src/shell/DesktopShell.module.css`: shell grid and viewport breakpoints. +- `webui/src/shell/navigation.ts`: page metadata and icon mapping. +- `webui/src/components/layout/PageState.tsx`: loading, partial error, empty, retry states. +- `webui/src/components/layout/ResourceList.tsx`: selectable keyboard list primitive. +- `webui/src/components/layout/DetailPane.tsx`: detail content and optional inspector rail. +- `webui/src/pages/*.tsx`: one composition module for each top-level page. +- `webui/src/pages/*.module.css`: page-specific layout only; shared tokens remain global. +- `webui/src/pages/selectors.ts`: deterministic overview and progress calculations. +- `webui/src/__tests__/shell.test.tsx`: shell navigation and responsive behavior. +- `webui/src/__tests__/pages.test.tsx`: page loading, empty, partial failure, and interactions. +- `webui/src/__tests__/sse-workflows.test.tsx`: ask-user and creation-request workflows. + +Modify these existing modules: + +- `webui/src/App.tsx`: providers, bootstrap, lazy page outlet, global overlays. +- `webui/src/AppState.tsx`: top-level page IDs, selection state, ask/creation SSE state. +- `webui/src/api/types.ts`: API resource and SSE payload types. +- `webui/src/api/client.ts`: temporary re-export during migration, then compatibility-only facade. +- `webui/src/hooks/useSSE.ts`: exported parser, reconnect timer cleanup, duplicate sequence guard. +- `webui/src/styles/global.css`: approved navy/cool-gray tokens and shared control states. +- `webui/src/i18n.tsx`: visible Chinese and English shell/page copy. +- Existing components under `webui/src/components/`: migrate into pages without duplicating working business behavior. + +--- + +### Task 1: Normalize HTTP Errors and Split the API Foundation + +**Files:** +- Create: `webui/src/api/http.ts` +- Create: `webui/src/api/runtime.ts` +- Create: `webui/src/api/index.ts` +- Modify: `webui/src/api/client.ts` +- Modify: `webui/src/__tests__/api.test.ts` + +**Interfaces:** +- Produces: `request(method, path, body?)`, `requestForm(path, form)`, `ApiError`, `formatApiError`, `setApiBase`, `getApiBase`, `apiUrl`, and `runtimeApi`. +- Preserves: `import { api } from './api/client'` until all call sites migrate. + +- [ ] **Step 1: Write failing normalization tests** + +```ts +it.each([ + [{ error: { code: 'world_not_found', message: 'World not found', retryable: false } }, 'world_not_found'], + [{ ok: false, error: { code: 'file_conflict', message: 'Conflict', retryable: true } }, 'file_conflict'], + [{ error: 'session not found' }, undefined], +])('normalizes documented error shapes', async (payload, code) => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 409 })); + await expect(request('GET', '/failure')).rejects.toMatchObject({ status: 409, code }); +}); +``` + +- [ ] **Step 2: Run the focused tests and confirm failure** + +Run: `npm test -- --run src/__tests__/api.test.ts` + +Expected: FAIL because `request` is not exported from `api/http.ts`. + +- [ ] **Step 3: Implement the shared HTTP module** + +```ts +export class ApiError extends Error { + constructor(message: string, public status: number, public code?: string, public retryable = false) { + super(message); + } +} + +export async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(apiUrl(path), { + method, + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const raw = payload?.error; + const detail = typeof raw === 'object' && raw ? raw : {}; + throw new ApiError( + detail.message ?? (typeof raw === 'string' ? raw : `Request failed (${response.status})`), + response.status, + detail.code, + Boolean(detail.retryable), + ); + } + return payload as T; +} +``` + +Move the existing base URL, form/blob helpers, and `formatApiError` unchanged in behavior. Move runtime metadata, sessions, runs, delegations, approvals, creation resolution, and SSE URL methods into `runtimeApi` in `api/runtime.ts`. Re-export a compatibility `api` object from `api/index.ts` and `api/client.ts` by spreading `runtimeApi` so existing call sites retain their method names. + +- [ ] **Step 4: Run API tests and production build** + +Run: `npm test -- --run src/__tests__/api.test.ts && npm run build` + +Expected: API tests PASS; TypeScript and Vite build PASS. + +- [ ] **Step 5: Commit the API foundation** + +```powershell +git add webui/src/api/http.ts webui/src/api/runtime.ts webui/src/api/index.ts webui/src/api/client.ts webui/src/__tests__/api.test.ts +git commit -m "refactor(webui): split shared API transport" +``` + +### Task 2: Add Typed Worldbuilding and File Endpoints + +**Files:** +- Create: `webui/src/api/worldbuilding.ts` +- Create: `webui/src/api/files.ts` +- Create: `webui/src/api/config.ts` +- Modify: `webui/src/api/types.ts` +- Modify: `webui/src/api/index.ts` +- Test: `webui/src/__tests__/api.test.ts` + +**Interfaces:** +- Produces: `worldbuildingApi`, `filesApi`, `configApi`. +- Core signatures: `getDashboard(worldId)`, `listLocations(worldId)`, `listKnowledge(worldId)`, `listFactions(worldId)`, `getTimeline(worldId)`, `listGraphEntities(worldId)`, `reorderChapters(worldId, chapterIds)`, resource delete functions, `listWorldFiles(worldId)`, `linkWorldFile(worldId, link)`, `unlinkWorldFile(worldId, filePath, target)`. + +- [ ] **Step 1: Add failing URL and body tests** + +```ts +it('reorders chapters with the documented body', async () => { + fetchMock.mockResolvedValueOnce(json({ ok: true })); + await worldbuildingApi.reorderChapters('w 1', ['c1', 'c2']); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/worldbuilding/w%201/chapters/reorder'), + expect.objectContaining({ method: 'POST', body: JSON.stringify({ order: ['c1', 'c2'] }) }), + ); +}); + +it('encodes a linked file path when deleting it', async () => { + fetchMock.mockResolvedValueOnce(json({ ok: true })); + await filesApi.unlinkWorldFile('w1', '章节/第一章.md', { entity_type: 'chapter', entity_id: 'c1' }); + expect(fetchMock.mock.calls[0][0]).toContain(encodeURIComponent('章节/第一章.md')); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'DELETE', + body: JSON.stringify({ target_type: 'chapter', target_id: 'c1' }), + }); +}); +``` + +- [ ] **Step 2: Run tests and confirm missing modules** + +Run: `npm test -- --run src/__tests__/api.test.ts` + +Expected: FAIL on imports for `worldbuildingApi` and `filesApi`. + +- [ ] **Step 3: Define exact shared resource types** + +```ts +export interface ResourceListResponse { ok?: boolean; items?: T[]; } +export interface LocationItem { id: string; name: string; description?: string; version?: number; } +export interface KnowledgeItem { id: string; title: string; content?: string; tags?: string[]; version?: number; } +export interface FactionItem { id: string; name: string; description?: string; version?: number; } +export interface TimelineEvent { id: string; title: string; world_time?: string; description?: string; } +export interface GraphEntity { id: string; type: string; name: string; } +export interface WorldFileLink { file_path: string; entity_type?: string; entity_id?: string; } +``` + +Implement every method with `encodeURIComponent` for path IDs and documented request bodies. Use response adapters that accept documented named arrays and `items` without manufacturing records. + +- [ ] **Step 4: Run API tests, formatter, and build** + +Run: `npm test -- --run src/__tests__/api.test.ts && npm run format:check && npm run build` + +Expected: all commands PASS. + +- [ ] **Step 5: Commit typed API domains** + +```powershell +git add webui/src/api webui/src/__tests__/api.test.ts +git commit -m "feat(webui): add typed worldbuilding APIs" +``` + +### Task 3: Complete Ask-User and Creation SSE Workflows + +**Files:** +- Modify: `webui/src/api/types.ts` +- Modify: `webui/src/api/runtime.ts` +- Modify: `webui/src/AppState.tsx` +- Modify: `webui/src/hooks/useSSE.ts` +- Create: `webui/src/components/AskUserPrompt.tsx` +- Create: `webui/src/components/AskUserPrompt.module.css` +- Create: `webui/src/components/CreationRequestDialog.tsx` +- Test: `webui/src/__tests__/AppState.test.ts` +- Test: `webui/src/__tests__/sse-workflows.test.tsx` + +**Interfaces:** +- Produces: `runtimeApi.respondToAsk(runId: string, callId: string, response: string)`, `pendingAsk`, `pendingCreation`, and identifier-bearing `RESOLVE_ASK` / `RESOLVE_CREATION` actions. +- Consumes: existing `resolveCreation(id, decision, modifications?)` behavior. + +- [ ] **Step 1: Add reducer tests for SSE lifecycle and deduplication** + +```ts +const askFrame = { seq: 10, type: 'ask_user_requested', payload: { run_id: 'r1', question: 'Choose POV' } }; +expect(reducer(initialState, { type: 'APPLY_SSE', frame: askFrame }).pendingAsk) + .toEqual({ runId: 'r1', question: 'Choose POV' }); +expect(reducer({ ...initialState, lastSeq: 10 }, { type: 'APPLY_SSE', frame: askFrame })) + .toEqual({ ...initialState, lastSeq: 10 }); +``` + +- [ ] **Step 2: Run focused tests and confirm failure** + +Run: `npm test -- --run src/__tests__/AppState.test.ts src/__tests__/sse-workflows.test.tsx` + +Expected: FAIL because pending workflow state and dialogs do not exist. + +- [ ] **Step 3: Implement typed SSE state and dialogs** + +```ts +export interface PendingAsk { runId: string; callId: string; question: string; choices?: string[]; multiSelect: boolean; } +export interface PendingCreation { id: string; toolName: string; preview?: Record; } +``` + +Reject frames whose `seq` is nonzero and `seq <= state.lastSeq`. On submit, disable the dialog, call the real endpoint with the SSE `call_id`, clear only the same request identifier on success, and show `formatApiError` on failure. A `creation_resolved` frame clears state only when its matching `result.ok` is not false. Terminal run events clear matching pending ask/creation state. Multi-select requests support toggling multiple options and expose `aria-pressed`; dialogs trap focus while open and restore focus when closed. + +- [ ] **Step 4: Verify workflow tests and all reducer tests** + +Run: `npm test -- --run src/__tests__/AppState.test.ts src/__tests__/sse-workflows.test.tsx` + +Expected: PASS with one network call per submission and no duplicate frame effects. + +- [ ] **Step 5: Commit SSE workflow completion** + +```powershell +git add webui/src/api webui/src/AppState.tsx webui/src/hooks/useSSE.ts webui/src/components/AskUserPrompt* webui/src/components/CreationRequestDialog.tsx webui/src/__tests__ +git commit -m "feat(webui): complete interactive SSE requests" +``` + +### Task 4: Build Shared Page State and Resource Layout Primitives + +**Files:** +- Create: `webui/src/hooks/useResource.ts` +- Create: `webui/src/components/layout/PageState.tsx` +- Create: `webui/src/components/layout/PageState.module.css` +- Create: `webui/src/components/layout/ResourceList.tsx` +- Create: `webui/src/components/layout/ResourceList.module.css` +- Create: `webui/src/components/layout/DetailPane.tsx` +- Create: `webui/src/components/layout/DetailPane.module.css` +- Test: `webui/src/__tests__/pages.test.tsx` + +**Interfaces:** +- Produces: `useResource(key, loader)`, `PageState`, `ResourceList`, `DetailPane`. + +- [ ] **Step 1: Write failing partial-error and keyboard selection tests** + +```tsx +render( x.id} renderItem={x => x.name} onSelect={onSelect} />); +fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); +expect(onSelect).toHaveBeenCalledWith('b'); +``` + +- [ ] **Step 2: Run the focused page tests** + +Run: `npm test -- --run src/__tests__/pages.test.tsx` + +Expected: FAIL because layout primitives are missing. + +- [ ] **Step 3: Implement cancellable resource lifecycle** + +```ts +export type ResourceState = { status: 'loading' | 'ready' | 'error'; data: T | null; error: Error | null; retry(): void }; +export function useResource(key: string, loader: (signal: AbortSignal) => Promise): ResourceState; +``` + +The hook must abort the previous request on key change, retain successful data during retry, and ignore aborted rejections. `PageState` renders skeleton, retained-data warning, full error, or empty state based on explicit props. + +- [ ] **Step 4: Run tests and accessibility assertions** + +Run: `npm test -- --run src/__tests__/pages.test.tsx` + +Expected: PASS; listbox exposes selected option and keyboard navigation. + +- [ ] **Step 5: Commit layout primitives** + +```powershell +git add webui/src/hooks/useResource.ts webui/src/components/layout webui/src/__tests__/pages.test.tsx +git commit -m "feat(webui): add resource page primitives" +``` + +### Task 5: Add the Ten-Page Desktop Shell + +**Files:** +- Create: `webui/src/shell/navigation.ts` +- Create: `webui/src/shell/DesktopShell.tsx` +- Create: `webui/src/shell/DesktopShell.module.css` +- Modify: `webui/src/AppState.tsx` +- Modify: `webui/src/App.tsx` +- Modify: `webui/src/i18n.tsx` +- Test: `webui/src/__tests__/shell.test.tsx` + +**Interfaces:** +- Produces: `AppPage = 'overview' | 'sessions' | 'world' | 'characters' | 'chapters' | 'scenes' | 'foreshadowing' | 'secrets' | 'files' | 'settings' | 'editor'`. +- Produces: `DesktopShell({ page, onNavigate, children })`. + +- [ ] **Step 1: Add failing navigation tests** + +```tsx +const navigation = screen.getByRole('navigation', { name: '主导航' }); +expect(within(navigation).getAllByRole('button')).toHaveLength(10); +fireEvent.click(within(navigation).getByRole('button', { name: /角色/ })); +expect(dispatch).toHaveBeenCalledWith({ type: 'SET_PAGE', page: 'characters' }); +``` + +- [ ] **Step 2: Run shell tests and confirm failure** + +Run: `npm test -- --run src/__tests__/shell.test.tsx` + +Expected: FAIL because the desktop shell is missing. + +- [ ] **Step 3: Implement navigation metadata and shell composition** + +```ts +export const desktopPages = [ + ['overview', '概览', LayoutDashboard], ['sessions', 'Sessions 会话', MessagesSquare], + ['world', 'World 世界设定', Globe2], ['characters', 'Characters 角色', Users], + ['chapters', 'Chapters 章节', BookOpen], ['scenes', 'Scenes 场景', PanelsTopLeft], + ['foreshadowing', 'Foreshadowing 伏笔线索', Sparkles], ['secrets', 'Secrets 秘密', KeyRound], + ['files', 'Files 资料库', Files], ['settings', 'Settings 设置', Settings], +] as const; +``` + +Use ` -

- {state.activeEditorChapterTitle || '章节编辑'} -

+

{state.activeEditorChapterTitle || '章节编辑'}

@@ -248,23 +326,122 @@ function AppInner() { // Phase-based rendering (workbench page) if (!bootstrapped || state.appPhase === 'loading') { - return ; + return inDesktopShell(); } if (state.appPhase === 'no_world') { return ( - setHelpOpen(true)} /> - {helpOpen && setHelpOpen(false)} />} + {inDesktopShell( + setHelpOpen(true)} />, + + {helpOpen && setHelpOpen(false)} />} + , + )} + + ); + } + + if (state.currentPage === 'overview' && state.worldId) { + return inDesktopShell( + }> + + , + narrativePageOverlays, + ); + } + + if (state.currentPage === 'world' && state.worldId) { + return inDesktopShell( + }> + + , + narrativePageOverlays, + ); + } + + if (state.currentPage === 'characters' && state.worldId) { + return inDesktopShell( + }> + + , + narrativePageOverlays, + ); + } + + if (state.currentPage === 'chapters' && state.worldId) { + return ( + + {inDesktopShell( + }> + + , + narrativePageOverlays, + )} + + ); + } + + if (state.currentPage === 'scenes' && state.worldId) { + return ( + + {inDesktopShell( + }> + + , + narrativePageOverlays, + )} + + ); + } + + if (state.currentPage === 'foreshadowing' && state.worldId) { + return ( + + {inDesktopShell( + }> + + , + narrativePageOverlays, + )} ); } - if (state.appPhase === 'no_agent') { + if (state.currentPage === 'secrets' && state.worldId) { return ( - setHelpOpen(true)} /> - {helpOpen && setHelpOpen(false)} />} + {inDesktopShell( + }> + + , + narrativePageOverlays, + )} + + ); + } + + if (state.currentPage === 'files' && state.worldId) { + return inDesktopShell( + }> + + , + narrativePageOverlays, + ); + } + + if ( + state.appPhase === 'no_agent' && + !shouldRenderSessionsPage(state.currentPage, state.appPhase) + ) { + return ( + + {inDesktopShell( + setHelpOpen(true)} />, + + {helpOpen && setHelpOpen(false)} />} + , + )} ); } @@ -272,68 +449,97 @@ function AppInner() { // appPhase === 'ready': three-column Workbench return ( -
- - setSidebarOpen(false)} /> - - -
- - setSidebarOpen((prev) => !prev)} - onToggleInspector={() => setInspectorOpen((prev) => !prev)} - onOpenGuide={() => setHelpOpen(true)} - sidebarOpen={sidebarOpen} - inspectorOpen={inspectorOpen} - connectionState={connState} - /> + {inDesktopShell( + state.currentPage === 'sessions' ? ( + }> + setHelpOpen(true)} /> + + ) : ( +
+ + setSidebarOpen(false)} /> + + +
+ + setSidebarOpen((prev) => !prev)} + onToggleInspector={() => setInspectorOpen((prev) => !prev)} + onOpenGuide={() => setHelpOpen(true)} + sidebarOpen={sidebarOpen} + inspectorOpen={inspectorOpen} + connectionState={connState} + /> +
+
+ + setInspectorOpen(false)} /> +
- - setHelpOpen(false)} /> - - setInspectorOpen(false)} /> - -
+ ), + <> + + setHelpOpen(false)} /> + - {/* Setup Wizard — shown when LLM is not configured */} - - {state.showSetupWizard && ( - dispatch({ type: 'SET_LLM_CONFIGURED', configured: true })} /> - )} - + {/* Setup Wizard — shown when LLM is not configured */} + + {state.showSetupWizard && ( + dispatch({ type: 'SET_LLM_CONFIGURED', configured: true })} + /> + )} + - {/* Chapter Review Banner — shown after chapter completion */} - - {state.chapterReview && state.worldId && ( - { - dispatch({ type: 'SET_CHAPTER_REVIEW', review: null }); - if (state.sessionId) { - api.startRun(state.sessionId, '开始写下一章', state.selectedModel).catch(() => {}); - } - }} - onRevise={() => { - dispatch({ type: 'SET_CHAPTER_REVIEW', review: null }); - }} - onExport={() => dispatch({ type: 'SET_SHOW_EXPORT_DIALOG', show: true })} - onClose={() => dispatch({ type: 'SET_CHAPTER_REVIEW', review: null })} - /> - )} - + {/* Chapter Review Banner — shown after chapter completion */} + + {state.chapterReview && state.worldId && ( + { + dispatch({ type: 'SET_CHAPTER_REVIEW', review: null }); + if (state.sessionId) { + api + .startRun(state.sessionId, '开始写下一章', state.selectedModel) + .catch(() => {}); + } + }} + onRevise={() => { + dispatch({ type: 'SET_CHAPTER_REVIEW', review: null }); + }} + onExport={() => dispatch({ type: 'SET_SHOW_EXPORT_DIALOG', show: true })} + onClose={() => dispatch({ type: 'SET_CHAPTER_REVIEW', review: null })} + /> + )} + - {/* Export Dialog */} - - {state.showExportDialog && state.worldId && ( - dispatch({ type: 'SET_SHOW_EXPORT_DIALOG', show: false })} - /> - )} - + {state.pendingAsk && ( + dispatch({ type: 'RESOLVE_ASK', callId })} + /> + )} + {state.pendingCreation && ( + dispatch({ type: 'RESOLVE_CREATION', creationId })} + /> + )} + + {/* Export Dialog */} + + {state.showExportDialog && state.worldId && ( + dispatch({ type: 'SET_SHOW_EXPORT_DIALOG', show: false })} + /> + )} + + , + )} ); } diff --git a/webui/src/AppState.tsx b/webui/src/AppState.tsx index 73964498..f770c739 100644 --- a/webui/src/AppState.tsx +++ b/webui/src/AppState.tsx @@ -1,9 +1,18 @@ -import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useReducer, + type Dispatch, + type ReactNode, +} from 'react'; import type { ChapterReviewItem, ConditionState, ForeshadowingItem, Message, + PendingAsk, + PendingCreation, PhaseTransition, PipelineViewData, RuntimeMetadata, @@ -18,9 +27,21 @@ import type { WorldAgent, WorldSummary, } from './api/types'; +import { readStoredDesktopPage, writeStoredDesktopPage } from './shell/navigation'; export type InspectorTab = 'story' | 'files' | 'agents' | 'run' | 'creation'; -export type AppPage = 'workbench' | 'settings' | 'editor'; +export type AppPage = + | 'overview' + | 'sessions' + | 'world' + | 'characters' + | 'chapters' + | 'scenes' + | 'foreshadowing' + | 'secrets' + | 'files' + | 'settings' + | 'editor'; export type WorldbuildingStatus = 'idle' | 'loading' | 'ready' | 'error'; export interface GeneratedFileEntry { @@ -48,6 +69,8 @@ export interface AppState { sessionId: string; lastSeq: number; currentRun: string | null; + pendingAsk: PendingAsk | null; + pendingCreation: PendingCreation | null; lastRunId: string | null; messages: Message[]; status: StatusLabel; @@ -109,13 +132,15 @@ export interface AppState { export const initialState: AppState = { appPhase: 'loading', - currentPage: 'workbench', + currentPage: 'overview', activeEditorChapterId: null, activeEditorChapterTitle: '', agentId: null, sessionId: '', lastSeq: 0, currentRun: null, + pendingAsk: null, + pendingCreation: null, lastRunId: null, messages: [], status: 'idle', @@ -308,6 +333,8 @@ export type Action = | { type: 'COMMIT_ACTIVE' } | { type: 'SET_STORY_VERSION' } | { type: 'APPLY_SSE'; frame: SseFrame } + | { type: 'RESOLVE_ASK'; callId: string } + | { type: 'RESOLVE_CREATION'; creationId: string } | { type: 'SET_PIPELINE_CONDITIONS'; conditions: ConditionState[] } | { type: 'SET_PIPELINE_VIEW'; view: Partial } | { type: 'DISMISS_PHASE_PROMPT' } @@ -339,6 +366,8 @@ export function reducer(state: AppState, action: Action): AppState { lastSeq: 0, currentRun: null, lastRunId: null, + pendingAsk: null, + pendingCreation: null, status: 'idle', }; @@ -346,11 +375,14 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, sessionId: action.sessionId, - agentId: action.agentId ?? state.agentId, + agentId: action.agentId ?? null, + appPhase: action.sessionId ? 'ready' : state.worldId ? 'no_agent' : state.appPhase, messages: [], lastSeq: 0, currentRun: null, lastRunId: null, + pendingAsk: null, + pendingCreation: null, status: 'idle', }; @@ -485,7 +517,8 @@ export function reducer(state: AppState, action: Action): AppState { ...state, editorBuffers: { ...state.editorBuffers, - [action.fileId]: state.editorOriginals[action.fileId] ?? state.editorBuffers[action.fileId] ?? '', + [action.fileId]: + state.editorOriginals[action.fileId] ?? state.editorBuffers[action.fileId] ?? '', }, workspaceFiles: state.workspaceFiles.map((file) => file.id === action.fileId ? { ...file, dirty: false } : file, @@ -678,10 +711,19 @@ export function reducer(state: AppState, action: Action): AppState { case 'APPLY_SSE': { const { frame } = action; + if (frame.seq > 0 && frame.seq <= state.lastSeq) return state; if (frame.seq > 0) state = { ...state, lastSeq: Math.max(state.lastSeq, frame.seq) }; return applySseFrame(state, frame); } + case 'RESOLVE_ASK': + return state.pendingAsk?.callId === action.callId ? { ...state, pendingAsk: null } : state; + + case 'RESOLVE_CREATION': + return state.pendingCreation?.id === action.creationId + ? { ...state, pendingCreation: null } + : state; + case 'SET_PIPELINE_CONDITIONS': return { ...state, pipelineConditions: action.conditions }; @@ -868,6 +910,42 @@ function applySseFrame(state: AppState, frame: SseFrame): AppState { case 'approval_resolved': return reducer(state, { type: 'CLEAR_APPROVAL' }); + case 'ask_user_requested': { + const choices = Array.isArray(p.options) + ? p.options.filter((choice): choice is string => typeof choice === 'string') + : undefined; + return { + ...state, + pendingAsk: { + runId: (p.run_id as string) || state.currentRun || '', + callId: (p.call_id as string) ?? '', + question: (p.question as string) ?? '', + ...(choices?.length ? { choices } : {}), + multiSelect: p.multi_select === true, + }, + }; + } + + case 'creation_requested': + return { + ...state, + pendingCreation: { + id: (p.creation_id as string) ?? '', + runId: (p.run_id as string) || state.currentRun || '', + toolName: (p.tool as string) ?? '', + ...(p.preview && typeof p.preview === 'object' + ? { preview: p.preview as Record } + : {}), + }, + }; + + case 'creation_resolved': { + const result = p.result as Record | undefined; + if (result?.ok === false) return state; + const creationId = (p.creation_id as string) ?? ''; + return reducer(state, { type: 'RESOLVE_CREATION', creationId }); + } + case 'usage_updated': return reducer(state, { type: 'SET_USAGE', @@ -884,7 +962,8 @@ function applySseFrame(state: AppState, frame: SseFrame): AppState { message: { id: msgId(), kind: 'system', text: `${type} — ${JSON.stringify(p)}` }, }); - case 'run_completed': + case 'run_completed': { + state = clearPendingForTerminalRun(state, (p.run_id as string) || state.currentRun || ''); return reducer( reducer(reducer(state, { type: 'COMMIT_ACTIVE' }), { type: 'SET_CURRENT_RUN', @@ -892,10 +971,12 @@ function applySseFrame(state: AppState, frame: SseFrame): AppState { }), { type: 'SET_STATUS', status: 'idle' }, ); + } case 'run_failed': case 'run_cancelled': case 'run_interrupted': + state = clearPendingForTerminalRun(state, (p.run_id as string) || state.currentRun || ''); state = reducer(reducer(state, { type: 'COMMIT_ACTIVE' }), { type: 'SET_CURRENT_RUN', runId: null, @@ -1077,6 +1158,15 @@ function applySseFrame(state: AppState, frame: SseFrame): AppState { } } +function clearPendingForTerminalRun(state: AppState, runId: string): AppState { + if (!runId) return state; + return { + ...state, + pendingAsk: state.pendingAsk?.runId === runId ? null : state.pendingAsk, + pendingCreation: state.pendingCreation?.runId === runId ? null : state.pendingCreation, + }; +} + function generatedFilesFromText(text: string): GeneratedFileEntry[] { const paths = new Set(); const filePattern = @@ -1133,7 +1223,14 @@ const AppContext = createContext<{ } | null>(null); export function AppStateProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(reducer, initialState); + const [state, baseDispatch] = useReducer(reducer, initialState, (baseState) => ({ + ...baseState, + currentPage: readStoredDesktopPage(), + })); + const dispatch = useCallback>((action) => { + if (action.type === 'SET_PAGE') writeStoredDesktopPage(action.page); + baseDispatch(action); + }, []); return {children}; } diff --git a/webui/src/__tests__/AppState.test.ts b/webui/src/__tests__/AppState.test.ts index 31ade23a..ff9358f2 100644 --- a/webui/src/__tests__/AppState.test.ts +++ b/webui/src/__tests__/AppState.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { shouldReportWorldbuildingPartialFailure, shouldWarnBeforeClose } from '../App'; +import { + shouldRenderSessionsPage, + shouldReportWorldbuildingPartialFailure, + shouldWarnBeforeClose, +} from '../App'; import { initialState, reducer, type AppState } from '../AppState'; function state(overrides: Partial = {}): AppState { @@ -8,13 +12,40 @@ function state(overrides: Partial = {}): AppState { describe('AppState reducer', () => { it('SET_SESSION resets messages and sets sessionId', () => { - const prev = state({ messages: [{ id: 'm1', kind: 'user', text: 'hi' }], lastSeq: 5 }); + const prev = state({ + messages: [{ id: 'm1', kind: 'user', text: 'hi' }], + lastSeq: 5, + pendingAsk: { + runId: 'old-run', + callId: 'old-call', + question: 'Old question', + multiSelect: false, + }, + pendingCreation: { id: 'old-creation', runId: 'old-run', toolName: 'create_scene' }, + }); const next = reducer(prev, { type: 'SET_SESSION', sessionId: 'new-id' }); expect(next.sessionId).toBe('new-id'); expect(next.messages).toHaveLength(0); expect(next.lastSeq).toBe(0); expect(next.currentRun).toBeNull(); expect(next.status).toBe('idle'); + expect(next.pendingAsk).toBeNull(); + expect(next.pendingCreation).toBeNull(); + }); + + it('SET_SESSION replaces the previous agent and clears it when the session has none', () => { + const withAgent = reducer(state({ agentId: 'old-agent' }), { + type: 'SET_SESSION', + sessionId: 'agent-session', + agentId: 'new-agent', + }); + expect(withAgent.agentId).toBe('new-agent'); + + const withoutAgent = reducer(withAgent, { + type: 'SET_SESSION', + sessionId: 'world-session', + }); + expect(withoutAgent.agentId).toBeNull(); }); it('SET_METADATA stores metadata and sets selectedModel', () => { @@ -42,7 +73,16 @@ describe('AppState reducer', () => { const next = reducer(prev, { type: 'SET_SESSIONS', sessions: [ - { id: 's1', title: 'Test', world_id: null, agent_id: null, last_seq: 0, created_at: '', updated_at: '', archived_at: null }, + { + id: 's1', + title: 'Test', + world_id: null, + agent_id: null, + last_seq: 0, + created_at: '', + updated_at: '', + archived_at: null, + }, ], }); expect(next.sessions).toHaveLength(1); @@ -200,6 +240,179 @@ describe('AppState reducer', () => { expect(next2.lastSeq).toBe(7); }); + it('tracks and resolves interactive SSE requests', () => { + const askFrame = { + seq: 10, + type: 'ask_user_requested', + payload: { + run_id: 'r1', + call_id: 'call_1', + question: 'Choose POV', + options: ['First person', 'Third person'], + }, + }; + const asked = reducer(state(), { type: 'APPLY_SSE', frame: askFrame }); + expect(asked.pendingAsk).toEqual({ + runId: 'r1', + callId: 'call_1', + question: 'Choose POV', + choices: ['First person', 'Third person'], + multiSelect: false, + }); + expect(reducer(asked, { type: 'RESOLVE_ASK', callId: 'call_1' }).pendingAsk).toBeNull(); + + const creationFrame = { + seq: 11, + type: 'creation_requested', + payload: { + run_id: 'r1', + creation_id: 'creation_1', + tool: 'create_scene', + preview: { title: 'Arrival' }, + }, + }; + const requested = reducer(asked, { type: 'APPLY_SSE', frame: creationFrame }); + expect(requested.pendingCreation).toEqual({ + id: 'creation_1', + runId: 'r1', + toolName: 'create_scene', + preview: { title: 'Arrival' }, + }); + expect( + reducer(requested, { + type: 'APPLY_SSE', + frame: { + seq: 12, + type: 'creation_resolved', + payload: { creation_id: 'creation_1', decision: 'allow' }, + }, + }).pendingCreation, + ).toBeNull(); + expect( + reducer(requested, { type: 'RESOLVE_CREATION', creationId: 'creation_1' }).pendingCreation, + ).toBeNull(); + }); + + it('retains a creation request when its resolving SSE result fails', () => { + const prev = state({ + pendingCreation: { id: 'creation_1', runId: 'run_1', toolName: 'create_scene' }, + lastSeq: 4, + }); + const next = reducer(prev, { + type: 'APPLY_SSE', + frame: { + seq: 5, + type: 'creation_resolved', + payload: { creation_id: 'creation_1', result: { ok: false } }, + }, + }); + expect(next.pendingCreation).toEqual(prev.pendingCreation); + }); + + it('does not let stale local resolutions clear newer requests', () => { + const prev = state({ + pendingAsk: { + runId: 'run_2', + callId: 'call_2', + question: 'New question', + multiSelect: false, + }, + pendingCreation: { id: 'creation_2', runId: 'run_2', toolName: 'create_scene' }, + }); + expect(reducer(prev, { type: 'RESOLVE_ASK', callId: 'call_1' }).pendingAsk).toEqual( + prev.pendingAsk, + ); + expect( + reducer(prev, { type: 'RESOLVE_CREATION', creationId: 'creation_1' }).pendingCreation, + ).toEqual(prev.pendingCreation); + }); + + it.each(['run_completed', 'run_failed', 'run_cancelled', 'run_interrupted'])( + 'clears matching pending requests on %s', + (type) => { + const prev = state({ + currentRun: 'run_1', + pendingAsk: { + runId: 'run_1', + callId: 'call_1', + question: 'Question', + multiSelect: false, + }, + pendingCreation: { id: 'creation_1', runId: 'run_1', toolName: 'create_scene' }, + }); + const next = reducer(prev, { + type: 'APPLY_SSE', + frame: { seq: 8, type, payload: { run_id: 'run_1' } }, + }); + expect(next.pendingAsk).toBeNull(); + expect(next.pendingCreation).toBeNull(); + }, + ); + + it('keeps newer pending requests when an older run terminates', () => { + const prev = state({ + currentRun: 'run_2', + pendingAsk: { + runId: 'run_2', + callId: 'call_2', + question: 'Question', + multiSelect: false, + }, + pendingCreation: { id: 'creation_2', runId: 'run_2', toolName: 'create_scene' }, + }); + const next = reducer(prev, { + type: 'APPLY_SSE', + frame: { seq: 8, type: 'run_completed', payload: { run_id: 'run_1' } }, + }); + expect(next.pendingAsk).toEqual(prev.pendingAsk); + expect(next.pendingCreation).toEqual(prev.pendingCreation); + }); + + it('retains a creation from another run even when currentRun matches the terminal event', () => { + const pendingCreation = { id: 'creation_2', runId: 'run_2', toolName: 'create_scene' }; + const next = reducer(state({ currentRun: 'run_1', pendingCreation }), { + type: 'APPLY_SSE', + frame: { seq: 8, type: 'run_completed', payload: { run_id: 'run_1' } }, + }); + expect(next.pendingCreation).toEqual(pendingCreation); + }); + + it('clears a creation matching the terminal run even when currentRun has moved on', () => { + const next = reducer( + state({ + currentRun: 'run_3', + pendingCreation: { id: 'creation_2', runId: 'run_2', toolName: 'create_scene' }, + }), + { + type: 'APPLY_SSE', + frame: { seq: 8, type: 'run_failed', payload: { run_id: 'run_2', error: 'stopped' } }, + }, + ); + expect(next.pendingCreation).toBeNull(); + }); + + it('ignores duplicate and stale nonzero SSE frames before applying their effects', () => { + const askFrame = { + seq: 10, + type: 'ask_user_requested', + payload: { run_id: 'r1', question: 'Choose POV' }, + }; + const prev = state({ lastSeq: 10 }); + expect(reducer(prev, { type: 'APPLY_SSE', frame: askFrame })).toEqual(prev); + + const zeroSeq = reducer(prev, { + type: 'APPLY_SSE', + frame: { ...askFrame, seq: 0 }, + }); + expect(zeroSeq.pendingAsk).toEqual({ + runId: 'r1', + callId: '', + question: 'Choose POV', + multiSelect: false, + }); + expect(zeroSeq.lastSeq).toBe(10); + }); + describe('SSE frame: run_started', () => { it('creates user message from payload.message', () => { const prev = state(); @@ -349,6 +562,17 @@ describe('worldbuilding bootstrap error policy', () => { }); }); +describe('sessions route policy', () => { + it('allows the sessions page while a world has no active agent session', () => { + expect(shouldRenderSessionsPage('sessions', 'no_agent')).toBe(true); + }); + + it('keeps loading and no-world phases on their truthful boundary pages', () => { + expect(shouldRenderSessionsPage('sessions', 'loading')).toBe(false); + expect(shouldRenderSessionsPage('sessions', 'no_world')).toBe(false); + }); +}); + describe('close protection policy', () => { it('warns before closing while a run is active', () => { expect(shouldWarnBeforeClose(state({ currentRun: 'run_1', status: 'thinking' }))).toBe(true); diff --git a/webui/src/__tests__/SettingsPanel.test.tsx b/webui/src/__tests__/SettingsPanel.test.tsx index c29c5b44..27b9152a 100644 --- a/webui/src/__tests__/SettingsPanel.test.tsx +++ b/webui/src/__tests__/SettingsPanel.test.tsx @@ -1,7 +1,17 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { api } from '../api/client'; +import { AppStateProvider } from '../AppState'; import SettingsPanel from '../components/Sidebar/SettingsPanel'; +import { + exportDiagnostics, + getDesktopRuntimeLogs, + getDesktopRuntimeStatus, + openDiagnosticsFolder, + restartDesktopRuntime, +} from '../desktop'; import { I18nProvider } from '../i18n'; +import SettingsPage from '../pages/SettingsPage'; vi.mock('../api/client', () => ({ api: { @@ -14,6 +24,23 @@ vi.mock('../api/client', () => ({ }), saveConfig: vi.fn().mockResolvedValue({ ok: true }), testConfig: vi.fn().mockResolvedValue({ ok: true }), + getPreferences: vi.fn().mockResolvedValue({ + default_genre: 'No preference', + preferred_style: '简洁', + allow_usage_logs: true, + }), + savePreferences: vi.fn().mockResolvedValue({ ok: true }), + metadata: vi.fn().mockResolvedValue({ + provider: 'openai', + model: 'gpt-4o', + models: [], + permission_mode: 'approval', + memory: { enabled: true }, + tools: [], + mcp_servers: [], + agents: [], + delegation_patterns: [], + }), }, formatApiError: vi.fn((error: unknown, fallback: string) => error instanceof Error ? error.message : fallback, @@ -34,7 +61,8 @@ vi.mock('../desktop', () => ({ error: null, }), isDesktopApp: vi.fn(() => true), - openDiagnosticsFolder: vi.fn(), + getDesktopRuntimeLogs: vi.fn().mockResolvedValue({ lines: ['runtime ready'] }), + openDiagnosticsFolder: vi.fn().mockResolvedValue({ ok: true, path: 'C:/Users/me/logs' }), restartDesktopRuntime: vi.fn().mockResolvedValue({ ok: true, status: null }), })); @@ -50,6 +78,130 @@ describe('SettingsPanel polish', () => { expect(screen.getByLabelText('访问密钥')).toBeDefined(); expect(screen.getByText('本地桌面状态')).toBeDefined(); expect(screen.getByRole('button', { name: '导出故障报告' })).toBeDefined(); - expect(document.body.textContent ?? '').not.toMatch(/API Key|API Base URL|Runtime|Database|Diagnostics/i); + expect(document.body.textContent ?? '').not.toMatch( + /API Key|API Base URL|Runtime|Database|Diagnostics/i, + ); + }); +}); + +describe('Settings page capabilities', () => { + it('keeps API credentials masked and does not enable unsupported settings', async () => { + render( + + + , + ); + + const key = await screen.findByLabelText('API key'); + expect(key).toHaveAttribute('type', 'password'); + expect(screen.queryByRole('switch', { name: /auto.?save/i })).toBeNull(); + expect(screen.queryByRole('combobox', { name: /theme/i })).toBeNull(); + expect(screen.queryByRole('textbox', { name: /database/i })).toBeNull(); + }); + + it('renders writable API base URL, read-only permission mode, and restart-required save result', async () => { + vi.mocked(api.saveConfig).mockResolvedValueOnce({ ok: true, restart_required: true } as never); + render( + + + , + ); + expect(await screen.findByLabelText('API base URL')).toHaveValue('https://api.openai.com/v1'); + expect(await screen.findByText('approval')).toBeDefined(); + fireEvent.change(screen.getByLabelText('API base URL'), { + target: { value: 'http://localhost:11434/v1' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save model settings' })); + expect(await screen.findByRole('status')).toHaveTextContent(/restart required/i); + expect(api.saveConfig).toHaveBeenCalledWith( + expect.objectContaining({ api_base_url: 'http://localhost:11434/v1' }), + ); + }); + + it('keeps model settings editable when preferences fail and reports a local warning', async () => { + vi.mocked(api.getPreferences).mockRejectedValueOnce(new Error('Preferences offline')); + render( + + + , + ); + expect(await screen.findByLabelText('API key')).toBeDefined(); + expect(screen.getByText(/Preferences unavailable: Preferences offline/)).toBeDefined(); + expect(screen.queryByLabelText('Default genre')).toBeNull(); + expect(screen.queryByLabelText('Preferred style')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Save preferences' })).toBeNull(); + expect(api.savePreferences).not.toHaveBeenCalled(); + expect(screen.queryByText('Loading model settings...')).toBeNull(); + }); + + it('reports null and unsuccessful desktop action results without discarding runtime state', async () => { + vi.mocked(getDesktopRuntimeStatus) + .mockResolvedValueOnce({ + phase: 'ready', + apiBaseUrl: 'http://127.0.0.1:3888', + port: 3888, + pid: 1234, + version: '0.1.0', + pgStatus: 'ready', + configPath: 'C:/config.json', + logPath: 'C:/desktop.log', + error: null, + }) + .mockResolvedValueOnce(null); + vi.mocked(restartDesktopRuntime).mockResolvedValueOnce(null); + vi.mocked(openDiagnosticsFolder).mockResolvedValueOnce({ + ok: false, + path: '', + error: 'Folder unavailable', + } as never); + vi.mocked(exportDiagnostics).mockResolvedValueOnce(null); + vi.mocked(getDesktopRuntimeLogs).mockResolvedValueOnce(null); + render( + + + , + ); + expect(await screen.findByText('http://127.0.0.1:3888')).toBeDefined(); + + for (const [name, failure] of [ + ['Refresh', /refresh runtime status failed/i], + ['Restart Runtime', /restart runtime failed/i], + ['Open diagnostics', /Folder unavailable/i], + ['Export diagnostics', /export diagnostics failed/i], + ['View logs', /view logs failed/i], + ] as const) { + fireEvent.click(screen.getByRole('button', { name })); + expect(await screen.findByRole('alert')).toHaveTextContent(failure); + expect(screen.getByText('http://127.0.0.1:3888')).toBeDefined(); + } + }); + + it('restricts preferred style to the backend enum', async () => { + render( + + + , + ); + const style = await screen.findByLabelText('Preferred style'); + expect(style.tagName).toBe('SELECT'); + expect( + within(style) + .getAllByRole('option') + .map((option) => option.textContent), + ).toEqual(['轻松', '严肃', '诗意', '简洁']); + }); + + it('keeps config available when runtime sources fail', async () => { + vi.mocked(api.metadata).mockRejectedValueOnce(new Error('Metadata offline')); + vi.mocked(getDesktopRuntimeStatus).mockRejectedValueOnce(new Error('Desktop offline')); + render( + + + , + ); + expect(await screen.findByLabelText('API key')).toBeDefined(); + expect(screen.getByText(/Runtime metadata unavailable: Metadata offline/)).toBeDefined(); + expect(screen.getByText(/Desktop Runtime unavailable: Desktop offline/)).toBeDefined(); + expect(screen.getAllByText('Unavailable').length).toBeGreaterThan(0); }); }); diff --git a/webui/src/__tests__/UserFlow.test.tsx b/webui/src/__tests__/UserFlow.test.tsx index 49ed4f7e..1d2a05c9 100644 --- a/webui/src/__tests__/UserFlow.test.tsx +++ b/webui/src/__tests__/UserFlow.test.tsx @@ -1,10 +1,283 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { useEffect } from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { AppStateProvider } from '../AppState'; +import { api } from '../api/client'; +import { AppStateProvider, useAppState } from '../AppState'; import Composer from '../components/Composer'; import ExportDialog from '../components/ExportDialog'; -import { ToastProvider } from '../components/Toast'; import SetupWizard from '../components/SetupWizard'; +import { ToastProvider } from '../components/Toast'; +import ScenesPage from '../pages/ScenesPage'; +import SessionsPage from '../pages/SessionsPage'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function SessionsPageHarness() { + const { dispatch } = useAppState(); + + useEffect(() => { + dispatch({ type: 'SET_WORLD', worldId: 'world_1' }); + dispatch({ type: 'SET_SESSION', sessionId: 'session_1' }); + dispatch({ type: 'SET_CURRENT_RUN', runId: 'run_1' }); + dispatch({ type: 'SET_STATUS', status: 'thinking' }); + dispatch({ + type: 'SET_WORLDBUILDING_DATA', + worlds: [ + { + id: 'world_1', + name: 'Northreach', + description: 'Snowbound border city', + created_at: '2026-06-06T10:00:00Z', + }, + ], + agents: [], + foreshadowing: [], + secrets: [], + worldTime: 'Day 4, dusk', + }); + dispatch({ + type: 'SET_SESSIONS', + sessions: [ + { + id: 'session_1', + title: 'Plan the rain invasion', + world_id: 'world_1', + agent_id: null, + last_seq: 6, + created_at: '2026-06-06T10:30:00Z', + updated_at: '2026-06-06T10:35:00Z', + archived_at: null, + }, + ], + }); + dispatch({ type: 'SET_INSPECTOR_TAB', tab: 'run' }); + }, [dispatch]); + + return ; +} + +describe('Sessions workbench', () => { + it('keeps session history, conversation, context, composer, and execution state together', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); + render( + + + + + , + ); + + expect(await screen.findByRole('region', { name: 'Session history' })).toBeDefined(); + expect(screen.getByRole('main', { name: 'Conversation' })).toBeDefined(); + expect(screen.getByRole('complementary', { name: 'Story inspector' })).toBeDefined(); + expect(screen.getByRole('heading', { name: 'Plan the rain invasion' })).toBeDefined(); + expect(screen.getByTestId('composer-input')).toBeDefined(); + for (const tab of ['Story', 'Files', 'Agents', 'Create', 'Run']) { + expect(screen.getByRole('tab', { name: tab })).toBeDefined(); + } + expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0); + expect(await screen.findByText('Creation in progress')).toBeDefined(); + }); + + it('hides and restores history and inspector panels with truthful toggle state', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); + render( + + + + + , + ); + + const history = await screen.findByRole('region', { name: 'Session history' }); + const inspector = screen.getByRole('complementary', { name: 'Story inspector' }); + expect(history).toHaveAttribute('aria-hidden', 'false'); + expect(inspector).toHaveAttribute('aria-hidden', 'false'); + + fireEvent.click(screen.getByTestId('menu-btn')); + expect(history).toHaveAttribute('aria-hidden', 'true'); + expect(history.className).not.toContain('historyOpen'); + expect(screen.getByRole('button', { name: 'Open session history' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + + fireEvent.click(screen.getByTestId('inspector-btn')); + expect(inspector).toHaveAttribute('aria-hidden', 'true'); + expect(inspector.className).not.toContain('panelOpen'); + expect(screen.getByRole('button', { name: 'Open inspector' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('opens the inspector from its tablet default without exposing a hidden-state mismatch', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); + render( + + + + + , + ); + + const inspector = await screen.findByTestId('inspector-panel'); + expect(inspector).toHaveAttribute('aria-hidden', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Open inspector' })); + expect(inspector).toHaveAttribute('aria-hidden', 'false'); + expect(inspector.className).toContain('panelOpen'); + }); +}); + +describe('Scene completion flow', () => { + it('marks a scene complete only after endScene resolves and renders returned extraction counts', async () => { + const ending = deferred>>(); + vi.spyOn(api, 'listScenes').mockResolvedValue({ + ok: true, + scenes: [ + { + id: 'scene-1', + title: 'Crossing the flooded stacks', + chapter_id: 'chapter-2', + world_time: 'Day 4, dusk', + status: 'writing', + participant_ids: ['lin'], + updated_at: '2026-06-19T09:00:00Z', + }, + ], + }); + vi.spyOn(api, 'endScene').mockReturnValue(ending.promise); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('option', { name: /Crossing the flooded stacks/ })); + expect(screen.getByText('writing')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'End scene' })); + const dialog = screen.getByRole('dialog', { name: 'End scene' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'End Scene' })); + + expect(screen.getByText('writing')).toBeDefined(); + expect(screen.queryByText('completed')).toBeNull(); + + await act(async () => + ending.resolve({ + ok: true, + diaries_written: [{ id: 'diary-1', agent_id: 'lin', scene_id: 'scene-1' }], + diary_count: 1, + relations_updated: 2, + proposed_foreshadowing: [{ id: 'thread-1', content: 'The archive glass remembers.' }], + leak_risks: 0, + }), + ); + + await waitFor(() => expect(screen.getByText('completed')).toBeDefined()); + expect(screen.getByText('diaries written').parentElement).toHaveTextContent('1'); + expect(screen.getByText('relations updated').parentElement).toHaveTextContent('2'); + expect(screen.getByText('The archive glass remembers.')).toBeDefined(); + }); + + it('keeps the end-scene target fixed when the live scene selection changes', async () => { + vi.spyOn(api, 'listScenes').mockResolvedValue({ + ok: true, + scenes: [ + { + id: 'scene-1', + title: 'First scene', + chapter_id: 'chapter-1', + world_time: 'Dawn', + status: 'writing', + participant_ids: [], + updated_at: '2026-06-19T09:00:00Z', + }, + { + id: 'scene-2', + title: 'Second scene', + chapter_id: 'chapter-1', + world_time: 'Noon', + status: 'writing', + participant_ids: [], + updated_at: '2026-06-19T10:00:00Z', + }, + ], + }); + vi.spyOn(api, 'endScene').mockResolvedValue({ + ok: true, + diaries_written: [], + diary_count: 0, + relations_updated: 0, + proposed_foreshadowing: [], + leak_risks: 0, + }); + render( + + + , + ); + fireEvent.click(await screen.findByRole('option', { name: /First scene/ })); + fireEvent.click(screen.getByRole('button', { name: 'End scene' })); + fireEvent.click(screen.getAllByRole('option', { hidden: true })[1]); + fireEvent.click( + within(screen.getByRole('dialog', { name: 'End scene' })).getByRole('button', { + name: 'End Scene', + }), + ); + + await waitFor(() => expect(api.endScene).toHaveBeenCalled()); + expect(api.endScene).toHaveBeenCalledWith('world-1', 'scene-1', expect.any(Object)); + }); + + it('traps focus in the end-scene modal and restores focus to its opener', async () => { + vi.spyOn(api, 'listScenes').mockResolvedValue({ + ok: true, + scenes: [ + { + id: 'scene-1', + title: 'First scene', + chapter_id: 'chapter-1', + world_time: 'Dawn', + status: 'writing', + participant_ids: [], + updated_at: '2026-06-19T09:00:00Z', + }, + ], + }); + render( + + + , + ); + fireEvent.click(await screen.findByRole('option', { name: /First scene/ })); + const opener = screen.getByRole('button', { name: 'End scene' }); + opener.focus(); + fireEvent.click(opener); + const dialog = screen.getByRole('dialog', { name: 'End scene' }); + + expect( + screen.getByRole('listbox', { name: 'Scenes', hidden: true }).closest('aside'), + ).toHaveAttribute('inert'); + expect(screen.getByRole('region', { hidden: true })).toHaveAttribute('aria-hidden', 'true'); + await waitFor(() => expect(within(dialog).getByRole('textbox')).toHaveFocus()); + const [close, cancel] = within(dialog).getAllByRole('button', { name: 'Cancel' }); + const end = within(dialog).getByRole('button', { name: 'End Scene' }); + close.focus(); + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + expect(end).toHaveFocus(); + end.focus(); + fireEvent.keyDown(dialog, { key: 'Tab' }); + expect(close).toHaveFocus(); + fireEvent.click(cancel); + expect(opener).toHaveFocus(); + }); +}); describe('SetupWizard', () => { it('renders the provider selection step initially', () => { @@ -74,9 +347,7 @@ describe('ExportDialog', () => { it('renders the export dialog with chapter checkboxes', () => { const onClose = vi.fn(); - render( - , - ); + render(); // Dialog title expect(screen.getByText('导出 TXT')).toBeDefined(); @@ -101,9 +372,7 @@ describe('ExportDialog', () => { it('all checking and unchecking of chapters via select-all and deselect-all', () => { const onClose = vi.fn(); - render( - , - ); + render(); // All checkboxes should be checked by default const checkbox1 = screen.getByLabelText(/第1章 The Beginning/) as HTMLInputElement; @@ -128,9 +397,7 @@ describe('ExportDialog', () => { it('export button is disabled until title is entered and at least one chapter is selected', () => { const onClose = vi.fn(); - render( - , - ); + render(); const exportBtn = screen.getByRole('button', { name: '导出' }); @@ -152,9 +419,7 @@ describe('ExportDialog', () => { it('calls onClose when cancel button is clicked', () => { const onClose = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getByRole('button', { name: '取消' })); expect(onClose).toHaveBeenCalledTimes(1); @@ -223,4 +488,20 @@ describe('Composer', () => { // Send button is disabled when text is empty expect((sendBtn as HTMLButtonElement).disabled).toBe(true); }); + + it('cannot start a run without a selected session', () => { + const startRun = vi.spyOn(api, 'startRun'); + render( + + + + + , + ); + + fireEvent.change(screen.getByTestId('composer-input'), { target: { value: 'Draft a scene' } }); + expect(screen.getByTestId('send-btn')).toBeDisabled(); + fireEvent.click(screen.getByTestId('send-btn')); + expect(startRun).not.toHaveBeenCalled(); + }); }); diff --git a/webui/src/__tests__/api.test.ts b/webui/src/__tests__/api.test.ts index 5f3fc566..f32aed75 100644 --- a/webui/src/__tests__/api.test.ts +++ b/webui/src/__tests__/api.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { filesApi, worldbuildingApi } from '../api'; +import { request } from '../api/http'; describe('api client', () => { beforeEach(() => { @@ -9,8 +11,33 @@ describe('api client', () => { vi.unstubAllGlobals(); }); + it.each([ + [ + { error: { code: 'world_not_found', message: 'World not found', retryable: false } }, + 'world_not_found', + ], + [ + { ok: false, error: { code: 'file_conflict', message: 'Conflict', retryable: true } }, + 'file_conflict', + ], + [{ error: 'session not found' }, undefined], + ])('normalizes documented error shapes', async (payload, code) => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 409 })); + await expect(request('GET', '/failure')).rejects.toMatchObject({ status: 409, code }); + }); + + it('rejects non-success 3xx responses with ApiError', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 304 })); + + await expect(request('GET', '/not-modified')).rejects.toMatchObject({ + name: 'ApiError', + status: 304, + }); + }); + it('metadata() calls GET /v1/runtime', async () => { vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: async () => ({ model: 'gpt-4o' }), } as Response); @@ -26,6 +53,7 @@ describe('api client', () => { it('createSession() calls POST /v1/sessions', async () => { vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 201, json: async () => ({ session_id: 's1' }), } as Response); @@ -82,6 +110,7 @@ describe('api client', () => { it('worldbuilding readers call the selected world endpoints', async () => { vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: async () => ({ ok: true, agents: [] }), } as Response); @@ -116,6 +145,7 @@ describe('api client', () => { it('saveConfig() sends max_output_tokens to the backend', async () => { vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: async () => ({ ok: true }), } as Response); @@ -146,6 +176,7 @@ describe('api client', () => { it('deleteWorld() calls DELETE /api/worldbuilding/worlds/:id', async () => { vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: async () => ({ ok: true, deleted: 'world_1' }), } as Response); @@ -161,6 +192,7 @@ describe('api client', () => { it('advanceWorldTime() calls POST /api/worldbuilding/:worldId/time/advance', async () => { vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: async () => ({ ok: true, world_time: 'Day 2 Dawn' }), } as Response); @@ -176,4 +208,190 @@ describe('api client', () => { }), ); }); + + it('reorders chapters with the documented body', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + + await worldbuildingApi.reorderChapters('w 1', ['c1', 'c2']); + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.stringContaining('/api/worldbuilding/w%201/chapters/reorder'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ order: ['c1', 'c2'] }), + }), + ); + }); + + it('requires target metadata when deleting a linked file', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + + const unlinkWithoutTarget = filesApi.unlinkWorldFile as ( + worldId: string, + filePath: string, + ) => Promise; + + await expect(unlinkWithoutTarget('w1', '章节/第一章.md')).rejects.toThrow( + 'File link target is required', + ); + }); + + it('encodes a linked file path and target when deleting it', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + + await filesApi.unlinkWorldFile('w1', '章节/第一章.md', { + entity_type: 'chapter', + entity_id: 'c1', + }); + + expect(vi.mocked(fetch).mock.calls[0][0]).toContain(encodeURIComponent('章节/第一章.md')); + expect(vi.mocked(fetch).mock.calls[0][1]).toMatchObject({ + method: 'DELETE', + body: JSON.stringify({ target_type: 'chapter', target_id: 'c1' }), + }); + }); + + it('preserves named resource arrays and truthful knowledge fields', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, locations: [{ id: 'l1', name: 'Harbor' }] }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + knowledge: [{ id: 'k1', category: 'history', content: 'Founded at dawn' }], + }), + { status: 200 }, + ), + ); + + const locations = await worldbuildingApi.listLocations('w1'); + const knowledge = await worldbuildingApi.listKnowledge('w1'); + + expect(locations.locations?.[0].name).toBe('Harbor'); + expect(locations.items).toEqual(locations.locations); + expect(knowledge.knowledge?.[0].category).toBe('history'); + expect(knowledge.items).toEqual(knowledge.knowledge); + }); + + it('preserves timeline metadata with truthful event fields', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + current_time: { day: 2, period: 1, label: 'Day 2' }, + events: [{ id: 'e1', world_time: 'Day 2', description: 'The gates opened' }], + }), + { status: 200 }, + ), + ); + + const timeline = await worldbuildingApi.getTimeline('w1'); + + expect(timeline.current_time.label).toBe('Day 2'); + expect(timeline.events?.[0].description).toBe('The gates opened'); + expect(timeline.items).toEqual(timeline.events); + }); + + it('uses the implemented agent memory, voice, and delete endpoints', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + summaries: [ + { + id: 'm1', + period_start: 'Day 1', + period_end: 'Day 2', + summary: 'Kept watch.', + source_diary_ids: ['d1'], + created_at: '2026-06-20', + }, + ], + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + voice: { + avg_sentence_length: 8.5, + sentence_variance: 1.2, + question_frequency: 0.1, + modifier_ratio: 0.2, + sample_count: 4, + signature_words: ['steady'], + tone_profile: { question_ratio: 0.1 }, + }, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const { api } = await import('../api/client'); + + const memory = await api.fetchMemorySummaries('world one', 'agent/two'); + const voice = await api.fetchAgentVoice('world one', 'agent/two'); + await api.deleteAgent('world one', 'agent/two'); + + expect(memory.summaries[0].summary).toBe('Kept watch.'); + expect(voice.voice.signature_words).toEqual(['steady']); + expect(vi.mocked(fetch).mock.calls.map(([url, init]) => [url, init?.method])).toEqual([ + ['/api/worldbuilding/world%20one/agents/agent%2Ftwo/memory-summaries', 'GET'], + ['/api/worldbuilding/world%20one/agents/agent%2Ftwo/voice', 'GET'], + ['/api/worldbuilding/world%20one/agents/agent%2Ftwo', 'DELETE'], + ]); + }); + + it('normalizes only voice endpoint 404 to an empty fingerprint', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 'not_found', message: 'Voice fingerprint not found' } }), + { status: 404 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 'database_error', message: 'Voice store failed' } }), + { status: 500 }, + ), + ); + const { api } = await import('../api/client'); + + await expect(api.fetchAgentVoice('w1', 'a1')).resolves.toEqual({ ok: true, voice: null }); + await expect(api.fetchAgentVoice('w1', 'a1')).rejects.toMatchObject({ + status: 500, + message: 'Voice store failed', + }); + }); + + it('preserves documented file-link target fields', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + files: [{ file_path: 'chapter.md', target_type: 'chapter', target_id: 'c1' }], + }), + { status: 200 }, + ), + ); + + const files = await filesApi.listWorldFiles('w1'); + + expect(files.files?.[0].target_type).toBe('chapter'); + expect(files.items?.[0].target_id).toBe('c1'); + }); }); diff --git a/webui/src/__tests__/components.test.tsx b/webui/src/__tests__/components.test.tsx index fafc7aaa..5a78df97 100644 --- a/webui/src/__tests__/components.test.tsx +++ b/webui/src/__tests__/components.test.tsx @@ -1,11 +1,10 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { useEffect } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { StrictMode, useEffect } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { api } from '../api/client'; import { AppStateProvider, useAppState } from '../AppState'; -import { getDesktopRuntimeSteps } from '../DesktopBoot'; import BrandMark from '../components/BrandMark'; import AssistantCell from '../components/cells/AssistantCell'; import StatusPill from '../components/cells/StatusPill'; @@ -20,6 +19,7 @@ import WorldSelector from '../components/Sidebar/WorldSelector'; import { ToastProvider } from '../components/Toast'; import WorldDashboard from '../components/WorldDashboard'; import WorldOnboarding from '../components/WorldOnboarding'; +import { getDesktopRuntimeSteps } from '../DesktopBoot'; function TimelineHarness() { const { dispatch } = useAppState(); @@ -121,6 +121,47 @@ function SessionLifecycleHarness() { return ; } +function SessionSelectionHarness() { + const { state, dispatch } = useAppState(); + + useEffect(() => { + dispatch({ type: 'SET_AGENT_SESSION', sessionId: 'agent_session', agentId: 'agent_old' }); + dispatch({ + type: 'SET_SESSIONS', + sessions: [ + { + id: 'agent_session', + title: 'Agent draft', + world_id: 'world_1', + agent_id: 'agent_old', + last_seq: 1, + created_at: '2026-06-06T10:30:00Z', + updated_at: '2026-06-06T10:30:00Z', + archived_at: null, + }, + { + id: 'world_session', + title: 'World notes', + world_id: 'world_1', + agent_id: null, + last_seq: 2, + created_at: '2026-06-06T11:00:00Z', + updated_at: '2026-06-06T11:00:00Z', + archived_at: null, + }, + ], + }); + }, [dispatch]); + + return ( + <> + + {state.sessionId} + {state.agentId ?? 'none'} + + ); +} + function WorldIconHarness() { const { dispatch } = useAppState(); @@ -194,12 +235,14 @@ describe('Cell components', () => { render( - + {}} /> , ); - expect(screen.getByRole('button', { name: 'Open sidebar' }).querySelector('svg')).toBeDefined(); + expect( + screen.getByRole('button', { name: 'Open session history' }).querySelector('svg'), + ).toBeDefined(); expect( screen.getByRole('button', { name: 'Open inspector' }).querySelector('svg'), ).toBeDefined(); @@ -207,6 +250,43 @@ describe('Cell components', () => { expect(screen.queryByText('◫')).toBeNull(); }); + it('MainPanel only references session history controls in sessions mode', () => { + const { rerender } = render( + + + {}} /> + + , + ); + + expect(screen.getByRole('button', { name: 'Open sidebar' })).not.toHaveAttribute( + 'aria-controls', + ); + + rerender( + + + {}} /> + + , + ); + + expect(screen.getByRole('button', { name: 'Open session history' })).toHaveAttribute( + 'aria-controls', + 'session-history-panel', + ); + }); + + it('keeps the sessions center column shrinkable before inspector overlay mode', () => { + const css = readFileSync(join(process.cwd(), 'src/pages/SessionsPage.module.css'), 'utf8'); + const basePageRule = css.match(/^\.page\s*\{([^}]*)\}/m)?.[1] ?? ''; + + expect(basePageRule).toMatch( + /grid-template-columns:\s*var\(--history-width\)\s+minmax\(0,\s*1fr\)\s+var\(--inspector-width\)/, + ); + expect(css).toMatch(/@media\s*\(max-width:\s*1179px\)/); + }); + it('MainPanel opens an in-workbench guide from the help control', () => { render( @@ -264,6 +344,21 @@ describe('Cell components', () => { expect(screen.getByLabelText('Session Old outline, archived, 8 turns')).toBeDefined(); }); + it('SessionList selects the session agent and clears a stale agent when absent', () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByLabelText('Session World notes, 2 turns')); + + expect(screen.getByLabelText('selected-session')).toHaveTextContent('world_session'); + expect(screen.getByLabelText('selected-agent')).toHaveTextContent('none'); + }); + it('UserCell renders text', () => { render(); expect(screen.getByText('Hello world')).toBeDefined(); @@ -418,7 +513,9 @@ describe('World onboarding', () => { target: { value: 'Snowbound border city' }, }); fireEvent.click(screen.getByRole('button', { name: /创建第一个|Create first/ })); - fireEvent.change(screen.getByLabelText(/Character Name|人物姓名|角色姓名/), { target: { value: 'Lian' } }); + fireEvent.change(screen.getByLabelText(/Character Name|人物姓名|角色姓名/), { + target: { value: 'Lian' }, + }); fireEvent.change(screen.getByLabelText(/Identity|身份/), { target: { value: 'Archivist of the old passes' }, }); @@ -688,10 +785,17 @@ function AgentsPanelHarness() { return {}} />; } -function FilesHarness() { +function FilesHarness({ + twoFiles = false, + worldId = 'world-a', +}: { + twoFiles?: boolean; + worldId?: string; +}) { const { dispatch } = useAppState(); useEffect(() => { + dispatch({ type: 'SET_WORLD', worldId }); dispatch({ type: 'SET_OUTPUT_DIRECTORY', path: '/Users/me/novel', @@ -706,7 +810,13 @@ function FilesHarness() { }, }); dispatch({ type: 'SET_INSPECTOR_TAB', tab: 'files' }); - }, [dispatch]); + if (twoFiles) { + dispatch({ + type: 'REGISTER_GENERATED_FILE', + file: { id: 'file_2', title: 'notes', path: '/Users/me/novel/notes.txt', updatedAt: 2 }, + }); + } + }, [dispatch, twoFiles, worldId]); return {}} />; } @@ -728,8 +838,8 @@ describe('InspectorPanel', () => { expect(screen.getByText('Lian')).toBeDefined(); expect(screen.getByText('The bell tower never rings at noon')).toBeDefined(); expect(screen.getByText('Lian knows the passphrase')).toBeDefined(); - expect(screen.getByText('World time control')).toBeDefined(); - expect(screen.getByText('Current: Day 4, dusk')).toBeDefined(); + expect(screen.getByText('世界时间')).toBeDefined(); + expect(screen.getByText('当前:Day 4, dusk')).toBeDefined(); expect(screen.queryByText(/[�鈥鈫鈭]/)).toBeNull(); }); @@ -809,7 +919,9 @@ describe('InspectorPanel', () => { expect(screen.getAllByText('Unsaved changes').length).toBeGreaterThan(0); - fireEvent.click(screen.getByRole('button', { name: 'Revert chapter-12 to last loaded version' })); + fireEvent.click( + screen.getByRole('button', { name: 'Revert chapter-12 to last loaded version' }), + ); expect((editor as HTMLTextAreaElement).value).toBe('Original draft.'); expect(screen.queryByText('Unsaved changes')).toBeNull(); @@ -829,11 +941,13 @@ describe('InspectorPanel', () => { vi.spyOn(api, 'saveWorkspaceFile').mockRejectedValue(new Error('Disk is locked')); render( - - - - - , + + + + + + + , ); fireEvent.click(await screen.findByRole('button', { name: 'Open chapter-12 in editor' })); @@ -845,6 +959,263 @@ describe('InspectorPanel', () => { expect(await screen.findAllByText('Disk is locked')).toHaveLength(2); }); + it('blocks file switching during save and commits the returned version', async () => { + let resolveSave!: (value: Awaited>) => void; + const pending = new Promise>>((resolve) => { + resolveSave = resolve; + }); + vi.spyOn(api, 'readWorkspaceFile').mockResolvedValue({ + ok: true, + file: { + path: '/Users/me/novel/chapter-12.md', + content: 'Original', + encoding: 'utf-8', + updated_at: 'old', + version: 'v1', + }, + }); + const save = vi + .spyOn(api, 'saveWorkspaceFile') + .mockReturnValueOnce(pending) + .mockResolvedValueOnce({ + ok: true, + file: { path: '/Users/me/novel/chapter-12.md', updated_at: 'newer', version: 'v3' }, + }); + render( + + + + + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Open chapter-12 in editor' })); + const editor = await screen.findByLabelText('Edit chapter-12'); + fireEvent.change(editor, { target: { value: 'First save' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter-12' })); + expect(screen.getByRole('button', { name: 'Open chapter-12 in editor' })).toBeDisabled(); + await act(async () => + resolveSave({ + ok: true, + file: { path: '/Users/me/novel/chapter-12.md', updated_at: 'new', version: 'v2' }, + }), + ); + fireEvent.change(editor, { target: { value: 'Second save' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter-12' })); + await waitFor(() => + expect(save).toHaveBeenLastCalledWith('/Users/me/novel/chapter-12.md', 'Second save', 'v2'), + ); + }); + + it('ignores stale read errors and finally callbacks across A-B-A overlap', async () => { + let rejectA!: (reason: Error) => void; + let resolveB!: (value: Awaited>) => void; + let resolveNewestA!: (value: Awaited>) => void; + const oldA = new Promise((_, reject) => { + rejectA = reject; + }); + const oldB = new Promise>>((resolve) => { + resolveB = resolve; + }); + const newestA = new Promise>>((resolve) => { + resolveNewestA = resolve; + }); + vi.spyOn(api, 'readWorkspaceFile') + .mockReturnValueOnce(oldA) + .mockReturnValueOnce(oldB) + .mockReturnValueOnce(newestA); + render( + + + + + , + ); + const a = await screen.findByRole('button', { name: 'Open chapter-12 in editor' }); + const b = screen.getByRole('button', { name: 'Open notes in editor' }); + fireEvent.click(a); + fireEvent.click(b); + fireEvent.click(a); + await act(async () => rejectA(new Error('Old A failed'))); + await act(async () => + resolveB({ + ok: true, + file: { + path: '/Users/me/novel/notes.txt', + content: 'Old B', + encoding: 'utf-8', + updated_at: 'old', + version: 'b1', + }, + }), + ); + expect(screen.queryByText('Old A failed')).toBeNull(); + expect(screen.getByRole('button', { name: 'Open chapter-12 in editor' })).toHaveTextContent( + 'Loading...', + ); + await act(async () => + resolveNewestA({ + ok: true, + file: { + path: '/Users/me/novel/chapter-12.md', + content: 'Newest A', + encoding: 'utf-8', + updated_at: 'new', + version: 'a2', + }, + }), + ); + expect(await screen.findByLabelText('Edit chapter-12')).toHaveValue('Newest A'); + }); + + it('invalidates a pending read and resets loading when the world changes', async () => { + let rejectRead!: (reason: Error) => void; + vi.spyOn(api, 'readWorkspaceFile').mockReturnValue( + new Promise((_, reject) => { + rejectRead = reject; + }), + ); + const view = render( + + + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Open chapter-12 in editor' })); + expect(screen.getByRole('button', { name: 'Open chapter-12 in editor' })).toHaveTextContent( + 'Loading...', + ); + view.rerender( + + + + + , + ); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Open chapter-12 in editor' })).toHaveTextContent( + 'Edit', + ), + ); + await act(async () => rejectRead(new Error('Old world read failed'))); + expect(screen.queryByText('Old world read failed')).toBeNull(); + }); + + it('invalidates save completion after a world switch', async () => { + let resolveSave!: (value: Awaited>) => void; + vi.spyOn(api, 'readWorkspaceFile').mockResolvedValue({ + ok: true, + file: { + path: '/Users/me/novel/chapter-12.md', + content: 'Old', + encoding: 'utf-8', + updated_at: 'old', + version: 'v1', + }, + }); + vi.spyOn(api, 'saveWorkspaceFile').mockReturnValue( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const view = render( + + + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Open chapter-12 in editor' })); + fireEvent.change(await screen.findByLabelText('Edit chapter-12'), { + target: { value: 'Local A' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter-12' })); + view.rerender( + + + + + , + ); + await act(async () => + resolveSave({ + ok: true, + file: { path: '/Users/me/novel/chapter-12.md', updated_at: 'new', version: 'v2' }, + }), + ); + expect(screen.queryByText('File saved.')).toBeNull(); + expect(screen.queryByText('Saved')).toBeNull(); + }); + + it('does not let an A-B-A open completion clear the newest open operation', async () => { + let resolveOld!: (value: { ok: boolean; path: string }) => void; + let resolveNew!: (value: { ok: boolean; path: string }) => void; + vi.spyOn(api, 'openWorkspacePath') + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOld = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveNew = resolve; + }), + ); + const view = render( + + + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Open output folder' })); + view.rerender( + + + + + , + ); + view.rerender( + + + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Open output folder' })); + await act(async () => resolveOld({ ok: true, path: '/old' })); + expect(screen.getByRole('button', { name: 'Open output folder' })).toHaveTextContent( + 'Opening...', + ); + await act(async () => resolveNew({ ok: true, path: '/new' })); + expect(screen.getByRole('button', { name: 'Open output folder' })).toHaveTextContent( + 'Open folder', + ); + }); + + it('ignores pending Inspector callbacks after unmount', async () => { + let rejectRead!: (reason: Error) => void; + vi.spyOn(api, 'readWorkspaceFile').mockReturnValue( + new Promise((_, reject) => { + rejectRead = reject; + }), + ); + const view = render( + + + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Open chapter-12 in editor' })); + view.unmount(); + await act(async () => rejectRead(new Error('Unmounted read'))); + expect(screen.queryByText('Unmounted read')).toBeNull(); + }); + it('renders a readable creation dashboard without mojibake copy', async () => { render( @@ -853,8 +1224,8 @@ describe('InspectorPanel', () => { ); expect(await screen.findByRole('heading', { name: 'Creation Panel' })).toBeDefined(); - expect(screen.getByRole('tab', { name: /Foreshadowing/ })).toBeDefined(); - expect(screen.getByText('Plant and track narrative threads.')).toBeDefined(); + expect(screen.getByRole('tab', { name: 'Create' })).toBeDefined(); + expect(await screen.findByText('Plant and track narrative threads.')).toBeDefined(); expect(screen.getByText('Open Threads')).toBeDefined(); expect(screen.getByText('The old bell never rings at noon.')).toBeDefined(); expect(screen.queryByText(/[�鈥鈫鈭]/)).toBeNull(); diff --git a/webui/src/__tests__/pages.test.tsx b/webui/src/__tests__/pages.test.tsx new file mode 100644 index 00000000..40a1026f --- /dev/null +++ b/webui/src/__tests__/pages.test.tsx @@ -0,0 +1,2339 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { startTransition, Suspense, useState } from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { api } from '../api/client'; +import { filesApi } from '../api/files'; +import { worldbuildingApi } from '../api/worldbuilding'; +import { AppStateProvider } from '../AppState'; +import CreateForeshadowingModal from '../components/Inspector/CreateForeshadowingModal'; +import CreateSecretModal from '../components/Inspector/CreateSecretModal'; +import DetailPane from '../components/layout/DetailPane'; +import PageState from '../components/layout/PageState'; +import ResourceList from '../components/layout/ResourceList'; +import ResponsivePane from '../components/layout/ResponsivePane'; +import { useResource } from '../hooks/useResource'; +import ChaptersPage from '../pages/ChaptersPage'; +import CharactersPage from '../pages/CharactersPage'; +import FilesPage from '../pages/FilesPage'; +import ForeshadowingPage, { deriveForeshadowingStatus } from '../pages/ForeshadowingPage'; +import OverviewPage from '../pages/OverviewPage'; +import ScenesPage from '../pages/ScenesPage'; +import SecretsPage from '../pages/SecretsPage'; +import { selectWorldMetrics } from '../pages/selectors'; +import WorldPage from '../pages/WorldPage'; + +vi.mock('../api/client', () => ({ + api: { + listAgents: vi.fn(), + listChapters: vi.fn(), + listScenes: vi.fn(), + listWorkspaceFiles: vi.fn(), + getStoryOverview: vi.fn(), + getWorldDetail: vi.fn(), + fetchAgentDetail: vi.fn(), + fetchDiaries: vi.fn(), + fetchRelations: vi.fn(), + createAgent: vi.fn(), + fetchMemorySummaries: vi.fn(), + fetchAgentVoice: vi.fn(), + deleteAgent: vi.fn(), + endScene: vi.fn(), + readWorkspaceFile: vi.fn(), + saveWorkspaceFile: vi.fn(), + openWorkspacePath: vi.fn(), + patchChapter: vi.fn(), + listForeshadowing: vi.fn(), + listSecrets: vi.fn(), + patchForeshadow: vi.fn(), + patchSecret: vi.fn(), + deleteForeshadowing: vi.fn(), + deleteSecret: vi.fn(), + createForeshadowing: vi.fn(), + createSecret: vi.fn(), + }, + formatApiError: vi.fn((error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback, + ), +})); + +vi.mock('../api/files', () => ({ filesApi: { listWorldFiles: vi.fn() } })); + +describe('Files page', () => { + const file = { + id: 'draft', + path: 'chapters/draft.md', + name: 'draft.md', + ext: 'md', + mime: 'text/markdown', + size: 14, + updated_at: '2026-06-20T00:00:00Z', + dirty: false, + }; + + beforeEach(() => { + vi.mocked(filesApi.listWorldFiles).mockResolvedValue({ ok: true, items: [] }); + }); + + it('loads file content only after selection', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: file.path, + content: 'Original text', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }); + + render(); + await screen.findByRole('option', { name: /draft/i }); + expect(api.readWorkspaceFile).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('option', { name: /draft/i })); + expect(await screen.findByRole('textbox', { name: 'File content' })).toHaveValue( + 'Original text', + ); + }); + + it('preserves the local draft and offers Reload and Copy after a file conflict', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: file.path, + content: 'Original text', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }); + vi.mocked(api.saveWorkspaceFile).mockRejectedValue( + Object.assign(new Error('File changed on disk'), { status: 409, code: 'file_conflict' }), + ); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + const editor = await screen.findByRole('textbox', { name: 'File content' }); + fireEvent.change(editor, { target: { value: 'My local draft' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save file' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/changed on disk/i); + expect(editor).toHaveValue('My local draft'); + expect(screen.getByRole('button', { name: 'Reload file' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Copy local draft' })).toBeDefined(); + }); + + it('ignores stale file reads across an A-B-A selection sequence', async () => { + const secondA = deferred>>(); + const firstA = deferred>>(); + const b = deferred>>(); + const other = { ...file, id: 'notes', path: 'notes.md', name: 'notes.md' }; + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file, other], + }); + vi.mocked(api.readWorkspaceFile) + .mockReturnValueOnce(firstA.promise) + .mockReturnValueOnce(b.promise) + .mockReturnValueOnce(secondA.promise); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + fireEvent.click(screen.getByRole('option', { name: /notes/i })); + fireEvent.click(screen.getByRole('option', { name: /draft/i })); + await act(async () => + secondA.resolve({ + ok: true, + file: { + path: file.path, + content: 'Newest A', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v3', + }, + }), + ); + await act(async () => + firstA.resolve({ + ok: true, + file: { + path: file.path, + content: 'Old A', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }), + ); + await act(async () => + b.resolve({ + ok: true, + file: { + path: other.path, + content: 'Old B', + encoding: 'utf-8', + updated_at: other.updated_at, + version: 'v2', + }, + }), + ); + + expect(screen.getByRole('textbox', { name: 'File content' })).toHaveValue('Newest A'); + }); + + it('keeps save completion live while refresh and selection are blocked', async () => { + const saving = deferred>>(); + const other = { ...file, id: 'notes', path: 'notes.md', name: 'notes.md' }; + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file, other], + }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: file.path, + content: 'Old', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }); + vi.mocked(api.saveWorkspaceFile).mockReturnValue(saving.promise); + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + fireEvent.change(await screen.findByRole('textbox', { name: 'File content' }), { + target: { value: 'Saved' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save file' })); + expect(screen.getByRole('button', { name: 'Refresh files' })).toBeDisabled(); + expect(screen.getByRole('option', { name: /notes/i })).toHaveAttribute('aria-disabled', 'true'); + await act(async () => + saving.resolve({ ok: true, file: { path: file.path, updated_at: 'now', version: 'v2' } }), + ); + expect(screen.getByRole('button', { name: 'Refresh files' })).not.toBeDisabled(); + expect(screen.getByRole('option', { name: /notes/i })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + }); + + it('shows world-link request failure as unavailable rather than empty', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(filesApi.listWorldFiles).mockRejectedValue(new Error('Links offline')); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: file.path, + content: 'Old', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }); + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + expect(await screen.findByText(/World links unavailable: Links offline/)).toBeDefined(); + expect(screen.queryByText('No world links.')).toBeNull(); + }); + + it('shows content loading failures separately from list and link state', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(api.readWorkspaceFile).mockRejectedValue(new Error('Content offline')); + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + expect(await screen.findByRole('alert')).toHaveTextContent('Content offline'); + expect(screen.getByRole('option', { name: /draft/i })).toBeDefined(); + expect(screen.queryByText(/World links unavailable/)).toBeNull(); + }); + + it('reloads the remote version and reports clipboard failures without losing the draft', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Clipboard denied')); + Object.assign(navigator, { clipboard: { writeText } }); + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(api.readWorkspaceFile) + .mockResolvedValueOnce({ + ok: true, + file: { + path: file.path, + content: 'Old', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }) + .mockResolvedValueOnce({ + ok: true, + file: { + path: file.path, + content: 'Remote', + encoding: 'utf-8', + updated_at: 'now', + version: 'v2', + }, + }); + vi.mocked(api.saveWorkspaceFile).mockRejectedValue( + Object.assign(new Error('Changed'), { status: 409, code: 'file_conflict' }), + ); + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + const editor = await screen.findByRole('textbox', { name: 'File content' }); + fireEvent.change(editor, { target: { value: 'Local' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save file' })); + fireEvent.click(await screen.findByRole('button', { name: 'Copy local draft' })); + expect(writeText).toHaveBeenCalledWith('Local'); + expect(await screen.findByText('Clipboard denied')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Reload file' })); + expect(await screen.findByRole('textbox', { name: 'File content' })).toHaveValue('Remote'); + fireEvent.change(screen.getByRole('textbox', { name: 'File content' }), { + target: { value: 'Next' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save file' })); + await waitFor(() => + expect(api.saveWorkspaceFile).toHaveBeenLastCalledWith(file.path, 'Next', 'v2'), + ); + }); + + it('opens the real workspace root and sends the selected type to the list API', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + render(); + expect(await screen.findByText('C:/story')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Open workspace' })); + expect(api.openWorkspacePath).toHaveBeenCalledWith('C:/story'); + fireEvent.change(screen.getByLabelText('File type'), { target: { value: 'markdown' } }); + await waitFor(() => + expect(api.listWorkspaceFiles).toHaveBeenLastCalledWith({ + world_id: 'world-1', + type: 'markdown', + }), + ); + }); + + it('keeps the previous list and shows a local warning when refresh or type loading fails', async () => { + vi.mocked(api.listWorkspaceFiles) + .mockResolvedValueOnce({ ok: true, root: 'C:/story', files: [file] }) + .mockRejectedValueOnce(new Error('Refresh offline')) + .mockRejectedValueOnce(new Error('Type offline')); + render(); + expect(await screen.findByRole('option', { name: /draft/i })).toBeDefined(); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh files' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Refresh offline'); + expect(screen.getByRole('option', { name: /draft/i })).toBeDefined(); + + fireEvent.change(screen.getByLabelText('File type'), { target: { value: 'markdown' } }); + expect(await screen.findByRole('alert')).toHaveTextContent('Type offline'); + expect(screen.getByRole('option', { name: /draft/i })).toBeDefined(); + }); + + it('locks root Open while pending and reports null, false, and rejected results', async () => { + const opening = deferred>>(); + vi.mocked(api.openWorkspacePath).mockClear(); + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [], + }); + vi.mocked(api.openWorkspacePath) + .mockReturnValueOnce(opening.promise) + .mockResolvedValueOnce(null as never) + .mockResolvedValueOnce({ ok: false, path: '', error: 'Open denied' } as never) + .mockRejectedValueOnce(new Error('Open crashed')); + render(); + const open = await screen.findByRole('button', { name: 'Open workspace' }); + + fireEvent.click(open); + expect(open).toBeDisabled(); + fireEvent.click(open); + expect(api.openWorkspacePath).toHaveBeenCalledTimes(1); + await act(async () => opening.resolve({ ok: true, path: 'C:/story' } as never)); + expect(open).not.toBeDisabled(); + + for (const failure of [/open workspace failed/i, /Open denied/i, /Open crashed/i]) { + fireEvent.click(open); + expect(await screen.findByRole('alert')).toHaveTextContent(failure); + expect(open).not.toBeDisabled(); + } + }); + + it('locks selected-file Reveal and reports null, false, and rejected results', async () => { + const revealing = deferred>>(); + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: file.path, + content: 'Original text', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }); + vi.mocked(api.openWorkspacePath) + .mockReturnValueOnce(revealing.promise) + .mockResolvedValueOnce(null as never) + .mockResolvedValueOnce({ ok: false, path: '', error: 'Reveal denied' } as never) + .mockRejectedValueOnce(new Error('Reveal crashed')); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + const reveal = await screen.findByRole('button', { name: 'Reveal selected file' }); + + fireEvent.click(reveal); + expect(reveal).toBeDisabled(); + expect(reveal).toHaveTextContent('Revealing...'); + await act(async () => revealing.resolve({ ok: true, path: file.path } as never)); + expect(await screen.findByRole('status')).toHaveTextContent('File revealed in workspace.'); + + for (const failure of [/reveal file failed/i, /Reveal denied/i, /Reveal crashed/i]) { + fireEvent.click(reveal); + expect(await screen.findByRole('alert')).toHaveTextContent(failure); + expect(reveal).not.toBeDisabled(); + } + }); + + it('invalidates pending Reveal feedback when another file is selected', async () => { + const revealing = deferred>>(); + const second = { ...file, path: 'C:/story/second.md', name: 'second.md' }; + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file, second], + }); + vi.mocked(api.readWorkspaceFile).mockImplementation(async (path) => ({ + ok: true, + file: { path, content: path, encoding: 'utf-8', updated_at: '', version: 'v1' }, + })); + vi.mocked(api.openWorkspacePath).mockReturnValue(revealing.promise); + render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + fireEvent.click(await screen.findByRole('button', { name: 'Reveal selected file' })); + fireEvent.click(screen.getByRole('option', { name: /second/i })); + expect(await screen.findByRole('button', { name: 'Reveal selected file' })).not.toBeDisabled(); + await act(async () => revealing.resolve({ ok: true, path: file.path } as never)); + expect(screen.queryByText('File revealed in workspace.')).toBeNull(); + }); + + it('uses one shared responsive resource pane across the five rail pages', () => { + for (const page of [ + 'CharactersPage', + 'FilesPage', + 'ScenesPage', + 'ForeshadowingPage', + 'SecretsPage', + ]) { + const source = readFileSync(join(process.cwd(), `src/pages/${page}.tsx`), 'utf8'); + expect(source).toContain('ResponsivePane'); + } + }); + + it('gives every responsive rail page an explicit positioning context', () => { + for (const page of [ + 'CharactersPage', + 'FilesPage', + 'ScenesPage', + 'ForeshadowingPage', + 'SecretsPage', + ]) { + const css = readFileSync(join(process.cwd(), `src/pages/${page}.module.css`), 'utf8'); + expect(css, page).toMatch(/\.(?:workspace|page)\s*\{[^}]*position:\s*relative/s); + } + const paneCss = readFileSync( + join(process.cwd(), 'src/components/layout/ResponsivePane.module.css'), + 'utf8', + ); + expect(paneCss).toMatch(/\.pane\s*\{[^}]*overflow:\s*auto/s); + }); + + it('keeps all page styles on approved global palette tokens', () => { + const pagesDir = join(process.cwd(), 'src/pages'); + const files = readdirSync(pagesDir).filter((name) => name.endsWith('.module.css')); + const allowed = new Set(['#06266f', '#ffffff']); + for (const name of files) { + const css = readFileSync(join(pagesDir, name), 'utf8'); + const colors = css.match(/#[0-9a-f]{3,8}\b/gi) ?? []; + expect( + colors.filter((color) => !allowed.has(color.toLowerCase())), + name, + ).toEqual([]); + if (css.includes('{')) + expect(css, name).toMatch(/var\(--(?:brand|surface|border|ink|muted|ruby|green|amber)/); + } + }); + + it('ignores a pending save completion after unmount', async () => { + const saving = deferred>>(); + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: file.path, + content: 'Old', + encoding: 'utf-8', + updated_at: file.updated_at, + version: 'v1', + }, + }); + vi.mocked(api.saveWorkspaceFile).mockReturnValue(saving.promise); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /draft/i })); + fireEvent.change(await screen.findByRole('textbox', { name: 'File content' }), { + target: { value: 'Local' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save file' })); + view.unmount(); + await act(async () => + saving.resolve({ ok: true, file: { path: file.path, updated_at: 'now', version: 'v2' } }), + ); + expect(screen.queryByRole('textbox', { name: 'File content' })).toBeNull(); + }); + + it('keeps the file shell and workspace actions visible for a real empty list', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/empty-story', + files: [], + }); + render(); + expect(await screen.findByRole('heading', { name: 'Files' })).toBeDefined(); + expect(screen.getByText('C:/empty-story')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Refresh files' })).toBeDefined(); + expect(screen.getByLabelText('File type')).toBeDefined(); + expect(screen.getByLabelText('Search files')).toBeDefined(); + expect(screen.getByText('No files in this view.')).toBeDefined(); + }); + + it('keeps filters available when a type has no matching files', async () => { + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ + ok: true, + root: 'C:/story', + files: [file], + }); + render(); + await screen.findByRole('option', { name: /draft/i }); + fireEvent.change(screen.getByLabelText('File type'), { target: { value: 'text' } }); + expect(await screen.findByText('No files in this view.')).toBeDefined(); + fireEvent.change(screen.getByLabelText('File type'), { target: { value: 'all' } }); + expect(await screen.findByRole('option', { name: /draft/i })).toBeDefined(); + }); +}); + +describe('Foreshadowing page', () => { + it('keys relation chips by relation type and target id', () => { + const source = readFileSync(join(process.cwd(), 'src/pages/ForeshadowingPage.tsx'), 'utf8'); + expect(source).toMatch(/key=\{`\$\{position\.type\}:\$\{position\.id\}`\}/); + }); + it('derives overdue only when planned and current chapter positions both exist', () => { + expect( + deriveForeshadowingStatus({ status: 'open', plannedPosition: 2, currentPosition: null }), + ).toBeNull(); + expect( + deriveForeshadowingStatus({ status: 'open', plannedPosition: 2, currentPosition: 4 }), + ).toBe('overdue'); + expect( + deriveForeshadowingStatus({ status: 'open', plannedPosition: 5, currentPosition: 4 }), + ).toBeNull(); + }); + + it('filters real records and keeps selection safe after deletion', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listForeshadowing) + .mockResolvedValueOnce({ + ok: true, + items: [ + { id: 'f1', content: 'Silver bell', status: 'open' }, + { id: 'f2', content: 'Broken seal', status: 'paid' }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + items: [{ id: 'f2', content: 'Broken seal', status: 'paid' }], + }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + vi.mocked(api.deleteForeshadowing).mockResolvedValue({ ok: true }); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Silver bell/ })); + fireEvent.click(screen.getByRole('button', { name: 'Delete foreshadowing' })); + + await waitFor(() => expect(api.deleteForeshadowing).toHaveBeenCalledWith('world-1', 'f1')); + expect(screen.getByRole('option', { name: /Broken seal/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('selects only visible records when the status filter changes', async () => { + vi.mocked(api.listForeshadowing).mockResolvedValue({ + ok: true, + items: [ + { id: 'f1', content: 'Silver bell', status: 'open' }, + { id: 'f2', content: 'Broken seal', status: 'paid' }, + ], + }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + render(); + fireEvent.click(await screen.findByRole('option', { name: /Silver bell/ })); + + fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'paid' } }); + + expect(screen.getByRole('option', { name: /Broken seal/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('heading', { name: 'Broken seal' })).toBeDefined(); + }); + + it('resolves real planted and paid narrative IDs and retains the list when context fails', async () => { + vi.mocked(api.listForeshadowing).mockResolvedValue({ + ok: true, + items: [ + { + id: 'f1', + content: 'Silver bell', + status: 'paid', + planted_at: 'scene-1', + paid_at: 'chapter-2', + }, + ], + }); + vi.mocked(api.listChapters).mockResolvedValue({ + ok: true, + chapters: [{ id: 'chapter-2', title: 'Reckoning', status: 'draft' }], + }); + vi.mocked(api.listScenes).mockResolvedValue({ + ok: true, + scenes: [{ id: 'scene-1', title: 'The warning', status: 'planned', chapter_id: 'chapter-1' }], + }); + render(); + fireEvent.click(await screen.findByRole('option', { name: /Silver bell/ })); + const positions = screen.getByRole('heading', { name: 'Narrative positions' }).parentElement!; + expect(within(positions).getByText('The warning')).toBeDefined(); + expect(within(positions).getByText('Reckoning')).toBeDefined(); + + vi.mocked(api.listChapters).mockRejectedValue(new Error('Chapters unavailable')); + vi.mocked(api.listScenes).mockRejectedValue(new Error('Scenes unavailable')); + fireEvent.click(screen.getByRole('button', { name: 'Refresh foreshadowing' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Chapters unavailable'); + expect(screen.getByRole('option', { name: /Silver bell/ })).toBeDefined(); + }); + + it('ignores a completed delete after switching worlds and disables duplicate mutation', async () => { + const deletion = deferred<{ ok: boolean }>(); + vi.mocked(api.deleteForeshadowing).mockClear(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listForeshadowing).mockImplementation((worldId) => + Promise.resolve({ + ok: true, + items: [ + { + id: 'shared', + content: worldId === 'world-1' ? 'Old thread' : 'New thread', + status: 'open', + }, + ], + }), + ); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + vi.mocked(api.deleteForeshadowing).mockReturnValue(deletion.promise); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /Old thread/ })); + const deleteButton = screen.getByRole('button', { name: 'Delete foreshadowing' }); + fireEvent.click(deleteButton); + expect(deleteButton).toBeDisabled(); + fireEvent.click(deleteButton); + expect(api.deleteForeshadowing).toHaveBeenCalledTimes(1); + + view.rerender(); + expect(await screen.findByRole('option', { name: /New thread/ })).toBeDefined(); + await act(async () => deletion.resolve({ ok: true })); + expect(screen.getByRole('option', { name: /New thread/ })).toBeDefined(); + }); + + it('does not let an A-B-A delete completion clear the new world generation lock', async () => { + const oldDeletion = deferred<{ ok: boolean }>(); + const newDeletion = deferred<{ ok: boolean }>(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listForeshadowing).mockImplementation((worldId) => + Promise.resolve({ + ok: true, + items: [ + { + id: 'shared', + content: worldId === 'world-a' ? 'A thread' : 'B thread', + status: 'open', + }, + ], + }), + ); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.deleteForeshadowing) + .mockReset() + .mockReturnValueOnce(oldDeletion.promise) + .mockReturnValueOnce(newDeletion.promise); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /A thread/ })); + fireEvent.click(screen.getByRole('button', { name: 'Delete foreshadowing' })); + view.rerender(); + await screen.findByRole('option', { name: /B thread/ }); + view.rerender(); + fireEvent.click(await screen.findByRole('option', { name: /A thread/ })); + fireEvent.click(screen.getByRole('button', { name: 'Delete foreshadowing' })); + + await act(async () => oldDeletion.resolve({ ok: true })); + + expect(screen.getByRole('button', { name: 'Delete foreshadowing' })).toBeDisabled(); + expect(screen.getByRole('option', { name: /A thread/ })).toBeDefined(); + await act(async () => newDeletion.resolve({ ok: true })); + }); + + it('keeps the committed world mutation valid when a different world render is suspended', async () => { + const deletion = deferred<{ ok: boolean }>(); + const suspended = new Promise(() => {}); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listForeshadowing).mockResolvedValue({ + ok: true, + items: [{ id: 'f1', content: 'Committed thread', status: 'open' }], + }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.deleteForeshadowing).mockReset().mockReturnValue(deletion.promise); + const SuspendRender = ({ active }: { active: boolean }) => { + if (active) throw suspended; + return null; + }; + const page = (worldId: string, suspend: boolean) => ( + Suspended

}> + + +
+ ); + const view = render(page('world-a', false)); + fireEvent.click(await screen.findByRole('option', { name: /Committed thread/ })); + fireEvent.click(screen.getByRole('button', { name: 'Delete foreshadowing' })); + + act(() => { + startTransition(() => view.rerender(page('world-b', true))); + }); + await act(async () => deletion.reject(new Error('Delete failed'))); + + expect(screen.getByRole('alert')).toHaveTextContent('Delete failed'); + expect(screen.getByRole('button', { name: 'Delete foreshadowing' })).not.toBeDisabled(); + }); + + it('closes the create mutation when the world changes', async () => { + vi.mocked(api.listForeshadowing).mockResolvedValue({ ok: true, items: [] }); + const view = render( + + + , + ); + await screen.findByText('No foreshadowing matches this filter.'); + fireEvent.click(screen.getByRole('button', { name: 'Create foreshadowing' })); + expect(screen.getByRole('dialog')).toBeDefined(); + + view.rerender( + + + , + ); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('does not let an A-B-A secret delete completion clear the new generation lock', async () => { + const oldDeletion = deferred<{ ok: boolean }>(); + const newDeletion = deferred<{ ok: boolean }>(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listSecrets).mockImplementation((worldId) => + Promise.resolve({ + ok: true, + items: [{ id: 'shared', title: `${worldId} secret`, status: 'active' }], + }), + ); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + vi.mocked(api.deleteSecret) + .mockReset() + .mockReturnValueOnce(oldDeletion.promise) + .mockReturnValueOnce(newDeletion.promise); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /world-a secret/ })); + fireEvent.click(screen.getByRole('button', { name: 'Delete secret' })); + view.rerender(); + await screen.findByRole('option', { name: /world-b secret/ }); + view.rerender(); + fireEvent.click(await screen.findByRole('option', { name: /world-a secret/ })); + fireEvent.click(screen.getByRole('button', { name: 'Delete secret' })); + + await act(async () => oldDeletion.resolve({ ok: true })); + + expect(screen.getByRole('button', { name: 'Delete secret' })).toBeDisabled(); + expect(screen.getByRole('option', { name: /world-a secret/ })).toBeDefined(); + await act(async () => newDeletion.resolve({ ok: true })); + }); +}); + +describe('Task 10 create mutation lifetime', () => { + it.each([ + ['foreshadowing', CreateForeshadowingModal, 'createForeshadowing'], + ['secret', CreateSecretModal, 'createSecret'], + ] as const)('ignores %s callbacks after unmount', async (_name, Modal, method) => { + const creation = deferred<{ ok: boolean }>(); + const onCreated = vi.fn(); + const onClose = vi.fn(); + vi.mocked(api[method]).mockReturnValue(creation.promise as never); + const view = render( + + + , + ); + const field = view.container.querySelector('textarea, input') as + | HTMLInputElement + | HTMLTextAreaElement; + fireEvent.change(field, { target: { value: 'Required value' } }); + const submit = Array.from(view.container.querySelectorAll('button')).at( + -1, + ) as HTMLButtonElement; + fireEvent.click(submit); + view.unmount(); + + await act(async () => creation.resolve({ ok: true })); + + expect(onCreated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it.each([ + ['foreshadowing', CreateForeshadowingModal, 'createForeshadowing'], + ['secret', CreateSecretModal, 'createSecret'], + ] as const)( + 'ignores stale %s callbacks after an A-B-A world change', + async (_name, Modal, method) => { + const creation = deferred<{ ok: boolean }>(); + const onCreated = vi.fn(); + const onClose = vi.fn(); + vi.mocked(api[method]).mockReturnValue(creation.promise as never); + const renderModal = (worldId: string) => ( + + + + ); + const view = render(renderModal('world-a')); + const field = view.container.querySelector('textarea, input') as + | HTMLInputElement + | HTMLTextAreaElement; + fireEvent.change(field, { target: { value: 'Required value' } }); + fireEvent.click( + Array.from(view.container.querySelectorAll('button')).at(-1) as HTMLButtonElement, + ); + view.rerender(renderModal('world-b')); + view.rerender(renderModal('world-a')); + + await act(async () => creation.resolve({ ok: true })); + + expect(onCreated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['foreshadowing', CreateForeshadowingModal, 'createForeshadowing'], + ['secret', CreateSecretModal, 'createSecret'], + ] as const)( + 'keeps the committed %s creation valid when another world render is suspended', + async (_name, Modal, method) => { + const creation = deferred<{ ok: boolean }>(); + const suspended = new Promise(() => {}); + vi.mocked(api[method]).mockReturnValue(creation.promise as never); + const SuspendRender = ({ active }: { active: boolean }) => { + if (active) throw suspended; + return null; + }; + const renderModal = (worldId: string, suspend: boolean) => ( + + Suspended

}> + + +
+
+ ); + const view = render(renderModal('world-a', false)); + const field = view.container.querySelector('textarea, input') as + | HTMLInputElement + | HTMLTextAreaElement; + fireEvent.change(field, { target: { value: 'Required value' } }); + fireEvent.click( + Array.from(view.container.querySelectorAll('button')).at(-1) as HTMLButtonElement, + ); + + act(() => { + startTransition(() => view.rerender(renderModal('world-b', true))); + }); + await act(async () => creation.reject(new Error('Create failed'))); + + expect(screen.getByText('Create failed')).toBeDefined(); + expect(Array.from(view.container.querySelectorAll('button')).at(-1)).not.toBeDisabled(); + }, + ); +}); + +describe('Secrets page', () => { + const secrets = { + ok: true, + items: [ + { + id: 's1', + title: 'The pact', + truth: 'The crown is counterfeit', + status: 'active', + aware_character_ids: ['a1', 'missing'], + }, + { id: 's2', title: 'The oath', truth: 'The oath was staged', status: 'exposed' }, + ], + }; + + it('keeps truth absent from the DOM until explicit reveal and hides it on selection and world changes', async () => { + vi.mocked(api.listSecrets).mockResolvedValue(secrets); + vi.mocked(api.listForeshadowing).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ + ok: true, + agents: [{ id: 'a1', name: 'Mira', display_name: 'Mira', kind: 'character' }], + }); + + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /The pact/ })); + expect(screen.queryByText('The crown is counterfeit')).toBeNull(); + expect(screen.queryByText('missing')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Reveal truth' })); + expect(screen.getByText('The crown is counterfeit')).toBeDefined(); + fireEvent.click(screen.getByRole('option', { name: /The oath/ })); + expect(screen.queryByText('The crown is counterfeit')).toBeNull(); + expect(screen.queryByText('The oath was staged')).toBeNull(); + view.rerender(); + expect(screen.queryByText('The oath was staged')).toBeNull(); + }); + + it('retains the secret list when relationship context is partially unavailable', async () => { + vi.mocked(api.listSecrets).mockResolvedValue(secrets); + vi.mocked(api.listForeshadowing).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockRejectedValue(new Error('Characters unavailable')); + + render(); + + expect(await screen.findByRole('option', { name: /The pact/ })).toBeDefined(); + expect(screen.getByRole('alert')).toHaveTextContent('Characters unavailable'); + }); + + it('uses the exposed status and selects the first visible filtered secret', async () => { + vi.mocked(api.listSecrets).mockResolvedValue(secrets); + vi.mocked(api.listForeshadowing).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + render(); + fireEvent.click(await screen.findByRole('option', { name: /The pact/ })); + + fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'exposed' } }); + + expect(screen.getByRole('option', { name: /The oath/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('heading', { name: 'The oath' })).toBeDefined(); + }); + + it('keeps revealed truth scoped to its secret across rerenders', async () => { + vi.mocked(api.listSecrets).mockResolvedValue(secrets); + vi.mocked(api.listForeshadowing).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: [] }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /The pact/ })); + fireEvent.click(screen.getByRole('button', { name: 'Reveal truth' })); + expect(screen.getByText('The crown is counterfeit')).toBeDefined(); + + view.rerender(); + + expect(screen.queryByText('The crown is counterfeit')).toBeNull(); + expect(screen.queryByText('The oath was staged')).toBeNull(); + }); + + it('closes the create mutation when the world changes', async () => { + vi.mocked(api.listSecrets).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + const view = render( + + + , + ); + await screen.findByText('No secrets match this filter.'); + fireEvent.click(screen.getByRole('button', { name: 'Create secret' })); + expect(screen.getByRole('dialog')).toBeDefined(); + + view.rerender( + + + , + ); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); + +describe('Foreshadowing and Secrets responsive layout', () => { + it.each(['ForeshadowingPage.module.css', 'SecretsPage.module.css'])( + '%s owns scrolling when context stacks', + (file) => { + const css = readFileSync(join(process.cwd(), 'src/pages', file), 'utf8'); + const responsive = css.slice( + css.indexOf('@media (max-width: 1100px)'), + css.indexOf('@media (max-width: 700px)'), + ); + expect(css).toMatch( + /\.workspace\s*\{[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*auto/s, + ); + expect(responsive).toMatch(/\.workspace\s*\{[^}]*align-content:\s*start/s); + expect(responsive).not.toMatch(/\.contextPane\s*\{[^}]*display:\s*none/s); + }, + ); +}); + +vi.mock('../api/worldbuilding', () => ({ + worldbuildingApi: { + getDashboard: vi.fn(), + listLocations: vi.fn(), + listKnowledge: vi.fn(), + listFactions: vi.fn(), + getTimeline: vi.fn(), + listGraphEntities: vi.fn(), + reorderChapters: vi.fn(), + }, +})); + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function ResourceHarness({ + resourceKey, + loader, + onRender, +}: { + resourceKey: string; + loader: (signal: AbortSignal) => Promise; + onRender?: (key: string, data: string | null) => void; +}) { + const resource = useResource(resourceKey, loader); + onRender?.(resourceKey, resource.data); + return ( +
+ {resource.status} + {resource.data ?? 'none'} + {resource.error?.message ?? 'none'} + +
+ ); +} + +describe('useResource', () => { + it('does not expose data from the previous key during the new key render', async () => { + const renders: Array<{ key: string; data: string | null }> = []; + const loader = vi + .fn() + .mockResolvedValueOnce('first data') + .mockReturnValue(new Promise(() => {})); + const onRender = (key: string, data: string | null) => renders.push({ key, data }); + const view = render( + , + ); + + await waitFor(() => expect(screen.getByLabelText('data')).toHaveTextContent('first data')); + const renderCountBeforeKeyChange = renders.length; + view.rerender(); + + expect(renders[renderCountBeforeKeyChange]).toEqual({ key: 'second', data: null }); + }); + + it('aborts the previous request when the resource key changes', async () => { + const first = deferred(); + const second = deferred(); + const signals: AbortSignal[] = []; + const loader = vi.fn((signal: AbortSignal) => { + signals.push(signal); + return signals.length === 1 ? first.promise : second.promise; + }); + const view = render(); + + await waitFor(() => expect(loader).toHaveBeenCalledTimes(1)); + view.rerender(); + + await waitFor(() => expect(loader).toHaveBeenCalledTimes(2)); + expect(signals[0].aborted).toBe(true); + expect(screen.getByLabelText('data')).toHaveTextContent('none'); + + await act(async () => second.resolve('second data')); + expect(screen.getByLabelText('data')).toHaveTextContent('second data'); + }); + + it('retains successful data when a retry fails', async () => { + const retryRequest = deferred(); + const loader = vi + .fn<(signal: AbortSignal) => Promise>() + .mockResolvedValueOnce('retained data') + .mockImplementationOnce(() => retryRequest.promise); + + render(); + await waitFor(() => expect(screen.getByLabelText('status')).toHaveTextContent('ready')); + + fireEvent.click(screen.getByRole('button', { name: 'Retry resource' })); + expect(screen.getByLabelText('status')).toHaveTextContent('loading'); + expect(screen.getByLabelText('data')).toHaveTextContent('retained data'); + + await act(async () => retryRequest.reject(new Error('Refresh failed'))); + expect(screen.getByLabelText('status')).toHaveTextContent('error'); + expect(screen.getByLabelText('data')).toHaveTextContent('retained data'); + expect(screen.getByLabelText('error')).toHaveTextContent('Refresh failed'); + }); + + it('ignores an aborted request rejection', async () => { + const request = deferred(); + render( request.promise} />); + + await act(async () => request.reject(new DOMException('Aborted', 'AbortError'))); + + expect(screen.getByLabelText('status')).toHaveTextContent('loading'); + expect(screen.getByLabelText('error')).toHaveTextContent('none'); + }); +}); + +describe('PageState', () => { + it('shows a non-blocking warning while retaining stale content after refresh failure', () => { + const retry = vi.fn(); + render( + +

Retained content

+
, + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Refresh failed'); + expect(screen.getByText('Retained content')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(retry).toHaveBeenCalledOnce(); + }); + + it('renders loading, full error, and empty states without page-specific copy', () => { + const { rerender } = render(); + expect(screen.getByRole('status')).toHaveAccessibleName('Loading worlds'); + + rerender( {}} />); + expect(screen.getByRole('alert')).toHaveTextContent('Worlds unavailable'); + + rerender(); + expect(screen.getByRole('heading', { name: 'No worlds' })).toBeDefined(); + expect(screen.getByText('Create one to begin.')).toBeDefined(); + }); +}); + +const chapterFixtures = [ + { + id: 'chapter-1', + title: 'Ashes at Dawn', + number: 1, + status: 'completed', + scene_count: 2, + updated_at: '2026-06-18T08:00:00Z', + }, + { + id: 'chapter-2', + title: 'The Rain Archive', + number: 2, + status: 'draft', + scene_count: 1, + updated_at: '2026-06-19T08:00:00Z', + }, +]; + +const sceneFixtures = [ + { + id: 'scene-1', + title: 'Crossing the flooded stacks', + chapter_id: 'chapter-2', + world_time: 'Day 4, dusk', + status: 'writing', + participant_ids: ['lin', 'sora'], + updated_at: '2026-06-19T09:00:00Z', + }, +]; + +describe('Chapter and scene pages', () => { + it('moves chapters with keyboard-accessible controls and sends the exact ordered IDs', async () => { + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(worldbuildingApi.reorderChapters).mockResolvedValue({ ok: true }); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Move The Rain Archive previous' })); + + await waitFor(() => + expect(worldbuildingApi.reorderChapters).toHaveBeenCalledWith('world-1', [ + 'chapter-2', + 'chapter-1', + ]), + ); + expect( + screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent), + ).toEqual(['The Rain Archive', 'Ashes at Dawn']); + }); + + it('disables chapter moves while a reorder request is pending', async () => { + const reorder = deferred<{ ok: boolean }>(); + vi.mocked(worldbuildingApi.reorderChapters).mockClear(); + vi.mocked(api.listChapters).mockClear(); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(worldbuildingApi.reorderChapters).mockReturnValue(reorder.promise); + + render( + + + , + ); + const movePrevious = await screen.findByRole('button', { + name: 'Move The Rain Archive previous', + }); + fireEvent.click(movePrevious); + + const moveNext = screen.getByRole('button', { name: 'Move The Rain Archive next' }); + const refresh = screen.getByRole('button', { name: 'Refresh' }); + expect(moveNext).toBeDisabled(); + expect(refresh).toBeDisabled(); + expect(screen.getAllByRole('heading', { level: 2 }).map((item) => item.textContent)).toEqual([ + 'The Rain Archive', + 'Ashes at Dawn', + ]); + fireEvent.click(refresh); + fireEvent.click(moveNext); + expect(worldbuildingApi.reorderChapters).toHaveBeenCalledTimes(1); + expect(api.listChapters).toHaveBeenCalledTimes(1); + + await act(async () => reorder.resolve({ ok: true })); + await waitFor(() => expect(moveNext).not.toBeDisabled()); + expect(refresh).not.toBeDisabled(); + }); + + it('reconciles local chapter order with a fresh server response', async () => { + vi.mocked(api.listChapters) + .mockResolvedValueOnce({ ok: true, chapters: chapterFixtures }) + .mockResolvedValueOnce({ + ok: true, + chapters: [ + { ...chapterFixtures[1], title: 'Rain Archive Revised', status: 'completed' }, + { + id: 'chapter-3', + title: 'A New Signal', + number: 3, + status: 'draft', + scene_count: 0, + updated_at: '2026-06-20T08:00:00Z', + }, + ], + }); + vi.mocked(worldbuildingApi.reorderChapters).mockResolvedValue({ ok: true }); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Move The Rain Archive previous' })); + const refresh = screen.getByRole('button', { name: 'Refresh' }); + await waitFor(() => expect(refresh).not.toBeDisabled()); + fireEvent.click(refresh); + + expect(await screen.findByRole('heading', { name: 'Rain Archive Revised' })).toBeDefined(); + expect(screen.getByRole('heading', { name: 'A New Signal' })).toBeDefined(); + expect(screen.queryByRole('heading', { name: 'Ashes at Dawn' })).toBeNull(); + }); + + it.each([ + ['Chapters', ChaptersPage, 'Loading chapters', 'No chapters yet', 'Chapter list unavailable'], + ['Scenes', ScenesPage, 'Loading scenes', 'No scenes yet', 'Scene list unavailable'], + ] as const)( + 'renders %s loading, empty, and error states', + async (_name, Page, loading, empty, error) => { + const list = _name === 'Chapters' ? api.listChapters : api.listScenes; + const pending = deferred(); + vi.mocked(list as typeof api.listChapters).mockReturnValueOnce(pending.promise); + const { unmount } = render( + + + , + ); + expect(screen.getByRole('status', { name: loading })).toBeDefined(); + unmount(); + + if (_name === 'Chapters') { + vi.mocked(api.listChapters).mockResolvedValueOnce({ ok: true, chapters: [] }); + } else { + vi.mocked(api.listScenes).mockResolvedValueOnce({ ok: true, scenes: [] }); + } + const emptyRender = render( + + + , + ); + expect(await screen.findByRole('heading', { name: empty })).toBeDefined(); + emptyRender.unmount(); + + vi.mocked(list as typeof api.listChapters).mockRejectedValueOnce(new Error(error)); + render( + + + , + ); + expect(await screen.findByRole('alert')).toHaveTextContent(error); + }, + ); + + it('opens the selected chapter in the reusable editor', async () => { + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: 'chapters/world-1/chapter-2.md', + content: 'Rain pressed against the archive glass.', + encoding: 'utf-8', + updated_at: '2026-06-19T09:00:00Z', + version: 'v1', + }, + }); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Edit The Rain Archive' })); + + expect(await screen.findByRole('textbox', { name: 'Chapter content' })).toHaveValue( + 'Rain pressed against the archive glass.', + ); + }); + + it('keeps chapter editor dirty state and sends the loaded version when saving', async () => { + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: 'chapters/world-1/chapter-2.md', + content: 'Original draft.', + encoding: 'utf-8', + updated_at: '2026-06-19T09:00:00Z', + version: 'v7', + }, + }); + vi.mocked(api.saveWorkspaceFile).mockRejectedValue(new Error('Conflict: file changed on disk')); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Edit The Rain Archive' })); + const editor = await screen.findByRole('textbox', { name: 'Chapter content' }); + fireEvent.change(editor, { target: { value: 'Revised draft.' } }); + expect(screen.getByText('Unsaved changes')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter' })); + + await waitFor(() => + expect(api.saveWorkspaceFile).toHaveBeenCalledWith( + 'chapters/world-1/chapter-2.md', + 'Revised draft.', + 'v7', + ), + ); + expect(await screen.findByRole('alert')).toHaveTextContent('Conflict: file changed on disk'); + expect(screen.getByText('Unsaved changes')).toBeDefined(); + }); + + it('keeps the dirty chapter selected when switching is cancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: 'chapters/world-1/chapter-2.md', + content: 'Original draft.', + encoding: 'utf-8', + updated_at: '2026-06-19T09:00:00Z', + version: 'v7', + }, + }); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Edit The Rain Archive' })); + const editor = await screen.findByRole('textbox', { name: 'Chapter content' }); + fireEvent.change(editor, { target: { value: 'Unsaved rain.' } }); + fireEvent.click(screen.getByRole('button', { name: 'Edit Ashes at Dawn' })); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(screen.getByRole('region', { name: 'Editing The Rain Archive' })).toBeDefined(); + expect(editor).toHaveValue('Unsaved rain.'); + }); + + it('blocks chapter switching without confirmation while a save is pending', async () => { + const saving = deferred>>(); + const confirm = vi.spyOn(window, 'confirm'); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: 'chapters/world-1/chapter-2.md', + content: 'Original draft.', + encoding: 'utf-8', + updated_at: '2026-06-19T09:00:00Z', + version: 'v7', + }, + }); + vi.mocked(api.saveWorkspaceFile).mockReturnValue(saving.promise); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Edit The Rain Archive' })); + const editor = await screen.findByRole('textbox', { name: 'Chapter content' }); + fireEvent.change(editor, { target: { value: 'Saving rain.' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter' })); + expect(screen.getByRole('button', { name: 'Save chapter' })).toHaveTextContent('Saving...'); + fireEvent.click(screen.getByRole('button', { name: 'Edit Ashes at Dawn' })); + + expect(confirm).not.toHaveBeenCalled(); + expect(screen.getByRole('region', { name: 'Editing The Rain Archive' })).toBeDefined(); + expect(editor).toHaveValue('Saving rain.'); + + await act(async () => + saving.resolve({ + ok: true, + file: { path: 'chapters/world-1/chapter-2.md', updated_at: 'now', version: 'v8' }, + }), + ); + }); + + it('commits a successful file version before reporting a title metadata failure', async () => { + vi.mocked(api.saveWorkspaceFile).mockClear(); + vi.mocked(api.listChapters).mockResolvedValue({ ok: true, chapters: chapterFixtures }); + vi.mocked(api.readWorkspaceFile).mockResolvedValue({ + ok: true, + file: { + path: 'chapters/world-1/chapter-2.md', + content: 'Original draft.', + encoding: 'utf-8', + updated_at: '2026-06-19T09:00:00Z', + version: 'v7', + }, + }); + vi.mocked(api.saveWorkspaceFile) + .mockResolvedValueOnce({ + ok: true, + file: { path: 'chapters/world-1/chapter-2.md', updated_at: 'now', version: 'v8' }, + }) + .mockResolvedValueOnce({ + ok: true, + file: { path: 'chapters/world-1/chapter-2.md', updated_at: 'later', version: 'v9' }, + }); + vi.mocked(api.patchChapter) + .mockRejectedValueOnce(new Error('Title service unavailable')) + .mockResolvedValueOnce({ ok: true }); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Edit The Rain Archive' })); + fireEvent.change(await screen.findByRole('textbox', { name: 'Chapter content' }), { + target: { value: 'Saved prose.' }, + }); + fireEvent.change(screen.getByRole('textbox', { name: 'Chapter title' }), { + target: { value: 'New title' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Chapter text saved, but title update failed: Title service unavailable', + ); + fireEvent.click(screen.getByRole('button', { name: 'Save chapter' })); + await waitFor(() => expect(api.saveWorkspaceFile).toHaveBeenCalledTimes(2)); + expect(api.saveWorkspaceFile).toHaveBeenLastCalledWith( + 'chapters/world-1/chapter-2.md', + 'Saved prose.', + 'v8', + ); + }); + + it('renders only actual scene fields and omits unsupported consistency content', async () => { + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: sceneFixtures }); + render(); + + fireEvent.click(await screen.findByRole('option', { name: /Crossing the flooded stacks/ })); + expect(screen.getByText('Day 4, dusk')).toBeDefined(); + expect(screen.getByText('lin, sora')).toBeDefined(); + expect(screen.queryByText(/consistency/i)).toBeNull(); + }); + + it('lets the chapter editor toolbar wrap at narrow widths without clipping actions', () => { + const css = readFileSync( + join(process.cwd(), 'src/components/ChapterEditor.module.css'), + 'utf8', + ); + expect(css).toMatch(/\.container\s*\{[^}]*min-width:\s*0/s); + expect(css).toMatch(/\.toolbar\s*\{[^}]*flex-wrap:\s*wrap/s); + expect(css).toMatch(/\.toolbarRight\s*\{[^}]*overflow-x:\s*auto/s); + }); +}); + +describe('ResourceList', () => { + const items = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ]; + + function SelectableList({ initialId = 'a' }: { initialId?: string }) { + const [selectedId, setSelectedId] = useState(initialId); + return ( + item.id} + renderItem={(item) => item.name} + onSelect={setSelectedId} + /> + ); + } + + it('scrolls keyboard-selected options into view', () => { + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + + render(); + scrollIntoView.mockClear(); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'End' }); + + expect(screen.getByRole('option', { name: 'B' })).toHaveAttribute('aria-selected', 'true'); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); + + scrollIntoView.mockClear(); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Home' }); + expect(screen.getByRole('option', { name: 'A' })).toHaveAttribute('aria-selected', 'true'); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); + + scrollIntoView.mockClear(); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); + expect(screen.getByRole('option', { name: 'B' })).toHaveAttribute('aria-selected', 'true'); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); + + delete HTMLElement.prototype.scrollIntoView; + }); + + it('focuses the listbox when an option is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('option', { name: 'B' })); + + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveFocus(); + fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + expect(screen.getByRole('option', { name: 'A' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('selects the next item with the keyboard and exposes the selected option', () => { + const onSelect = vi.fn(); + render( + item.id} + renderItem={(item) => item.name} + onSelect={onSelect} + />, + ); + + const listbox = screen.getByRole('listbox', { name: 'Characters' }); + expect(screen.getByRole('option', { name: 'A' })).toHaveAttribute('aria-selected', 'true'); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(onSelect).toHaveBeenCalledWith('b'); + }); + + it('supports boundary navigation and mouse selection', () => { + const onSelect = vi.fn(); + render( + item.id} + renderItem={(item) => item.name} + onSelect={onSelect} + />, + ); + + const listbox = screen.getByRole('listbox'); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(onSelect).toHaveBeenLastCalledWith('b'); + fireEvent.keyDown(listbox, { key: 'Home' }); + expect(onSelect).toHaveBeenLastCalledWith('a'); + fireEvent.click(screen.getByRole('option', { name: 'B' })); + expect(onSelect).toHaveBeenLastCalledWith('b'); + }); +}); + +describe('DetailPane', () => { + it('provides a labeled detail region with actions and an optional inspector', () => { + render( + Edit} + inspector={

Related chapters

} + > +

Character details

+
, + ); + + expect(screen.getByRole('region', { name: 'Lian' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Edit' })).toBeDefined(); + expect(screen.getByRole('complementary')).toHaveTextContent('Related chapters'); + }); + + it('keeps a collapsed inspector reachable and restores trigger focus after Escape', () => { + render( + Related chapters

} inspectorLabel="Relations"> +

Character details

+
, + ); + + const toggle = screen.getByRole('button', { name: 'Hide Relations' }); + fireEvent.click(toggle); + expect(screen.queryByRole('complementary', { name: 'Relations' })).toBeNull(); + expect(toggle).toHaveAccessibleName('Show Relations'); + fireEvent.click(toggle); + const inspector = screen.getByRole('complementary', { name: 'Relations' }); + fireEvent.keyDown(inspector, { key: 'Escape' }); + expect(screen.queryByRole('complementary', { name: 'Relations' })).toBeNull(); + expect(toggle).toHaveFocus(); + }); + + it('traps Tab in a compact inspector', () => { + vi.stubGlobal('matchMedia', () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + render( + Last control} + inspectorLabel="Relations" + > +

Details

+
, + ); + fireEvent.click(screen.getByRole('button', { name: 'Show Relations' })); + const dialog = screen.getByRole('dialog', { name: 'Relations' }); + const controls = within(dialog).getAllByRole('button'); + controls.at(-1)?.focus(); + fireEvent.keyDown(document, { key: 'Tab' }); + expect(controls[0]).toHaveFocus(); + controls[0].focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(controls.at(-1)).toHaveFocus(); + vi.unstubAllGlobals(); + }); +}); + +describe('ResponsivePane', () => { + it('opens as a compact dialog, moves focus, closes on Escape, and restores focus', () => { + vi.stubGlobal('matchMedia', () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Open Story resources' }); + fireEvent.click(trigger); + const dialog = screen.getByRole('dialog', { name: 'Story resources' }); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Close Story resources' })).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog', { name: 'Story resources' })).toBeNull(); + expect(trigger).toHaveFocus(); + vi.unstubAllGlobals(); + }); + + it('returns focus to its trigger when selection closes the compact pane', () => { + vi.stubGlobal('matchMedia', () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + render( + +
+ Selected resource +
+
, + ); + const trigger = screen.getByRole('button', { name: 'Open Resources' }); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole('option')); + expect(screen.queryByRole('dialog', { name: 'Resources' })).toBeNull(); + expect(trigger).toHaveFocus(); + vi.unstubAllGlobals(); + }); +}); + +describe('Overview selectors', () => { + it('derives counts from real response-shaped resource fixtures', () => { + expect( + selectWorldMetrics({ + agents: [{ id: 'a' }], + chapters: [{ id: 'c', status: 'completed' }], + scenes: [], + files: [], + overview: null, + dashboard: null, + }), + ).toMatchObject({ characterCount: 1, chapterCount: 1, completedChapterCount: 1 }); + }); + + it('uses dashboard aggregates when available and derives missing values', () => { + expect( + selectWorldMetrics({ + agents: [{ id: 'a' }], + chapters: [{ id: 'c', status: 'drafting' }], + scenes: [{ id: 's', status: 'completed' }], + files: [{ id: 'f' }], + overview: null, + dashboard: { + agents: { total: 4 }, + chapters: { total: 3, completed: 2 }, + progress: { chapter_completion_pct: 67 }, + }, + }), + ).toMatchObject({ + characterCount: 4, + chapterCount: 3, + completedChapterCount: 2, + sceneCount: 1, + fileCount: 1, + chapterCompletionPercent: 67, + }); + }); + + it('counts revised dashboard chapters as completed progress', () => { + expect( + selectWorldMetrics({ + agents: [], + chapters: null, + scenes: [], + files: [], + overview: null, + dashboard: { + chapters: { total: 1, completed: 0, revised: 1 }, + progress: { chapter_completion_pct: 100 }, + }, + }), + ).toMatchObject({ + chapterCount: 1, + completedChapterCount: 1, + chapterCompletionPercent: 100, + }); + }); + + it('does not substitute dashboard file-link associations for workspace files', () => { + expect( + selectWorldMetrics({ + agents: [], + chapters: [], + scenes: [], + files: null, + overview: null, + dashboard: { file_links: { total: 9 } }, + }), + ).toMatchObject({ fileCount: null, fileSource: null }); + }); +}); + +describe('Overview page', () => { + const navigate = vi.fn(); + + function mockResources({ + dashboardFails = false, + empty = false, + failedLists = [], + }: { + dashboardFails?: boolean; + empty?: boolean; + failedLists?: Array<'agents' | 'chapters' | 'scenes' | 'files'>; + } = {}) { + vi.mocked(api.listAgents).mockResolvedValue({ + ok: true, + agents: empty ? [] : [{ id: 'a', name: 'Lin', display_name: 'Lin', kind: 'character' }], + }); + vi.mocked(api.listChapters).mockResolvedValue({ + ok: true, + chapters: empty + ? [] + : [ + { + id: 'c', + title: 'The First Light', + number: 1, + status: 'drafting', + scene_count: 1, + updated_at: '2026-06-18T12:00:00Z', + }, + ], + }); + vi.mocked(api.listScenes).mockResolvedValue({ ok: true, scenes: [] }); + vi.mocked(api.listWorkspaceFiles).mockResolvedValue({ ok: true, root: '', files: [] }); + vi.mocked(api.getStoryOverview).mockResolvedValue({ + ok: true, + overview: { agents: [], foreshadowing: [], secrets: [], world_time: null }, + }); + const dashboard = { + agents: { total: 1 }, + chapters: { total: 1, completed: 0 }, + progress: { chapter_completion_pct: 0 }, + }; + vi.mocked(worldbuildingApi.getDashboard).mockImplementation(() => + dashboardFails + ? Promise.reject(new Error('Dashboard unavailable')) + : Promise.resolve({ ok: true, dashboard: empty ? {} : dashboard }), + ); + if (failedLists.includes('agents')) { + vi.mocked(api.listAgents).mockRejectedValue(new Error('Agents unavailable')); + } + if (failedLists.includes('chapters')) { + vi.mocked(api.listChapters).mockRejectedValue(new Error('Chapters unavailable')); + } + if (failedLists.includes('scenes')) { + vi.mocked(api.listScenes).mockRejectedValue(new Error('Scenes unavailable')); + } + if (failedLists.includes('files')) { + vi.mocked(api.listWorkspaceFiles).mockRejectedValue(new Error('Files unavailable')); + } + } + + it('Overview renders API-backed metrics, sessions, reminders, and quick links', async () => { + mockResources(); + render( + , + ); + + expect(await screen.findByText('Night planning')).toBeDefined(); + expect(screen.getByText('The First Light')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Sessions' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeDefined(); + expect(screen.queryByRole('button', { name: 'View characters' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Chapters' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Files' })).toBeNull(); + expect(screen.getByText('Characters').closest('article')).toHaveAttribute( + 'title', + 'Counted from the worldbuilding dashboard.', + ); + expect(screen.getByText('Characters').closest('article')).toHaveTextContent('1'); + expect(screen.getByRole('progressbar', { name: 'Chapter completion' })).toHaveAttribute( + 'title', + 'Calculated from the worldbuilding dashboard.', + ); + expect(screen.getByRole('progressbar', { name: 'Scene completion' })).toHaveAttribute( + 'title', + 'Calculated from scene statuses in the scene list.', + ); + }); + + it('Overview falls back to derived resource counts when dashboard fails', async () => { + mockResources({ dashboardFails: true }); + render(); + + expect(await screen.findByText(/Dashboard summary is unavailable\./)).toBeDefined(); + expect(screen.getByTitle('Counted from the world character list.')).toHaveTextContent('1'); + expect(screen.getByText('The First Light')).toBeDefined(); + expect(screen.getByRole('progressbar', { name: 'Chapter completion' })).toHaveAttribute( + 'title', + 'Calculated from chapter statuses in the chapter list.', + ); + }); + + it('Overview does not turn failed lists into an empty world when dashboard also fails', async () => { + mockResources({ + dashboardFails: true, + empty: true, + failedLists: ['agents', 'chapters', 'scenes', 'files'], + }); + render(); + + expect(await screen.findByText(/Some overview data is unavailable\./)).toBeDefined(); + expect( + screen.queryByRole('heading', { name: 'Your world is ready for its first details' }), + ).toBeNull(); + expect(screen.getByText('Characters')).toBeDefined(); + expect(screen.queryByText('Chapters')).toBeNull(); + expect(screen.queryByText('Scenes')).toBeNull(); + expect(screen.queryByText('Files')).toBeNull(); + expect(screen.getByText('Reminder sources are unavailable.')).toBeDefined(); + }); + + it('Overview omits only a failed metric and shows a partial warning', async () => { + mockResources({ failedLists: ['files'] }); + vi.mocked(worldbuildingApi.getDashboard).mockResolvedValue({ + ok: true, + dashboard: { + agents: { total: 1 }, + chapters: { total: 1, completed: 0 }, + file_links: { total: 7 }, + progress: { chapter_completion_pct: 0 }, + }, + }); + render(); + + expect(await screen.findByText(/Some overview data is unavailable\./)).toBeDefined(); + expect(screen.getByText('Characters')).toBeDefined(); + expect(screen.getByText('Chapters')).toBeDefined(); + expect(screen.queryByText('Files')).toBeNull(); + expect( + screen.queryByRole('heading', { name: 'Your world is ready for its first details' }), + ).toBeNull(); + }); + + it('Overview shows an honest empty state for an empty world', async () => { + mockResources({ empty: true }); + render(); + + expect( + await screen.findByRole('heading', { name: 'Your world is ready for its first details' }), + ).toBeDefined(); + expect(screen.queryByText('Recent activity')).toBeNull(); + }); +}); + +describe('World page', () => { + it('keeps world detail visible when the graph request fails', async () => { + vi.mocked(api.getWorldDetail).mockResolvedValue({ + ok: true, + world: { + id: 'world-1', + name: 'Starfall City', + description: 'A city rebuilt after the long night.', + created_at: '2026-06-01T00:00:00Z', + stats: { agents: 2, chapters: 1, scenes: 3, open_foreshadowing: 0, active_secrets: 0 }, + }, + }); + vi.mocked(worldbuildingApi.listLocations).mockResolvedValue({ + ok: true, + locations: [], + items: [], + }); + vi.mocked(worldbuildingApi.listFactions).mockResolvedValue({ + ok: true, + factions: [], + items: [], + }); + vi.mocked(worldbuildingApi.listKnowledge).mockResolvedValue({ + ok: true, + knowledge: [], + items: [], + }); + vi.mocked(worldbuildingApi.getTimeline).mockResolvedValue({ + ok: true, + current_time: { day: 1, period: 1, label: 'First dawn' }, + events: [], + items: [], + }); + vi.mocked(worldbuildingApi.listGraphEntities).mockRejectedValue(new Error('Graph unavailable')); + + render(); + + expect(await screen.findByRole('heading', { name: 'Starfall City' })).toBeDefined(); + expect(screen.getByText('A city rebuilt after the long night.')).toBeDefined(); + expect(await screen.findByText('Graph unavailable')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Retry graph' })).toBeDefined(); + }); + + it('shows honest empty states for endpoint-backed world sections', async () => { + vi.mocked(api.getWorldDetail).mockResolvedValue({ + ok: true, + world: { + id: 'world-1', + name: 'Blank World', + description: '', + created_at: '2026-06-01T00:00:00Z', + stats: { agents: 0, chapters: 0, scenes: 0, open_foreshadowing: 0, active_secrets: 0 }, + }, + }); + vi.mocked(worldbuildingApi.listLocations).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(worldbuildingApi.listFactions).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(worldbuildingApi.listKnowledge).mockResolvedValue({ ok: true, items: [] }); + vi.mocked(worldbuildingApi.getTimeline).mockResolvedValue({ + ok: true, + current_time: { day: 1, period: 1, label: 'Day 1' }, + items: [], + }); + vi.mocked(worldbuildingApi.listGraphEntities).mockResolvedValue({ ok: true, items: [] }); + + render(); + + expect(await screen.findByText('No locations yet.')).toBeDefined(); + expect(screen.getByText('No factions yet.')).toBeDefined(); + expect(screen.getByText('No knowledge records yet.')).toBeDefined(); + expect(screen.getByText('No timeline events yet.')).toBeDefined(); + expect(screen.getByText('No graph entities yet.')).toBeDefined(); + }); +}); + +describe('Characters page', () => { + const lin = { id: 'lin', name: 'Lin', display_name: 'Lin', kind: 'character' }; + const sora = { id: 'sora', name: 'Sora', display_name: 'Sora', kind: 'character' }; + + function detail(id: string, name: string) { + return { + ok: true as const, + agent: { + id, + world_id: 'world-1', + name, + display_name: name, + kind: 'character', + created_at: '2026-06-01T00:00:00Z', + updated_at: '2026-06-01T00:00:00Z', + character_card: { version: 1, core_traits: [], identity: `${name} identity` }, + }, + }; + } + + it('loads selected detail once and retains selection after list refresh', async () => { + vi.mocked(api.listAgents).mockClear(); + vi.mocked(api.fetchAgentDetail).mockClear(); + vi.mocked(api.fetchDiaries).mockClear(); + vi.mocked(api.fetchRelations).mockClear(); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [lin, sora] }); + vi.mocked(api.fetchAgentDetail).mockImplementation((_worldId, agentId) => + Promise.resolve(detail(agentId, agentId === 'lin' ? 'Lin' : 'Sora')), + ); + vi.mocked(api.fetchDiaries).mockResolvedValue({ ok: true, diaries: [] }); + vi.mocked(api.fetchRelations).mockResolvedValue({ ok: true, relations: [] }); + vi.mocked(api.fetchMemorySummaries).mockResolvedValue({ ok: true, summaries: [] }); + vi.mocked(api.fetchAgentVoice).mockResolvedValue({ + ok: true, + voice: { + avg_sentence_length: 8, + sentence_variance: 1, + question_frequency: 0, + modifier_ratio: 0, + sample_count: 2, + signature_words: [], + tone_profile: {}, + }, + }); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Sora/ })); + expect(await screen.findByRole('heading', { name: 'Sora' })).toBeDefined(); + expect(screen.queryByText('Not set')).toBeNull(); + expect(api.fetchAgentDetail).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh characters' })); + + await waitFor(() => expect(api.listAgents).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('option', { name: /Sora/ })).toHaveAttribute('aria-selected', 'true'); + expect(api.fetchAgentDetail).toHaveBeenCalledTimes(1); + }); + + it('shows an honest empty state without loading character detail', async () => { + vi.mocked(api.fetchAgentDetail).mockClear(); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [] }); + render(); + + expect(await screen.findByRole('heading', { name: 'No characters yet' })).toBeDefined(); + expect(api.fetchAgentDetail).not.toHaveBeenCalled(); + }); + + it('renders memory, voice, and real relationship targets independently', async () => { + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [lin, sora] }); + vi.mocked(api.fetchAgentDetail).mockResolvedValue(detail('lin', 'Lin')); + vi.mocked(api.fetchDiaries).mockResolvedValue({ ok: true, diaries: [] }); + vi.mocked(api.fetchRelations).mockResolvedValue({ + ok: true, + relations: [ + { agent_id: 'lin', target_id: 'sora', relation_type: 'ally', updated_at: '2026-06-20' }, + { + agent_id: 'lin', + target_id: 'missing-agent', + relation_type: 'rival', + updated_at: '2026-06-20', + }, + ], + }); + vi.mocked(api.fetchMemorySummaries).mockResolvedValue({ + ok: true, + summaries: [ + { + id: 'm1', + period_start: 'Day 1', + period_end: 'Day 2', + summary: 'Guarded the gate.', + source_diary_ids: ['d1'], + created_at: '2026-06-20', + }, + ], + }); + vi.mocked(api.fetchAgentVoice).mockResolvedValue({ + ok: true, + voice: { + avg_sentence_length: 9, + sentence_variance: 2, + question_frequency: 0.1, + modifier_ratio: 0.2, + sample_count: 5, + signature_words: ['steady'], + tone_profile: { question_ratio: 0.1 }, + }, + }); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Lin/ })); + + expect(await screen.findByText('Guarded the gate.')).toBeDefined(); + const relationships = screen.getByRole('heading', { name: 'Relationships' }).closest('section'); + expect(relationships).not.toBeNull(); + expect(within(relationships!).getByText('Sora')).toBeDefined(); + expect(within(relationships!).getByText('missing-agent')).toBeDefined(); + expect(screen.getByText('steady')).toBeDefined(); + }); + + it('deletes with confirmation, refreshes the list, and selects a surviving neighbor', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listAgents) + .mockResolvedValueOnce({ ok: true, agents: [lin, sora] }) + .mockResolvedValueOnce({ ok: true, agents: [lin] }); + vi.mocked(api.fetchAgentDetail).mockImplementation((_worldId, id) => + Promise.resolve(detail(id, id === 'lin' ? 'Lin' : 'Sora')), + ); + vi.mocked(api.fetchDiaries).mockResolvedValue({ ok: true, diaries: [] }); + vi.mocked(api.fetchRelations).mockResolvedValue({ ok: true, relations: [] }); + vi.mocked(api.fetchMemorySummaries).mockResolvedValue({ ok: true, summaries: [] }); + vi.mocked(api.fetchAgentVoice).mockRejectedValue(new Error('Voice fingerprint not found')); + vi.mocked(api.deleteAgent).mockResolvedValue({ ok: true }); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Sora/ })); + await screen.findByRole('heading', { name: 'Sora' }); + fireEvent.click(screen.getByRole('button', { name: 'Delete character' })); + + await waitFor(() => expect(api.deleteAgent).toHaveBeenCalledWith('world-1', 'sora')); + await waitFor(() => + expect(screen.getByRole('option', { name: /Lin/ })).toHaveAttribute('aria-selected', 'true'), + ); + }); + + it('filters a confirmed deletion when retained list refresh fails', async () => { + const deletion = deferred<{ ok: boolean }>(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listAgents) + .mockResolvedValueOnce({ ok: true, agents: [lin, sora] }) + .mockRejectedValueOnce(new Error('List refresh failed')); + vi.mocked(api.fetchAgentDetail).mockImplementation((_worldId, id) => + Promise.resolve(detail(id, id === 'lin' ? 'Lin' : 'Sora')), + ); + vi.mocked(api.fetchDiaries).mockResolvedValue({ ok: true, diaries: [] }); + vi.mocked(api.fetchRelations).mockResolvedValue({ ok: true, relations: [] }); + vi.mocked(api.fetchMemorySummaries).mockResolvedValue({ ok: true, summaries: [] }); + vi.mocked(api.fetchAgentVoice).mockResolvedValue({ ok: true, voice: null }); + vi.mocked(api.deleteAgent).mockReturnValue(deletion.promise); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Sora/ })); + await screen.findByRole('heading', { name: 'Sora' }); + fireEvent.click(screen.getByRole('button', { name: 'Delete character' })); + expect(screen.getByRole('option', { name: /Sora/ })).toBeDefined(); + + await act(async () => deletion.resolve({ ok: true })); + + await waitFor(() => expect(screen.queryByRole('option', { name: /Sora/ })).toBeNull()); + expect(await screen.findByRole('alert')).toHaveTextContent('List refresh failed'); + expect(screen.getByRole('button', { name: 'Retry character list' })).toBeDefined(); + expect(screen.getByRole('option', { name: /Lin/ })).toHaveAttribute('aria-selected', 'true'); + }); + + it('shows retained refresh failure beside empty state after deleting the only character', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(api.listAgents) + .mockResolvedValueOnce({ ok: true, agents: [lin] }) + .mockRejectedValueOnce(new Error('List refresh failed')); + vi.mocked(api.fetchAgentDetail).mockResolvedValue(detail('lin', 'Lin')); + vi.mocked(api.fetchDiaries).mockResolvedValue({ ok: true, diaries: [] }); + vi.mocked(api.fetchRelations).mockResolvedValue({ ok: true, relations: [] }); + vi.mocked(api.fetchMemorySummaries).mockResolvedValue({ ok: true, summaries: [] }); + vi.mocked(api.fetchAgentVoice).mockResolvedValue({ ok: true, voice: null }); + vi.mocked(api.deleteAgent).mockResolvedValue({ ok: true }); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Lin/ })); + await screen.findByRole('heading', { name: 'Lin' }); + fireEvent.click(screen.getByRole('button', { name: 'Delete character' })); + + expect(await screen.findByRole('heading', { name: 'No characters yet' })).toBeDefined(); + expect(screen.queryByRole('option', { name: /Lin/ })).toBeNull(); + expect(await screen.findByRole('alert')).toHaveTextContent('List refresh failed'); + expect(screen.getByRole('button', { name: 'Retry character list' })).toBeDefined(); + }); + + it('renders voice 404 as empty and voice 500 as retryable error', async () => { + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [lin, sora] }); + vi.mocked(api.fetchAgentDetail).mockImplementation((_worldId, id) => + Promise.resolve(detail(id, id === 'lin' ? 'Lin' : 'Sora')), + ); + vi.mocked(api.fetchDiaries).mockResolvedValue({ ok: true, diaries: [] }); + vi.mocked(api.fetchRelations).mockResolvedValue({ ok: true, relations: [] }); + vi.mocked(api.fetchMemorySummaries).mockResolvedValue({ ok: true, summaries: [] }); + vi.mocked(api.fetchAgentVoice) + .mockResolvedValueOnce({ ok: true, voice: null }) + .mockRejectedValueOnce(new Error('Voice store failed')); + + render(); + fireEvent.click(await screen.findByRole('option', { name: /Lin/ })); + expect(await screen.findByText('No voice fingerprint yet.')).toBeDefined(); + fireEvent.click(screen.getByRole('option', { name: /Sora/ })); + expect(await screen.findByText('Voice store failed')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Retry voice fingerprint' })).toBeDefined(); + }); + + it('keeps character context accessible below 1050px', () => { + const css = readFileSync(join(process.cwd(), 'src/pages/CharactersPage.module.css'), 'utf8'); + const responsiveBlock = css.slice( + css.indexOf('@media (max-width: 1050px)'), + css.indexOf('@media (max-width: 700px)'), + ); + expect(responsiveBlock).not.toMatch(/\.contextPane\s*\{[^}]*display:\s*none/s); + expect(responsiveBlock).toMatch(/\.contextPane\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/s); + }); + + it('refreshes only the character list after creation', async () => { + vi.mocked(api.listAgents).mockClear(); + vi.mocked(api.fetchAgentDetail).mockClear(); + vi.mocked(api.fetchDiaries).mockClear(); + vi.mocked(api.fetchRelations).mockClear(); + vi.mocked(api.listAgents).mockResolvedValue({ ok: true, agents: [lin] }); + vi.mocked(api.createAgent).mockResolvedValue({ ok: true, agent_id: 'new-agent', name: 'Mira' }); + + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Create character' })); + const dialog = screen.getByRole('dialog', { name: 'Create character' }); + fireEvent.change(within(dialog).getByLabelText('Character name'), { + target: { value: 'Mira' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create character' })); + + await waitFor(() => expect(api.listAgents).toHaveBeenCalledTimes(2)); + expect(api.fetchAgentDetail).not.toHaveBeenCalled(); + expect(api.fetchDiaries).not.toHaveBeenCalled(); + expect(api.fetchRelations).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/__tests__/shell.test.tsx b/webui/src/__tests__/shell.test.tsx new file mode 100644 index 00000000..5ed929f1 --- /dev/null +++ b/webui/src/__tests__/shell.test.tsx @@ -0,0 +1,432 @@ +import { readFileSync } from 'node:fs'; +import type { ReactNode } from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { shouldWarnBeforeClose } from '../App'; +import { AppStateProvider, useAppState } from '../AppState'; +import { useSafeNavigation } from '../hooks/useSafePageNavigation'; +import { I18nProvider } from '../i18n'; +import DesktopShell from '../shell/DesktopShell'; +import { desktopPages, readStoredDesktopPage, writeStoredDesktopPage } from '../shell/navigation'; + +vi.mock('../DesktopBoot', () => ({ + default: ({ children }: { children: ReactNode }) => children, +})); +vi.mock('../hooks/useSSE', () => ({ useSSE: () => 'connected' })); +vi.mock('../components/WorldSidebar', () => ({ default: () => })); +vi.mock('../components/MainPanel', () => ({ default: () =>
Workbench main
})); +vi.mock('../components/InspectorPanel', () => ({ default: () => })); +vi.mock('../api/client', () => ({ + api: { + getConfig: vi.fn().mockResolvedValue({ api_key_masked: '' }), + metadata: vi.fn().mockResolvedValue({ + provider: 'openai', + model: 'gpt-4o', + models: [], + permission_mode: 'default', + memory: { enabled: false }, + tools: [], + mcp_servers: [], + agents: [], + delegation_patterns: [], + }), + listWorlds: vi.fn().mockResolvedValue({ + worlds: [{ id: 'world-1', name: 'Test World', description: '', created_at: '' }], + }), + listSessions: vi.fn().mockResolvedValue({ + sessions: [ + { + id: 'session-1', + title: 'Test', + world_id: 'world-1', + agent_id: 'agent-1', + last_seq: 0, + created_at: '', + updated_at: '', + archived_at: null, + }, + ], + }), + capabilities: vi.fn().mockResolvedValue({ capabilities: {}, fallback: false }), + getPreferences: vi.fn().mockResolvedValue(null), + getStoryOverview: vi.fn().mockResolvedValue({ overview: null, fallback: false }), + listAgents: vi.fn().mockResolvedValue({ agents: [] }), + listForeshadowing: vi.fn().mockResolvedValue({ items: [] }), + listSecrets: vi.fn().mockResolvedValue({ items: [] }), + getWorldTime: vi.fn().mockResolvedValue({}), + sseUrl: vi.fn().mockReturnValue(null), + }, +})); + +describe('desktop shell', () => { + beforeEach(() => window.localStorage.clear()); + + function renderShell(page: 'overview' | 'sessions', onNavigate = vi.fn()) { + return render( + + + +
Existing workbench
+
+
+
, + ); + } + + function SafeNavigationHarness() { + const { state, dispatch } = useAppState(); + const { requestPageChange } = useSafeNavigation(); + const activeFile = state.workspaceFiles.find((file) => file.id === state.activeEditorFileId); + return ( + <> + + + + + + {state.currentPage} + + {state.activeEditorFileId ? state.editorBuffers[state.activeEditorFileId] : ''} + + {state.editorSaveStatus} + {String(activeFile?.dirty ?? false)} + {String(shouldWarnBeforeClose(state))} + + Embedded chapter editor + + + ); + } + + function renderSafeNavigation() { + return render( + + + + + , + ); + } + + it('keeps dirty Chapters navigation on the page when Overview or Scenes is cancelled', () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Start chapters' })); + fireEvent.click(screen.getByRole('button', { name: 'Make dirty' })); + const navigation = screen.getByRole('navigation', { name: 'Primary navigation' }); + + fireEvent.click(within(navigation).getAllByRole('button')[0]); + fireEvent.click(within(navigation).getByRole('button', { name: 'Scenes 场景' })); + + expect(screen.getByLabelText('Current page')).toHaveTextContent('chapters'); + expect(window.confirm).toHaveBeenCalledTimes(2); + }); + + it('allows confirmed dirty navigation from Chapters to Scenes', () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Start chapters' })); + fireEvent.click(screen.getByRole('button', { name: 'Make dirty' })); + fireEvent.click(screen.getByRole('button', { name: 'Scenes 场景' })); + + expect(screen.getByLabelText('Current page')).toHaveTextContent('scenes'); + fireEvent.click( + screen.getByRole('navigation', { name: 'Primary navigation' }).querySelectorAll('button')[0], + ); + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('Current page')).toHaveTextContent('overview'); + }); + + it('blocks navigation while a chapter save is pending without prompting', () => { + const confirm = vi.spyOn(window, 'confirm'); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Start chapters' })); + fireEvent.click(screen.getByRole('button', { name: 'Start saving' })); + fireEvent.click(screen.getByRole('button', { name: 'Scenes 场景' })); + + expect(screen.getByLabelText('Current page')).toHaveTextContent('chapters'); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('blocks world changes while saving without prompting', () => { + const confirm = vi.spyOn(window, 'confirm'); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Setup worlds' })); + fireEvent.click(screen.getByRole('button', { name: 'Start saving' })); + + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'world-2' } }); + + expect(screen.getByRole('combobox')).toHaveValue('world-1'); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('retains the current world on cancel and changes it after confirmed discard', () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Setup worlds' })); + fireEvent.click(screen.getByRole('button', { name: 'Make dirty' })); + + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'world-2' } }); + expect(screen.getByRole('combobox')).toHaveValue('world-1'); + expect(screen.getByLabelText('Editor status')).toHaveTextContent('dirty'); + + confirm.mockReturnValue(true); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'world-2' } }); + expect(screen.getByRole('combobox')).toHaveValue('world-2'); + expect(screen.getByLabelText('Editor status')).toHaveTextContent('idle'); + expect(confirm).toHaveBeenCalledTimes(2); + }); + + it('preserves a cancelled workspace draft and reverts it after confirmed navigation', () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Open workspace draft' })); + const navigation = screen.getByRole('navigation', { name: 'Primary navigation' }); + + fireEvent.click(within(navigation).getAllByRole('button')[5]); + expect(screen.getByLabelText('Current page')).toHaveTextContent('overview'); + expect(screen.getByLabelText('Editor buffer')).toHaveTextContent('Unsaved draft'); + expect(screen.getByLabelText('File dirty')).toHaveTextContent('true'); + expect(screen.getByLabelText('Warn before close')).toHaveTextContent('true'); + + confirm.mockReturnValue(true); + fireEvent.click(within(navigation).getAllByRole('button')[5]); + fireEvent.click(within(navigation).getAllByRole('button')[8]); + + expect(screen.getByLabelText('Current page')).toHaveTextContent('files'); + expect(screen.getByLabelText('Editor buffer')).toHaveTextContent('Original'); + expect(screen.getByLabelText('File dirty')).toHaveTextContent('false'); + expect(screen.getByLabelText('Editor status')).toHaveTextContent('idle'); + expect(screen.getByLabelText('Warn before close')).toHaveTextContent('false'); + }); + + it('renders exactly ten bilingual navigation buttons and dispatches navigation', () => { + const dispatch = vi.fn(); + renderShell('overview', (page) => dispatch({ type: 'SET_PAGE', page })); + + const navigation = screen.getByRole('navigation', { name: '主导航' }); + expect(within(navigation).getAllByRole('button')).toHaveLength(10); + expect(within(navigation).getByRole('button', { name: '概览' })).toBeInTheDocument(); + expect(within(navigation).getByRole('button', { name: 'Sessions 会话' })).toBeInTheDocument(); + expect(within(navigation).getByRole('button', { name: 'World 世界设定' })).toBeInTheDocument(); + + fireEvent.click(within(navigation).getByRole('button', { name: /角色/ })); + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_PAGE', page: 'characters' }); + }); + + it('marks the active page and preserves the existing workbench children', () => { + renderShell('sessions'); + + expect(screen.getByRole('button', { name: 'Sessions 会话' })).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(screen.getByText('Existing workbench')).toBeInTheDocument(); + }); + + it('restores only persisted top-level pages', () => { + window.localStorage.setItem('merak.desktop.page', 'characters'); + expect(readStoredDesktopPage()).toBe('characters'); + + window.localStorage.setItem('merak.desktop.page', 'editor'); + expect(readStoredDesktopPage()).toBe('overview'); + + window.localStorage.setItem('merak.desktop.page', 'not-a-page'); + expect(readStoredDesktopPage()).toBe('overview'); + }); + + it('persists only validated top-level pages', () => { + writeStoredDesktopPage('files'); + expect(window.localStorage.getItem('merak.desktop.page')).toBe('files'); + + writeStoredDesktopPage('editor'); + expect(window.localStorage.getItem('merak.desktop.page')).toBe('files'); + expect(desktopPages).toHaveLength(10); + }); + + it('keeps global overlays outside the constrained page-content outlet', () => { + render( + + + Overlay
} + > +
Workbench
+ + + , + ); + + const main = screen.getByRole('main'); + expect(within(main).getByTestId('page-content')).toBeInTheDocument(); + expect(within(main).queryByTestId('global-overlay')).toBeNull(); + expect(screen.getByTestId('global-overlay')).toBeInTheDocument(); + }); + + it('persists programmatic settings and overview transitions but never editor', () => { + function PageDispatchHarness() { + const { dispatch } = useAppState(); + return ( + <> + + + + + ); + } + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(window.localStorage.getItem('merak.desktop.page')).toBe('settings'); + fireEvent.click(screen.getByRole('button', { name: 'Overview' })); + expect(window.localStorage.getItem('merak.desktop.page')).toBe('overview'); + fireEvent.click(screen.getByRole('button', { name: 'Editor' })); + expect(window.localStorage.getItem('merak.desktop.page')).toBe('overview'); + }); + + it('keeps all navigation buttons reachable in short windows', () => { + const css = readFileSync('src/shell/DesktopShell.module.css', 'utf8'); + expect(css).toMatch(/\.navigation\s*\{[^}]*overflow-y:\s*auto/s); + }); + + it('opens and closes compact navigation with Escape and restores focus', () => { + renderShell('overview'); + const trigger = screen.getByRole('button', { name: 'Open navigation' }); + fireEvent.click(trigger); + expect(screen.getByRole('dialog', { name: /navigation|导航/i })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Close navigation' })[1]).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(trigger).toHaveFocus(); + }); + + it('traps forward and reverse Tab inside open navigation', () => { + renderShell('overview'); + fireEvent.click(screen.getByRole('button', { name: 'Open navigation' })); + const dialog = screen.getByRole('dialog', { name: /navigation|导航/i }); + const controls = within(dialog).getAllByRole('button'); + controls.at(-1)?.focus(); + fireEvent.keyDown(document, { key: 'Tab' }); + expect(controls[0]).toHaveFocus(); + controls[0].focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(controls.at(-1)).toHaveFocus(); + }); + + it('returns focus to the menu trigger after a compact navigation item succeeds', () => { + const navigate = vi.fn(); + renderShell('overview', navigate); + const trigger = screen.getByRole('button', { name: 'Open navigation' }); + fireEvent.click(trigger); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /Characters/ })); + expect(navigate).toHaveBeenCalledWith('characters'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(trigger).toHaveFocus(); + }); + + it('returns focus to the menu trigger when dirty navigation is cancelled', () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderSafeNavigation(); + fireEvent.click(screen.getByRole('button', { name: 'Start chapters' })); + fireEvent.click(screen.getByRole('button', { name: 'Make dirty' })); + const trigger = screen.getByRole('button', { name: 'Open navigation' }); + fireEvent.click(trigger); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /Scenes/ })); + expect(screen.getByLabelText('Current page')).toHaveTextContent('chapters'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(trigger).toHaveFocus(); + }); + + it('defines the approved shared visual tokens and responsive shell contracts', () => { + const globalCss = readFileSync('src/styles/global.css', 'utf8'); + const shellCss = readFileSync('src/shell/DesktopShell.module.css', 'utf8'); + + expect(globalCss).toContain('--brand: #06266f'); + expect(globalCss).toContain('--page: #ffffff'); + expect(globalCss).toMatch(/:focus-visible/); + expect(globalCss).toMatch(/prefers-reduced-motion:\s*reduce/); + expect(shellCss).toMatch(/@media \(max-width: 1180px\)/); + expect(shellCss).toMatch(/@media \(max-width: 980px\)/); + }); + + it('gives the world dashboard a definite scroll-container height', () => { + const css = readFileSync('src/components/WorldDashboard.module.css', 'utf8'); + const dashboardRule = css.match(/\.dashboard\s*\{([^}]*)\}/s)?.[1] ?? ''; + + expect(dashboardRule).toMatch(/^\s*height:\s*100%/m); + expect(dashboardRule).toMatch(/^\s*min-height:\s*0/m); + expect(dashboardRule).toMatch(/^\s*overflow:\s*auto/m); + }); + + it('mounts App global dialogs outside the constrained page outlet', async () => { + const { default: App } = await import('../App'); + render(); + + const dialog = await screen.findByRole('dialog', { name: '初始化设置' }); + expect(within(screen.getByRole('main')).queryByRole('dialog')).toBeNull(); + expect(dialog).toBeInTheDocument(); + }); +}); diff --git a/webui/src/__tests__/sse-workflows.test.tsx b/webui/src/__tests__/sse-workflows.test.tsx new file mode 100644 index 00000000..9d88470a --- /dev/null +++ b/webui/src/__tests__/sse-workflows.test.tsx @@ -0,0 +1,328 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runtimeApi } from '../api/runtime'; +import AskUserPrompt from '../components/AskUserPrompt'; +import CreationRequestDialog from '../components/CreationRequestDialog'; + +const apiMocks = vi.hoisted(() => ({ + respondToAsk: vi.fn(), + resolveCreation: vi.fn(), +})); + +vi.mock('../api/client', () => ({ + api: apiMocks, + formatApiError: (error: unknown, fallback = 'Request failed') => + error instanceof Error ? error.message : fallback, +})); + +describe('interactive SSE workflows', () => { + beforeEach(() => vi.clearAllMocks()); + + it('exposes the ask-user response endpoint', () => { + expect(runtimeApi.respondToAsk).toBeTypeOf('function'); + }); + + it('submits an ask response once and resolves only after success', async () => { + let complete!: () => void; + apiMocks.respondToAsk.mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + const onResolved = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'First person' })); + const submit = screen.getByRole('button', { name: 'Send response' }); + fireEvent.click(submit); + fireEvent.click(submit); + + expect(apiMocks.respondToAsk).toHaveBeenCalledOnce(); + expect(apiMocks.respondToAsk).toHaveBeenCalledWith('run_1', 'call_1', 'First person'); + expect(onResolved).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Sending...' })).toBeDisabled(); + + complete(); + await waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(onResolved).toHaveBeenCalledWith('call_1'); + }); + + it('keeps the ask dialog open and formats request errors', async () => { + apiMocks.respondToAsk.mockRejectedValue(new Error('Run is no longer waiting')); + const onResolved = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText('Your response'), { target: { value: 'Yes' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send response' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Run is no longer waiting'); + expect(onResolved).not.toHaveBeenCalled(); + }); + + it('toggles multiple ask options and exposes their selected state', async () => { + apiMocks.respondToAsk.mockResolvedValue({ ok: true }); + render( + , + ); + const hope = screen.getByRole('button', { name: 'Hope' }); + const loss = screen.getByRole('button', { name: 'Loss' }); + fireEvent.click(hope); + fireEvent.click(loss); + expect(hope).toHaveAttribute('aria-pressed', 'true'); + expect(loss).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(hope); + expect(hope).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByRole('button', { name: 'Send response' })); + await waitFor(() => + expect(apiMocks.respondToAsk).toHaveBeenCalledWith('run_1', 'call_1', 'Loss'), + ); + }); + + it('resets ask input and errors when request identity changes', async () => { + apiMocks.respondToAsk.mockRejectedValueOnce(new Error('Old failure')); + const { rerender } = render( + , + ); + fireEvent.change(screen.getByLabelText('Your response'), { target: { value: 'Old answer' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send response' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Old failure'); + + rerender( + , + ); + expect(screen.getByLabelText('Your response')).toHaveValue(''); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('ignores completion from a replaced ask while the new request is submitting', async () => { + let rejectOld!: (error: Error) => void; + let resolveNew!: () => void; + apiMocks.respondToAsk + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectOld = reject; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveNew = resolve; + }), + ); + const onResolved = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.change(screen.getByLabelText('Your response'), { target: { value: 'Old answer' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send response' })); + rerender( + , + ); + fireEvent.change(screen.getByLabelText('Your response'), { target: { value: 'New answer' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send response' })); + + rejectOld(new Error('Old failure')); + await waitFor(() => expect(apiMocks.respondToAsk).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Sending...' })).toBeDisabled(); + + resolveNew(); + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('call_2')); + }); + + it.each([ + ['Allow', 'allow'], + ['Deny', 'deny'], + ] as const)('resolves a creation request once via %s', async (button, decision) => { + apiMocks.resolveCreation.mockResolvedValue({ ok: true }); + const onResolved = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: button })); + + await waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(onResolved).toHaveBeenCalledWith('creation_1'); + expect(apiMocks.resolveCreation).toHaveBeenCalledOnce(); + expect(apiMocks.resolveCreation).toHaveBeenCalledWith( + 'creation_1', + decision, + decision === 'allow' ? { title: 'Arrival' } : undefined, + ); + }); + + it('resets creation edits and errors when request identity changes', async () => { + apiMocks.resolveCreation.mockRejectedValueOnce(new Error('Old failure')); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Allow' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Old failure'); + rerender( + , + ); + expect(screen.getByLabelText('Proposed values')).toHaveValue('{\n "title": "New"\n}'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('ignores completion from a replaced creation while the new request is submitting', async () => { + let rejectOld!: (error: Error) => void; + let resolveNew!: () => void; + apiMocks.resolveCreation + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectOld = reject; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveNew = resolve; + }), + ); + const onResolved = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Allow' })); + rerender( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Allow' })); + + rejectOld(new Error('Old failure')); + await waitFor(() => expect(apiMocks.resolveCreation).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Submitting...' })).toBeDisabled(); + + resolveNew(); + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('creation_2')); + }); + + it.each([ + ['ask', 'Agent question'], + ['creation', 'Creation request'], + ] as const)('traps and restores focus for the %s dialog', (_kind, title) => { + const opener = document.createElement('button'); + document.body.append(opener); + opener.focus(); + const view = + title === 'Agent question' + ? render( + , + ) + : render( + , + ); + const dialog = screen.getByRole('dialog', { name: title }); + const focusable = dialog.querySelectorAll( + 'button:not([disabled]), textarea:not([disabled])', + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + last.focus(); + fireEvent.keyDown(dialog, { key: 'Tab' }); + expect(document.activeElement).toBe(first); + first.focus(); + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(last); + view.unmount(); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); + + it('keeps the creation dialog open when resolution fails', async () => { + apiMocks.resolveCreation.mockRejectedValue(new Error('Creation expired')); + const onResolved = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Allow' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Creation expired'); + expect(onResolved).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index b9beedf4..20d0358f 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -1,3 +1,5 @@ +import { ApiError, apiUrl, fallbackRequest, request, requestBlob, requestForm } from './http'; +import { runtimeApi } from './runtime'; import type { AdvanceWorldTimeResponse, AgentDetailResponse, @@ -27,6 +29,7 @@ import type { ForeshadowingListResponse, GenerateTitleResponse, LlmConfigFull, + MemorySummaryListResponse, OkResponse, OpenWorkspacePathResponse, PatchAgentCardResponse, @@ -47,6 +50,7 @@ import type { StoryOverviewResponse, UpdateSessionResponse, UpdateWorldResponse, + VoiceFingerprintResponse, WorkflowSummary, WorkspaceFileContentResponse, WorkspaceFileListResponse, @@ -55,209 +59,7 @@ import type { WorldTimeResponse, } from './types'; -let apiBase = import.meta.env.VITE_API_BASE ?? ''; - -export function setApiBase(base: string) { - apiBase = base.replace(/\/$/, ''); -} - -export function getApiBase() { - return apiBase; -} - -export function apiUrl(path: string) { - if (/^https?:\/\//i.test(path)) return path; - return `${apiBase}${path.startsWith('/') ? path : `/${path}`}`; -} - -export class ApiError extends Error { - status: number; - code?: string; - constructor(message: string, status: number, code?: string) { - super(message); - this.name = 'ApiError'; - this.status = status; - this.code = code; - } -} - -export function formatApiError( - error: unknown, - fallback = '操作失败,请稍后重试。', - t?: (key: string) => string, -) { - if (error instanceof ApiError) { - if (t && error.code) { - const key = `error.${error.code}`; - const translated = t(key); - if (translated !== key) return translated; - } - switch (error.code) { - case 'version_conflict': - return '内容已在后端更新,请刷新后再保存。'; - case 'file_conflict': - return '文件已被其他操作修改,请刷新后再保存。'; - case 'session_not_found': - return '会话不存在,可能已被删除或归档。'; - case 'session_busy': - return '会话正在进行中,请等待当前操作完成后再发送消息。'; - case 'run_not_found': - return '运行记录不存在。'; - case 'approval_not_found': - return '审批请求不存在或已过期。'; - case 'invalid_request': - return error.message || '请求格式有误,请检查输入。'; - case 'invalid_path': - return '文件路径不在允许的范围内。'; - case 'file_not_found': - return '文件不存在或已被删除。'; - case 'unsupported_file_type': - return '不支持该文件类型。'; - - // World - case 'world_not_found': - return '世界不存在或已被删除。'; - case 'world_create_failed': - return '创建世界失败,请检查名称是否重复。'; - case 'world_name_required': - return '世界名称不能为空。'; - - // Agent - case 'agent_not_found': - return '角色不存在或已被删除。'; - case 'agent_create_failed': - return '创建角色失败,请检查必填字段。'; - case 'agent_version_conflict': - return '角色信息已被其他操作更新,请刷新后再试。'; - - // Scene - case 'scene_not_found': - return '场景不存在或已被删除。'; - case 'scene_create_failed': - return '创建场景失败,请确认章节存在。'; - case 'scene_end_failed': - return '结束场景失败,场景可能已经结束。'; - case 'scene_status_invalid': - return '场景当前状态不支持此操作。'; - - // Chapter - case 'chapter_not_found': - return '章节不存在或已被删除。'; - case 'chapter_update_failed': - return '章节更新失败,请刷新后再试。'; - - // Diary - case 'information_boundary_leak': - return '日记内容包含角色不应知晓的信息,已被信息边界过滤。'; - case 'diary_write_failed': - return '日记写入失败,请稍后重试。'; - - // Foreshadowing / Secret - case 'foreshadow_not_found': - return '伏笔不存在,可能已被删除。'; - case 'foreshadowing_not_found': - return '伏笔不存在或已被删除。'; - case 'secret_not_found': - return '秘密不存在或已被删除。'; - case 'secret_status_invalid': - return '秘密当前状态不支持此操作。'; - - // Pipeline - case 'pipeline_not_available': - return '创作流水线暂不可用,请确认后端已启用 worldbuilding pipeline。'; - case 'pipeline_advance_blocked': - return '阶段推进被阻止:当前阶段条件未全部满足。请先完成当前阶段的所有要求。'; - case 'pipeline_phase_invalid': - return '目标阶段无效,请确认阶段名称正确。'; - case 'workflow_not_found': - return '指定的 Pipeline 工作流不存在。'; - - // Image service - case 'image_service_not_available': - return '图片服务未启用,请确认后端 Image Service 已初始化。'; - case 'image_upload_failed': - return '图片上传失败,请确认文件格式和大小符合要求。'; - case 'invalid_image_type': - return '图片类型必须是头像或人设图。'; - case 'image_not_found': - return '图片不存在或已被删除。'; - - // Config - case 'config_load_failed': - return '配置加载失败,请检查后端服务状态。'; - case 'config_save_failed': - return '配置保存失败,请稍后重试。'; - case 'title_generation_failed': - return '标题生成失败,请稍后重试。'; - case 'test_failed': - return `连接测试失败:${error.message}`; - case 'test_unavailable': - return '连接测试暂不可用,请检查后端配置。'; - - // General - case 'missing_param': - return error.message || '缺少必要参数,请检查输入。'; - case 'database_error': - return '数据库操作失败,请稍后重试。'; - - default: - return error.message || (t ? t('error.unknown') : fallback); - } - } - return error instanceof Error ? error.message : (t ? t('error.unknown') : fallback); -} - -async function request(method: string, path: string, body?: unknown): Promise { - const opts: RequestInit = { - method, - headers: { 'Content-Type': 'application/json' }, - }; - if (body !== undefined) { - opts.body = JSON.stringify(body); - } - const res = await fetch(apiUrl(path), opts); - return parseJsonResponse(res); -} - -async function parseJsonResponse(res: Response): Promise { - let json: unknown; - try { - json = await res.json(); - } catch { - const text = await res.text().catch(() => ''); - throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 200)}`); - } - if (res.status >= 400) { - const error = ( - json as { error?: { message?: string; code?: string } | string; message?: string } - ).error; - const message = - typeof error === 'string' - ? error - : (error?.message ?? (json as { message?: string }).message ?? `HTTP ${res.status}`); - const code = typeof error === 'object' && error !== null ? error.code : undefined; - throw new ApiError(message, res.status, code); - } - return json as T; -} - -async function requestForm(path: string, body: FormData): Promise { - const res = await fetch(apiUrl(path), { method: 'POST', body }); - return parseJsonResponse(res); -} - -async function requestBlob(method: string, path: string, body?: Blob): Promise { - const res = await fetch(apiUrl(path), { method, body }); - return parseJsonResponse(res); -} - -async function fallbackRequest(path: string, fallback: T): Promise { - try { - return await request('GET', path); - } catch { - return fallback; - } -} +export { ApiError, apiUrl, formatApiError, getApiBase, setApiBase } from './http'; const fallbackCapabilities: CapabilitiesResponse = { ok: true, @@ -272,74 +74,11 @@ const fallbackCapabilities: CapabilitiesResponse = { }; export const api = { - metadata: () => request('GET', '/v1/runtime'), + ...runtimeApi, capabilities: () => fallbackRequest('/api/webui/capabilities', fallbackCapabilities), - createSession: (title = '', worldId?: string, agentId?: string) => { - const body: Record = { title }; - if (worldId) body.world_id = worldId; - if (agentId) body.agent_id = agentId; - return request('POST', '/v1/sessions', body); - }, - - archiveSession: (session: SessionSummary, archived: boolean) => - request('POST', `/v1/sessions/${session.id}/archive`, { archived }), - - updateSession: (id: string, title: string) => - request('PATCH', `/v1/sessions/${id}`, { title }), - - generateTitle: (id: string) => - request('POST', `/v1/sessions/${id}/generate-title`), - - listSessions: (worldId?: string) => { - const query = worldId ? `?world_id=${encodeURIComponent(worldId)}` : ''; - return request('GET', `/v1/sessions${query}`); - }, - - getOrCreateAgentSession: (worldId: string, agentId: string) => - request<{ session: SessionSummary; created: boolean }>( - 'GET', - `/v1/worlds/${encodeURIComponent(worldId)}/agents/${encodeURIComponent(agentId)}/session`, - ), - - getSession: (id: string) => request('GET', `/v1/sessions/${id}`), - - getRun: (runId: string) => request('GET', `/v1/runs/${runId}`), - - events: (id: string, after = 0) => - request<{ events: unknown[] }>('GET', `/v1/sessions/${id}/events?after=${after}`), - - memory: (id: string) => - request<{ session_id: string; items: unknown[] }>('GET', `/v1/sessions/${id}/memory`), - - startRun: (id: string, message: string, model = '') => - request('POST', `/v1/sessions/${id}/runs`, { - message, - ...(model ? { model } : {}), - }), - - startDelegation: ( - id: string, - pattern: string, - agents: string[], - task: string, - aggregation = 'all_results', - ) => - request<{ delegation_id: string; parent_run_id: string; session_id: string }>( - 'POST', - `/v1/sessions/${id}/delegations`, - { pattern, agents, task, aggregation }, - ), - - resolveApproval: (id: string, allow: boolean) => - request('POST', `/v1/approvals/${id}`, { - decision: allow ? 'allow' : 'deny', - }), - - cancelRun: (id: string) => request('POST', `/v1/runs/${id}/cancel`), - listWorlds: () => request('GET', '/api/worldbuilding/worlds'), createWorld: (name: string, description = '') => @@ -425,13 +164,11 @@ export const api = { ), saveWorkspaceFile: (path: string, content: string, version?: string) => - request( - 'PUT', - '/api/workspace/files/content', - { path, content, version }, - ), - - sseUrl: (id: string) => `${apiBase}/v1/sessions/${id}/events/stream`, + request('PUT', '/api/workspace/files/content', { + path, + content, + version, + }), getConfig: () => request('GET', '/api/config/llm'), @@ -444,13 +181,12 @@ export const api = { temperature?: number; context_memory_length?: 'short' | 'medium' | 'long'; writer_model?: string; - }) => request('POST', '/api/config/llm', config), + }) => request('POST', '/api/config/llm', config), testConfig: () => request('POST', '/api/config/llm/test'), // Preferences - getPreferences: () => - request('GET', '/api/config/preferences'), + getPreferences: () => request('GET', '/api/config/preferences'), savePreferences: (prefs: { default_genre?: string; @@ -595,6 +331,30 @@ export const api = { `/api/worldbuilding/${worldId}/agents/${agentId}/relations`, ), + fetchMemorySummaries: (worldId: string, agentId: string) => + request( + 'GET', + `/api/worldbuilding/${encodeURIComponent(worldId)}/agents/${encodeURIComponent(agentId)}/memory-summaries`, + ), + + fetchAgentVoice: async (worldId: string, agentId: string) => { + try { + return await request( + 'GET', + `/api/worldbuilding/${encodeURIComponent(worldId)}/agents/${encodeURIComponent(agentId)}/voice`, + ); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return { ok: true, voice: null }; + throw error; + } + }, + + deleteAgent: (worldId: string, agentId: string) => + request( + 'DELETE', + `/api/worldbuilding/${encodeURIComponent(worldId)}/agents/${encodeURIComponent(agentId)}`, + ), + // Patch other cards patchForeshadow: (worldId: string, id: string, fields: Record) => request('PATCH', `/api/worldbuilding/${worldId}/foreshadowing/${id}`, { fields }), @@ -608,6 +368,12 @@ export const api = { patchSecret: (worldId: string, id: string, fields: Record) => request('PATCH', `/api/worldbuilding/${worldId}/secrets/${id}`, { fields }), + deleteForeshadowing: (worldId: string, id: string) => + request('DELETE', `/api/worldbuilding/${worldId}/foreshadowing/${id}`), + + deleteSecret: (worldId: string, id: string) => + request('DELETE', `/api/worldbuilding/${worldId}/secrets/${id}`), + // Run audit fetchRunAudit: (runId: string) => request('GET', `/v1/runs/${runId}/audit`), @@ -699,14 +465,6 @@ export const api = { // World detail getWorldDetail: (worldId: string) => request('GET', `/api/worldbuilding/worlds/${worldId}`), - - // Creation resolution — wired for future SSE-driven creation approval flow - // The backend emits creation requests via SSE; the UI will call this to allow/deny with optional modifications. - resolveCreation: (id: string, decision: string, modifications?: Record) => - request('POST', `/v1/creations/${id}/resolve`, { - decision, - modifications, - }), }; export async function getPipelineState(worldId: string): Promise { diff --git a/webui/src/api/config.ts b/webui/src/api/config.ts new file mode 100644 index 00000000..886196e5 --- /dev/null +++ b/webui/src/api/config.ts @@ -0,0 +1,22 @@ +import { request } from './http'; +import type { LlmConfigFull, OkResponse, PreferencesResponse, UserPreferences } from './types'; + +export interface SaveLlmConfig { + provider?: string; + api_key?: string; + api_base_url?: string; + default_model?: string; + max_output_tokens?: number; + temperature?: number; + context_memory_length?: 'short' | 'medium' | 'long'; + writer_model?: string; +} + +export const configApi = { + getConfig: () => request('GET', '/api/config/llm'), + saveConfig: (config: SaveLlmConfig) => request('POST', '/api/config/llm', config), + testConfig: () => request('POST', '/api/config/llm/test'), + getPreferences: () => request('GET', '/api/config/preferences'), + savePreferences: (preferences: Partial) => + request('PUT', '/api/config/preferences', preferences), +}; diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts new file mode 100644 index 00000000..1c4b5366 --- /dev/null +++ b/webui/src/api/files.ts @@ -0,0 +1,33 @@ +import { request } from './http'; +import type { OkResponse, WorldFileLinkInput, WorldFileListResponse } from './types'; + +type WorldFileTarget = Required>; + +const filesPath = (worldId: string) => `/api/worldbuilding/${encodeURIComponent(worldId)}/files`; + +function fileTarget(link?: Pick) { + if (!link?.entity_type || !link.entity_id) { + throw new Error('File link target is required'); + } + return { target_type: link.entity_type, target_id: link.entity_id }; +} + +export const filesApi = { + listWorldFiles: async (worldId: string): Promise => { + const response = await request('GET', filesPath(worldId)); + return { ...response, items: response.items ?? response.files }; + }, + + linkWorldFile: (worldId: string, link: WorldFileLinkInput) => + request('POST', filesPath(worldId), { + file_path: link.file_path, + ...fileTarget(link), + }), + + unlinkWorldFile: async (worldId: string, filePath: string, target: WorldFileTarget) => + request( + 'DELETE', + `${filesPath(worldId)}/${encodeURIComponent(filePath)}`, + fileTarget(target), + ), +}; diff --git a/webui/src/api/http.ts b/webui/src/api/http.ts new file mode 100644 index 00000000..923d73cd --- /dev/null +++ b/webui/src/api/http.ts @@ -0,0 +1,194 @@ +let apiBase = import.meta.env.VITE_API_BASE ?? ''; + +export function setApiBase(base: string) { + apiBase = base.replace(/\/$/, ''); +} + +export function getApiBase() { + return apiBase; +} + +export function apiUrl(path: string) { + if (/^https?:\/\//i.test(path)) return path; + return `${apiBase}${path.startsWith('/') ? path : `/${path}`}`; +} + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + public retryable = false, + ) { + super(message); + this.name = 'ApiError'; + } +} + +async function parseJsonResponse(response: Response): Promise { + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const raw = payload?.error; + const detail = typeof raw === 'object' && raw ? raw : {}; + throw new ApiError( + detail.message ?? (typeof raw === 'string' ? raw : `Request failed (${response.status})`), + response.status, + detail.code, + Boolean(detail.retryable), + ); + } + return payload as T; +} + +export async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(apiUrl(path), { + method, + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return parseJsonResponse(response); +} + +export async function requestForm(path: string, form: FormData): Promise { + const response = await fetch(apiUrl(path), { method: 'POST', body: form }); + return parseJsonResponse(response); +} + +export async function requestBlob(method: string, path: string, body?: Blob): Promise { + const response = await fetch(apiUrl(path), { method, body }); + return parseJsonResponse(response); +} + +export async function fallbackRequest(path: string, fallback: T): Promise { + try { + return await request('GET', path); + } catch { + return fallback; + } +} + +export function formatApiError( + error: unknown, + fallback = '操作失败,请稍后重试。', + t?: (key: string) => string, +) { + if (error instanceof ApiError) { + if (t && error.code) { + const key = `error.${error.code}`; + const translated = t(key); + if (translated !== key) return translated; + } + switch (error.code) { + case 'version_conflict': + return '内容已在后端更新,请刷新后再保存。'; + case 'file_conflict': + return '文件已被其他操作修改,请刷新后再保存。'; + case 'session_not_found': + return '会话不存在,可能已被删除或归档。'; + case 'session_busy': + return '会话正在进行中,请等待当前操作完成后再发送消息。'; + case 'run_not_found': + return '运行记录不存在。'; + case 'approval_not_found': + return '审批请求不存在或已过期。'; + case 'invalid_request': + return error.message || '请求格式有误,请检查输入。'; + case 'invalid_path': + return '文件路径不在允许的范围内。'; + case 'file_not_found': + return '文件不存在或已被删除。'; + case 'unsupported_file_type': + return '不支持该文件类型。'; + + // World + case 'world_not_found': + return '世界不存在或已被删除。'; + case 'world_create_failed': + return '创建世界失败,请检查名称是否重复。'; + case 'world_name_required': + return '世界名称不能为空。'; + + // Agent + case 'agent_not_found': + return '角色不存在或已被删除。'; + case 'agent_create_failed': + return '创建角色失败,请检查必填字段。'; + case 'agent_version_conflict': + return '角色信息已被其他操作更新,请刷新后再试。'; + + // Scene + case 'scene_not_found': + return '场景不存在或已被删除。'; + case 'scene_create_failed': + return '创建场景失败,请确认章节存在。'; + case 'scene_end_failed': + return '结束场景失败,场景可能已经结束。'; + case 'scene_status_invalid': + return '场景当前状态不支持此操作。'; + + // Chapter + case 'chapter_not_found': + return '章节不存在或已被删除。'; + case 'chapter_update_failed': + return '章节更新失败,请刷新后再试。'; + + // Diary + case 'information_boundary_leak': + return '日记内容包含角色不应知晓的信息,已被信息边界过滤。'; + case 'diary_write_failed': + return '日记写入失败,请稍后重试。'; + + // Foreshadowing / Secret + case 'foreshadow_not_found': + return '伏笔不存在,可能已被删除。'; + case 'foreshadowing_not_found': + return '伏笔不存在或已被删除。'; + case 'secret_not_found': + return '秘密不存在或已被删除。'; + case 'secret_status_invalid': + return '秘密当前状态不支持此操作。'; + + // Pipeline + case 'pipeline_not_available': + return '创作流水线暂不可用,请确认后端已启用 worldbuilding pipeline。'; + case 'pipeline_advance_blocked': + return '阶段推进被阻止:当前阶段条件未全部满足。请先完成当前阶段的所有要求。'; + case 'pipeline_phase_invalid': + return '目标阶段无效,请确认阶段名称正确。'; + case 'workflow_not_found': + return '指定的 Pipeline 工作流不存在。'; + + // Image service + case 'image_service_not_available': + return '图片服务未启用,请确认后端 Image Service 已初始化。'; + case 'image_upload_failed': + return '图片上传失败,请确认文件格式和大小符合要求。'; + case 'invalid_image_type': + return '图片类型必须是头像或人设图。'; + case 'image_not_found': + return '图片不存在或已被删除。'; + + // Config + case 'config_load_failed': + return '配置加载失败,请检查后端服务状态。'; + case 'config_save_failed': + return '配置保存失败,请稍后重试。'; + case 'title_generation_failed': + return '标题生成失败,请稍后重试。'; + case 'test_failed': + return `连接测试失败:${error.message}`; + case 'test_unavailable': + return '连接测试暂不可用,请检查后端配置。'; + + // General + case 'missing_param': + return error.message || '缺少必要参数,请检查输入。'; + case 'database_error': + return '数据库操作失败,请稍后重试。'; + + default: + return error.message || (t ? t('error.unknown') : fallback); + } + } + return error instanceof Error ? error.message : t ? t('error.unknown') : fallback; +} diff --git a/webui/src/api/index.ts b/webui/src/api/index.ts new file mode 100644 index 00000000..5f26a32c --- /dev/null +++ b/webui/src/api/index.ts @@ -0,0 +1,7 @@ +export { configApi } from './config'; +export { filesApi } from './files'; +export * from './http'; +export { runtimeApi } from './runtime'; +export * from './types'; +export { worldbuildingApi } from './worldbuilding'; +export { api } from './client'; diff --git a/webui/src/api/runtime.ts b/webui/src/api/runtime.ts new file mode 100644 index 00000000..c66f727e --- /dev/null +++ b/webui/src/api/runtime.ts @@ -0,0 +1,90 @@ +import { getApiBase, request } from './http'; +import type { + ApprovalResponse, + ArchiveSessionResponse, + AskResponse, + CancelRunResponse, + CreateSessionResponse, + GenerateTitleResponse, + ResolveCreationResponse, + RunDetailResponse, + RuntimeMetadata, + SessionListResponse, + SessionSummary, + StartRunResponse, + UpdateSessionResponse, +} from './types'; + +export const runtimeApi = { + metadata: () => request('GET', '/v1/runtime'), + + createSession: (title = '', worldId?: string, agentId?: string) => { + const body: Record = { title }; + if (worldId) body.world_id = worldId; + if (agentId) body.agent_id = agentId; + return request('POST', '/v1/sessions', body); + }, + + archiveSession: (session: SessionSummary, archived: boolean) => + request('POST', `/v1/sessions/${session.id}/archive`, { archived }), + + updateSession: (id: string, title: string) => + request('PATCH', `/v1/sessions/${id}`, { title }), + + generateTitle: (id: string) => + request('POST', `/v1/sessions/${id}/generate-title`), + + listSessions: (worldId?: string) => { + const query = worldId ? `?world_id=${encodeURIComponent(worldId)}` : ''; + return request('GET', `/v1/sessions${query}`); + }, + + getOrCreateAgentSession: (worldId: string, agentId: string) => + request<{ session: SessionSummary; created: boolean }>( + 'GET', + `/v1/worlds/${encodeURIComponent(worldId)}/agents/${encodeURIComponent(agentId)}/session`, + ), + + getSession: (id: string) => request('GET', `/v1/sessions/${id}`), + getRun: (runId: string) => request('GET', `/v1/runs/${runId}`), + events: (id: string, after = 0) => + request<{ events: unknown[] }>('GET', `/v1/sessions/${id}/events?after=${after}`), + memory: (id: string) => + request<{ session_id: string; items: unknown[] }>('GET', `/v1/sessions/${id}/memory`), + + startRun: (id: string, message: string, model = '') => + request('POST', `/v1/sessions/${id}/runs`, { + message, + ...(model ? { model } : {}), + }), + + startDelegation: ( + id: string, + pattern: string, + agents: string[], + task: string, + aggregation = 'all_results', + ) => + request<{ delegation_id: string; parent_run_id: string; session_id: string }>( + 'POST', + `/v1/sessions/${id}/delegations`, + { pattern, agents, task, aggregation }, + ), + + resolveApproval: (id: string, allow: boolean) => + request('POST', `/v1/approvals/${id}`, { + decision: allow ? 'allow' : 'deny', + }), + cancelRun: (id: string) => request('POST', `/v1/runs/${id}/cancel`), + respondToAsk: (runId: string, callId: string, response: string) => + request('POST', `/v1/runs/${runId}/ask-response`, { + call_id: callId, + response, + }), + resolveCreation: (id: string, decision: string, modifications?: Record) => + request('POST', `/v1/creations/${id}/resolve`, { + decision, + modifications, + }), + sseUrl: (id: string) => `${getApiBase()}/v1/sessions/${id}/events/stream`, +}; diff --git a/webui/src/api/types.ts b/webui/src/api/types.ts index 8047c44d..63287e68 100644 --- a/webui/src/api/types.ts +++ b/webui/src/api/types.ts @@ -4,6 +4,27 @@ export interface SseFrame { payload: Record; } +export interface PendingAsk { + runId: string; + callId: string; + question: string; + choices?: string[]; + multiSelect: boolean; +} + +export interface PendingCreation { + id: string; + runId: string; + toolName: string; + preview?: Record; +} + +export interface AskResponse { + ok: boolean; + run_id: string; + call_id: string; +} + export type MessageKind = 'user' | 'assistant' | 'tool' | 'system' | 'approval' | 'status_pill'; export type StatusLabel = @@ -223,6 +244,7 @@ export interface OkResponse { export interface OpenWorkspacePathResponse { ok: boolean; path: string; + error?: string; } export interface UiCapabilities { @@ -495,7 +517,6 @@ export interface DiaryListResponse { export interface MemorySummary { id: string; - agent_id: string; period_start: string; period_end: string; summary: string; @@ -503,6 +524,21 @@ export interface MemorySummary { created_at: string; } +export interface VoiceFingerprint { + avg_sentence_length: number; + sentence_variance: number; + question_frequency: number; + modifier_ratio: number; + sample_count: number; + signature_words: string[]; + tone_profile: Record; +} + +export interface VoiceFingerprintResponse { + ok: boolean; + voice: VoiceFingerprint | null; +} + export interface MemorySummaryListResponse { ok: boolean; summaries: MemorySummary[]; @@ -682,3 +718,108 @@ export interface ExportResult { file_path: string; total_chars: number; } + +export interface ResourceListResponse { + ok?: boolean; + items?: T[]; +} + +export interface LocationItem { + id: string; + name: string; + description?: string; + version?: number; +} + +export interface KnowledgeItem { + id: string; + title: string; + content?: string; + tags?: string[]; + version?: number; +} + +export interface FactionItem { + id: string; + name: string; + description?: string; + version?: number; +} + +export interface TimelineEvent { + id: string; + title: string; + world_time?: string; + description?: string; +} + +export interface GraphEntity { + id: string; + type: string; + name: string; +} + +export interface WorldFileLinkInput { + file_path: string; + entity_type?: string; + entity_id?: string; +} + +export type WorldFileLink = WorldFileLinkInput; + +export interface KnowledgeRecord { + id: string; + category: string; + content: string; + tags?: string[]; + aliases?: string[]; + related_ids?: string[]; + created_at?: string; +} + +export interface TimelineEventRecord { + id: string; + world_time: string; + description: string; + recorded_by?: string; + affected_character_ids?: string[]; + related_scene_ids?: string[]; +} + +export interface WorldFileLinkRecord { + file_path: string; + target_type: string; + target_id: string; + created_at?: string; +} + +export type LocationListResponse = ResourceListResponse & { + locations?: LocationItem[]; +}; + +export type KnowledgeListResponse = ResourceListResponse & { + knowledge?: KnowledgeRecord[]; +}; + +export type FactionListResponse = ResourceListResponse & { + factions?: FactionItem[]; +}; + +export interface TimelineCurrentTime { + day: number; + period: number; + label: string; +} + +export type TimelineResponse = ResourceListResponse & { + current_time: TimelineCurrentTime; + events?: TimelineEventRecord[]; +}; + +export type GraphEntityListResponse = ResourceListResponse & { + entities?: GraphEntity[]; +}; + +export type WorldFileListResponse = ResourceListResponse & { + files?: WorldFileLinkRecord[]; +}; diff --git a/webui/src/api/worldbuilding.ts b/webui/src/api/worldbuilding.ts new file mode 100644 index 00000000..0b1f985f --- /dev/null +++ b/webui/src/api/worldbuilding.ts @@ -0,0 +1,73 @@ +import { request } from './http'; +import type { + FactionItem, + FactionListResponse, + GraphEntity, + GraphEntityListResponse, + KnowledgeListResponse, + LocationListResponse, + OkResponse, + ResourceListResponse, + TimelineResponse, +} from './types'; + +function adaptList>( + response: TResponse, + namedItems?: T[], +): TResponse { + return { + ...response, + items: response.items ?? namedItems, + }; +} + +const worldPath = (worldId: string) => `/api/worldbuilding/${encodeURIComponent(worldId)}`; +const resourcePath = (worldId: string, resource: string, resourceId?: string) => + `${worldPath(worldId)}/${resource}${resourceId ? `/${encodeURIComponent(resourceId)}` : ''}`; + +export const worldbuildingApi = { + getDashboard: (worldId: string) => + request<{ ok?: boolean; dashboard?: Record }>( + 'POST', + `${worldPath(worldId)}/dashboard`, + ), + + listLocations: async (worldId: string) => + request('GET', resourcePath(worldId, 'locations')).then((response) => + adaptList(response, response.locations), + ), + + listKnowledge: async (worldId: string) => + request('GET', resourcePath(worldId, 'knowledge')).then((response) => + adaptList(response, response.knowledge), + ), + + listFactions: async (worldId: string) => + request('GET', resourcePath(worldId, 'factions')).then((response) => + adaptList(response, response.factions), + ), + + getTimeline: async (worldId: string) => + request('GET', resourcePath(worldId, 'timeline')).then((response) => + adaptList(response, response.events), + ), + + listGraphEntities: async (worldId: string) => + request('GET', resourcePath(worldId, 'knowledge-graph/entities')).then( + (response) => adaptList(response, response.entities), + ), + + reorderChapters: (worldId: string, chapterIds: string[]) => + request('POST', resourcePath(worldId, 'chapters/reorder'), { + order: chapterIds, + }), + + deleteLocation: (worldId: string, locationId: string) => + request('DELETE', resourcePath(worldId, 'locations', locationId)), + + deleteKnowledge: (worldId: string, knowledgeId: string) => + request('DELETE', resourcePath(worldId, 'knowledge', knowledgeId)), + + deleteFaction: (worldId: string, factionId: string) => + request('DELETE', resourcePath(worldId, 'factions', factionId)), +}; diff --git a/webui/src/components/AskUserPrompt.module.css b/webui/src/components/AskUserPrompt.module.css new file mode 100644 index 00000000..1fd9cfca --- /dev/null +++ b/webui/src/components/AskUserPrompt.module.css @@ -0,0 +1,75 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, 0.55); +} + +.card { + width: min(520px, 100%); + padding: 24px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); +} + +.question { + white-space: pre-wrap; +} +.choices { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0; +} +.choice, +.selected, +.submit { + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; +} +.choice { + background: var(--bg-secondary); + color: var(--text); +} +.selected, +.submit { + background: var(--accent); + color: white; +} +.label { + display: block; + margin: 16px 0 6px; + font-size: 0.9rem; +} +.input { + box-sizing: border-box; + width: 100%; + min-height: 88px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--input-bg); + color: var(--text); + resize: vertical; +} +.actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} +.error { + color: var(--ruby); +} +.submit:disabled, +.choice:disabled, +.selected:disabled { + cursor: not-allowed; + opacity: 0.6; +} diff --git a/webui/src/components/AskUserPrompt.tsx b/webui/src/components/AskUserPrompt.tsx new file mode 100644 index 00000000..2a5f5908 --- /dev/null +++ b/webui/src/components/AskUserPrompt.tsx @@ -0,0 +1,150 @@ +import { FormEvent, RefObject, useEffect, useRef, useState } from 'react'; +import { api, formatApiError } from '../api/client'; +import type { PendingAsk } from '../api/types'; +import styles from './AskUserPrompt.module.css'; + +interface Props { + request: PendingAsk; + onResolved: (callId: string) => void; +} + +const focusableSelector = + 'button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function useDialogFocus(dialogRef: RefObject) { + useEffect(() => { + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const dialog = dialogRef.current; + const focusables = () => + Array.from(dialog?.querySelectorAll(focusableSelector) ?? []); + if (dialog && !dialog.contains(document.activeElement)) focusables()[0]?.focus(); + + function trapFocus(event: KeyboardEvent) { + if (event.key !== 'Tab' || !dialog) return; + const elements = focusables(); + if (!elements.length) return; + const first = elements[0]; + const last = elements[elements.length - 1]; + if ( + event.shiftKey && + (document.activeElement === first || !dialog.contains(document.activeElement)) + ) { + event.preventDefault(); + last.focus(); + } else if ( + !event.shiftKey && + (document.activeElement === last || !dialog.contains(document.activeElement)) + ) { + event.preventDefault(); + first.focus(); + } + } + + dialog?.addEventListener('keydown', trapFocus); + return () => { + dialog?.removeEventListener('keydown', trapFocus); + previousFocus?.focus(); + }; + }, [dialogRef]); +} + +export default function AskUserPrompt({ request, onResolved }: Props) { + const [response, setResponse] = useState(''); + const [selectedChoices, setSelectedChoices] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const dialogRef = useRef(null); + const requestIdRef = useRef(request.callId); + requestIdRef.current = request.callId; + useDialogFocus(dialogRef); + + useEffect(() => { + setResponse(''); + setSelectedChoices([]); + setSubmitting(false); + setError(null); + }, [request.callId]); + + const answer = response.trim() || selectedChoices.join(', '); + + function toggleChoice(choice: string) { + if (request.multiSelect) { + setSelectedChoices((current) => + current.includes(choice) ? current.filter((item) => item !== choice) : [...current, choice], + ); + return; + } + setSelectedChoices([choice]); + setResponse(choice); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!answer || submitting) return; + setSubmitting(true); + setError(null); + const submittedCallId = request.callId; + try { + await api.respondToAsk(request.runId, request.callId, answer); + onResolved(request.callId); + } catch (cause) { + if (requestIdRef.current === submittedCallId) { + setError(formatApiError(cause, 'Could not send your response.')); + } + } finally { + if (requestIdRef.current === submittedCallId) setSubmitting(false); + } + } + + return ( +
+
+

Agent question

+

{request.question}

+ {request.choices?.length ? ( +
+ {request.choices.map((choice) => ( + + ))} +
+ ) : null} + +