diff --git a/.mstar/knowledge/architecture-patterns/developer-rail-app-rebuild.md b/.mstar/knowledge/architecture-patterns/developer-rail-app-rebuild.md new file mode 100644 index 000000000..f256e46fd --- /dev/null +++ b/.mstar/knowledge/architecture-patterns/developer-rail-app-rebuild.md @@ -0,0 +1,28 @@ +# Developer rail app rebuild pattern + +Promoted to: `.mstar/knowledge/architecture-patterns/developer-rail-app-rebuild.md` (this file). Derived from iteration v1.221-developer-rails (plans v221-modules-rail, v221-caps-sessions-rail); precedents: v1.220 developer shell (Hub/Harness), v1.219 shell skeleton. + +## When to use + +Rebuilding an `apps/web` rail app to the post-2026-10-22 generation standard (single SPA, app-shell IA per `.mstar/specs/proposed/surfaces/web-rebuild-ia.md`, web-first). Verified three times in v1.221 (modules, capabilities, sessions) after v1.220 established it (Hub, Harness). + +## The recipe (per rail app) + +1. **Routing/ownership first.** A rail app must have exactly one entry point. If a legacy surface is Settings-modal-owned or a compatibility redirect, delete the modal machinery outright (section descriptor, canonicalization special case, entrance-registry row, modal-primary "last safe location" special case) — no redirect; the product has never shipped. Verify with a repo-wide grep for the dead path (tracked sources only; `dist/` is gitignored). +2. **Move the body into `components/developer/`**, split at the **pure-view boundary**: presentational files import only `react`, `lucide-react`, `@42ch/nexus-ui`, `@/components/ui/*`, `@/lib/utils` (plus `./hub-card`, a sibling inside the presentational root, when composing cards). No hooks, router, i18n, transport, or query imports in chrome; copy arrives via props, data via view models, behavior via callbacks. App wiring (queries, search-param seed/write-back, copy assembly) stays in the page component. +3. **Registry wiring**: repoint the `AppDef.load` dynamic import in `lib/app-registry.ts`; AppId/route/icon unchanged. Entrance rules (`entrance-registry.ts`) keep the route row; delete modal-era rows. +4. **Design Studio fixture** (repo convention: every new visual surface, both themes, all variants). Wire through the established five points — reuse, never rebuild: `@web-developer` alias in design-studio `tsconfig.json` + `vite.config.ts` **and `vitest.config.ts`** (standalone config; forgetting it breaks fixture-test resolution), `EXTRACT_IMPORT_ROOTS` in `surface-source-badge.tsx`, `SURFACES_SHELL_ENTRIES` in `gallery-index.ts`, a sibling section in `surfaces.tsx` (`SurfacesShellPage`). No new Studio route. +5. **Locale parity**: the `locale-parity.test.ts` already registers all rail namespaces — verify, never modify. Chrome copy is assembled in the page and passed down; fixture copy is literal. +6. **Fixture tests must pin instance identity**: a chrome mounted N times in a gallery needs per-instance ids — derive ids with React `useId()` inside the chrome (self-consistent `htmlFor`/`id`), and assert pairwise-distinct ids with a negative check (reverting to the hardcoded id must turn the test red). Lesson: PR #444 Greptile round (validate-preset) and v1.221 QC (caps filter) both caught hardcoded ids. + +## Traps learned + +- Deep links are contracts: preserve every param form verbatim (`?module=&run=`, `?world=&branch=`, `?filter=`) with seed-on-mount/read-only semantics; canvas/timeline entries navigate with those params. +- Old `pages/-page.tsx` bodies sometimes are `` redirects — replacing them means the route finally renders a body; consumers calling `openSettings('')` are forced cascades. +- Status display reuses the v1.220 `statusVariant` mapping as-is (`running`/`completed` green, `failed` red); never invent a variant per app. +- List ordering contracts survive visual rebuilds: keep the documented sort (e.g. sessions F-F1) and assert DOM order equality against a deliberately non-alphabetical fixture list. +- Killed table layouts leave orphaned locale keys (`columns.*`) — sweep both locales. + +## Legacy deep-link disposition (v1.221 close-out sign-off) + +I-000085 / PR #445 SF-1: legacy `/settings/modules` deep links (in-app navigation shipped in v1.147, e.g. the canvas Run Module entry pinned `?world=…`) now fall back to the default settings section (`agent`), preserving the query string. **No alias, no redirect** — product decision 2026-10-24 (D4). Rationale: the route was only ever reachable from in-app navigation in pre-release builds; the product has never shipped to end users, so no external bookmark or tab outlives the change. Explicit sign-off: PM, 2026-10-24. If the product ever ships with `/settings/modules` reachable, revisit with a real alias before the release — this disposition expires with the "never shipped" premise. diff --git a/apps/design-studio/src/App.test.tsx b/apps/design-studio/src/App.test.tsx index 0fa419bc6..6f7b77021 100644 --- a/apps/design-studio/src/App.test.tsx +++ b/apps/design-studio/src/App.test.tsx @@ -1131,7 +1131,7 @@ describe('Surfaces page — Settings shell chrome fixtures', () => { ).not.toBeInTheDocument(); }); - it('renders section nav with Agent, Workspace, Appearance, Modules, Advanced', async () => { + it('renders section nav with Agent, Workspace, Appearance, Advanced', async () => { const hostRoot = screen.getByTestId('settings-host-fixtures'); const sectionNav = within(hostRoot).getByTestId('settings-section-nav'); expect( @@ -1143,9 +1143,6 @@ describe('Surfaces page — Settings shell chrome fixtures', () => { expect( within(sectionNav).getByTestId('settings-section-nav-appearance'), ).toHaveTextContent('Appearance'); - expect( - within(sectionNav).getByTestId('settings-section-nav-modules'), - ).toHaveTextContent('Modules'); expect( within(sectionNav).getByTestId('settings-section-nav-advanced'), ).toHaveTextContent('Advanced'); @@ -1231,9 +1228,6 @@ describe('Surfaces page — Settings shell chrome fixtures', () => { expect( within(framesRoot).getByTestId('settings-section-frame-appearance'), ).toBeInTheDocument(); - expect( - within(framesRoot).getByTestId('settings-section-frame-modules'), - ).toBeInTheDocument(); }); it('renders Agent section fixture with locked helper and preselected Codex (instant-apply)', async () => { diff --git a/apps/design-studio/src/components/__tests__/surface-source-badge.test.tsx b/apps/design-studio/src/components/__tests__/surface-source-badge.test.tsx index a36ae2ce2..c51ae8a0b 100644 --- a/apps/design-studio/src/components/__tests__/surface-source-badge.test.tsx +++ b/apps/design-studio/src/components/__tests__/surface-source-badge.test.tsx @@ -35,6 +35,12 @@ describe('classifySurfaceImport', () => { expect(classifySurfaceImport('@web-shell/selection-submenu')).toBe( 'extract', ); + expect( + classifySurfaceImport('@web-developer/modules-page-chrome'), + ).toBe('extract'); + expect(classifySurfaceImport('@web-developer/run-studio-chrome')).toBe( + 'extract', + ); }); it('rejects lookalike package prefixes at root/subpath boundaries', () => { diff --git a/apps/design-studio/src/components/surface-source-badge.tsx b/apps/design-studio/src/components/surface-source-badge.tsx index 857614c33..7ea9b6cd3 100644 --- a/apps/design-studio/src/components/surface-source-badge.tsx +++ b/apps/design-studio/src/components/surface-source-badge.tsx @@ -53,6 +53,7 @@ const EXTRACT_IMPORT_ROOTS: readonly string[] = [ '@web-settings', '@web-global-timeline', '@web-shell', + '@web-developer', ]; /** diff --git a/apps/design-studio/src/fixtures/__tests__/capabilities-rail-fixtures.test.tsx b/apps/design-studio/src/fixtures/__tests__/capabilities-rail-fixtures.test.tsx new file mode 100644 index 000000000..cdf8bfba0 --- /dev/null +++ b/apps/design-studio/src/fixtures/__tests__/capabilities-rail-fixtures.test.tsx @@ -0,0 +1,121 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { CapabilitiesRailFixtures } from '@/fixtures/capabilities-rail-fixtures'; +import { ThemeProvider } from '@/components/theme-provider'; + +/** + * The `/capabilities` chrome has four content states worth eyeballing: the + * registry with a filter that hits, a filter that misses (its own copy plus a + * Clear filter action), an empty registry (which wins over the miss), and the + * read error. Each frame owns one `data-testid`; the assertions below pin the + * state that makes the frame different from its siblings. + */ +const VARIANTS = [ + 'capabilities-rail-fixture-empty', + 'capabilities-rail-fixture-hit', + 'capabilities-rail-fixture-miss', + 'capabilities-rail-fixture-error', +] as const; + +afterEach(() => { + cleanup(); + document.documentElement.classList.remove('dark'); +}); + +describe('CapabilitiesRailFixtures', () => { + it('mounts every variant frame', () => { + render(); + + expect(screen.getByTestId('capabilities-rail-fixtures')).toBeInTheDocument(); + for (const testId of VARIANTS) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + }); + + it('gives every mounted body its own label/input id pair', () => { + render(); + + const ids = VARIANTS.map((testId) => { + const frame = screen.getByTestId(testId); + // getByLabelText resolves through htmlFor → id: each label reaches the + // filter rendered beside it, not the first frame in the document. + return within(frame).getByLabelText('Filter capabilities').id; + }); + + expect(new Set(ids).size).toBe(VARIANTS.length); + for (const id of ids) { + expect(id.length).toBeGreaterThan(0); + expect(document.querySelectorAll(`[id="${id}"]`)).toHaveLength(1); + } + }); + + it('renders the registry-empty copy even though a filter is typed', () => { + render(); + const frame = screen.getByTestId('capabilities-rail-fixture-empty'); + + expect(within(frame).getByText('No capabilities')).toBeInTheDocument(); + expect( + within(frame).getByText('Capabilities will appear here once the runtime registers them.'), + ).toBeInTheDocument(); + expect(within(frame).queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument(); + }); + + it('narrows the list to the matching capability on a filtered hit', () => { + render(); + const frame = screen.getByTestId('capabilities-rail-fixture-hit'); + + expect(within(frame).getByText('nexus.world.describe')).toBeInTheDocument(); + expect(within(frame).getByText('nexus.world.edit')).toBeInTheDocument(); + expect(within(frame).queryByText('sync.pull')).not.toBeInTheDocument(); + expect(within(frame).getByDisplayValue('world')).toBeInTheDocument(); + }); + + it('renders the filtered-miss copy quoting the query, and Clear filter restores the list', () => { + render(); + const frame = screen.getByTestId('capabilities-rail-fixture-miss'); + + expect( + within(frame).getByText('No capability matches "no.such.cap"'), + ).toBeInTheDocument(); + expect( + within(frame).getByText('Clear the filter to see all capabilities.'), + ).toBeInTheDocument(); + + fireEvent.click(within(frame).getByRole('button', { name: 'Clear filter' })); + + expect(within(frame).getByText('nexus.world.describe')).toBeInTheDocument(); + expect(within(frame).getByText('sync.pull')).toBeInTheDocument(); + expect(within(frame).getByDisplayValue('')).toBeInTheDocument(); + expect(within(frame).queryByText(/No capability matches/)).not.toBeInTheDocument(); + }); + + it('renders the read error as an alert with retry', () => { + render(); + const frame = screen.getByTestId('capabilities-rail-fixture-error'); + + expect(within(frame).getByText('Could not load capabilities.')).toBeInTheDocument(); + expect(within(frame).getByRole('alert')).toBeInTheDocument(); + expect(within(frame).queryByText('No capabilities')).not.toBeInTheDocument(); + }); + + it('renders every variant under both themes', () => { + for (const forced of ['light', 'dark'] as const) { + render( + + + , + ); + + // The Studio theme system is the authority for the marker (`.dark`); + // asserting it proves the fixture renders under the resolved theme, not + // just that a fixture-owned class survived. + expect(document.documentElement.classList.contains('dark')).toBe(forced === 'dark'); + for (const testId of VARIANTS) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + + cleanup(); + } + }); +}); diff --git a/apps/design-studio/src/fixtures/__tests__/modules-rail-fixtures.test.tsx b/apps/design-studio/src/fixtures/__tests__/modules-rail-fixtures.test.tsx new file mode 100644 index 000000000..cec5b8f98 --- /dev/null +++ b/apps/design-studio/src/fixtures/__tests__/modules-rail-fixtures.test.tsx @@ -0,0 +1,139 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ModulesRailFixtures } from '@/fixtures/modules-rail-fixtures'; +import { ThemeProvider } from '@/components/theme-provider'; + +/** + * The `/modules` rail has five states worth eyeballing: the ready list with a + * selection, the manifest detail beside the no-selection prompt, and the three + * Run Studio reads (pending / success / deep-linked detail error). Each frame + * owns one `data-testid`; the assertions below pin the state that makes the + * frame different from its siblings. + */ +const VARIANTS = [ + 'modules-rail-fixture-list', + 'modules-rail-fixture-detail', + 'modules-rail-fixture-run-pending', + 'modules-rail-fixture-run-success', + 'modules-rail-fixture-run-error', +] as const; + +afterEach(() => { + cleanup(); + document.documentElement.classList.remove('dark'); +}); + +describe('ModulesRailFixtures', () => { + it('mounts every variant frame', () => { + render(); + + expect(screen.getByTestId('modules-rail-fixtures')).toBeInTheDocument(); + for (const testId of VARIANTS) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + }); + + it('gives each mounted Run Studio its own control ids', () => { + render(); + const runFrames = [ + 'modules-rail-fixture-run-pending', + 'modules-rail-fixture-run-success', + 'modules-rail-fixture-run-error', + ] as const; + + // getByLabelText resolves through htmlFor → id: each label reaches the + // control rendered beside it, not the first Run Studio in the document. + // The schema-driven Run form derives its field ids as + // `${idPrefix}-${name}`; a literal prefix re-duplicated + // run-form-attacker_id across the three frames (I-000086 occ 97). + for (const label of [ + 'World', + 'Filter by status', + 'Filter by World', + 'Attacker', + 'Defender', + ]) { + const ids = runFrames.map((testId) => { + const frame = screen.getByTestId(testId); + return within(frame).getByLabelText(label).id; + }); + + expect(new Set(ids).size).toBe(runFrames.length); + for (const id of ids) { + expect(id.length).toBeGreaterThan(0); + expect(document.querySelectorAll(`[id="${id}"]`)).toHaveLength(1); + } + } + }); + + it('renders the ready list with one selected module', () => { + render(); + const frame = screen.getByTestId('modules-rail-fixture-list'); + + expect(within(frame).getByTestId('modules-page-body')).toBeInTheDocument(); + expect(within(frame).getByRole('button', { name: 'Basic Combat', pressed: true })).toBeInTheDocument(); + expect( + within(frame).getByRole('button', { name: 'Scouting Party', pressed: false }), + ).toBeInTheDocument(); + }); + + it('renders manifest fields beside the select prompt', () => { + render(); + const frame = screen.getByTestId('modules-rail-fixture-detail'); + + expect(within(frame).getByText('Select a module')).toBeInTheDocument(); + expect(within(frame).getByRole('heading', { name: 'Basic Combat' })).toBeInTheDocument(); + expect(within(frame).getByText('ABI version')).toBeInTheDocument(); + expect(within(frame).getByText('3')).toBeInTheDocument(); + expect(within(frame).getByText('Host functions')).toBeInTheDocument(); + expect(within(frame).getByText('Schemas')).toBeInTheDocument(); + }); + + it('renders the pending Run state', () => { + render(); + const frame = screen.getByTestId('modules-rail-fixture-run-pending'); + + expect(within(frame).getByTestId('run-studio')).toBeInTheDocument(); + expect(within(frame).getByRole('button', { name: 'Running…' })).toBeDisabled(); + expect(within(frame).getByText('This Run is still executing.')).toBeInTheDocument(); + }); + + it('renders the proposal inspector for a Run awaiting review', () => { + render(); + const frame = screen.getByTestId('modules-rail-fixture-run-success'); + + expect(within(frame).getByTestId('run-inspector')).toBeInTheDocument(); + expect(within(frame).getByText('Aria strikes Brann')).toBeInTheDocument(); + expect(within(frame).getByTestId('run-inspector-accept')).toBeEnabled(); + expect(within(frame).getByTestId('run-inspector-discard')).toBeEnabled(); + }); + + it('renders the deep-linked Run detail read error', () => { + render(); + const frame = screen.getByTestId('modules-rail-fixture-run-error'); + + expect(within(frame).getByText('Could not load this Run')).toBeInTheDocument(); + expect(within(frame).queryByTestId('run-inspector')).not.toBeInTheDocument(); + }); + + it('renders every variant under both themes', () => { + for (const forced of ['light', 'dark'] as const) { + render( + + + , + ); + + // The Studio theme system is the authority for the marker (`.dark`); + // asserting it proves the fixture renders under the resolved theme, not + // just that a fixture-owned class survived. + expect(document.documentElement.classList.contains('dark')).toBe(forced === 'dark'); + for (const testId of VARIANTS) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + + cleanup(); + } + }); +}); diff --git a/apps/design-studio/src/fixtures/__tests__/sessions-rail-fixtures.test.tsx b/apps/design-studio/src/fixtures/__tests__/sessions-rail-fixtures.test.tsx new file mode 100644 index 000000000..bb30df652 --- /dev/null +++ b/apps/design-studio/src/fixtures/__tests__/sessions-rail-fixtures.test.tsx @@ -0,0 +1,102 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { SessionsRailFixtures } from '@/fixtures/sessions-rail-fixtures'; + +/** + * The `/sessions` rail has two states worth eyeballing: the mixed + * running/completed/failed card grid (each card one link to its run view) and + * the zero-session empty state. Assertions pin what distinguishes them; the + * theme pass proves the token-backed frame survives the dark toggle. + */ +const VARIANTS = ['sessions-rail-fixture-mixed', 'sessions-rail-fixture-empty'] as const; + +afterEach(() => { + cleanup(); + document.documentElement.classList.remove('dark'); +}); + +describe('SessionsRailFixtures', () => { + it('mounts every variant frame', () => { + render(); + + expect(screen.getByTestId('sessions-rail-fixtures')).toBeInTheDocument(); + for (const testId of VARIANTS) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + }); + + it('renders one whole-card link per session in list order', () => { + render(); + const frame = screen.getByTestId('sessions-rail-fixture-mixed'); + const links = within(frame).getAllByRole('link'); + + expect(links.map((link) => link.getAttribute('href'))).toEqual([ + '/sessions/sess-7f3a91c2d4e8', + '/sessions/sess-2b8c4a', + '/sessions/sess-9d1e0f', + ]); + expect(links.map((link) => link.getAttribute('aria-label'))).toEqual([ + 'Open run view for session sess-7f3…d4e8', + 'Open run view for session sess-2b8c4a', + 'Open run view for session sess-9d1e0f', + ]); + // One affordance per card, nothing interactive nested inside it. + for (const link of links) { + expect(within(link).queryAllByRole('link')).toHaveLength(0); + expect(within(link).queryAllByRole('button')).toHaveLength(0); + } + }); + + it('shows short id, pill, preset and creator on each card', () => { + render(); + const frame = screen.getByTestId('sessions-rail-fixture-mixed'); + + const running = within(frame).getByRole('link', { + name: 'Open run view for session sess-7f3…d4e8', + }); + expect(within(running).getByText('sess-7f3…d4e8')).toBeInTheDocument(); + expect(within(running).getByText('Preset')).toBeInTheDocument(); + expect(within(running).getByText('novel-writing')).toBeInTheDocument(); + expect(within(running).getByText('Creator')).toBeInTheDocument(); + expect(within(running).getByText('author-01')).toBeInTheDocument(); + // No `current_task_id` on the card. + expect(within(running).queryByText(/Current task/i)).not.toBeInTheDocument(); + }); + + it('paints completed as the green pill and failed as the red one', () => { + render(); + + expect( + screen.getByText('Completed').className, + ).toContain('nexus-ui-badge-soft-running-bg'); + expect(screen.getByText('Failed').className).toContain('nexus-ui-badge-soft-error-bg'); + }); + + it('renders the zero-session empty copy with no cards', () => { + render(); + const frame = screen.getByTestId('sessions-rail-fixture-empty'); + + expect(within(frame).getByText('No sessions yet')).toBeInTheDocument(); + expect( + within(frame).getByText('Sessions appear here when a preset run starts.'), + ).toBeInTheDocument(); + expect(within(frame).queryAllByRole('link')).toHaveLength(0); + }); + + it('renders every variant under both themes', () => { + for (const theme of ['light', 'dark'] as const) { + document.documentElement.classList.toggle('dark', theme === 'dark'); + + render(); + + for (const testId of VARIANTS) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + // Token-backed frame class survives the toggle — the card has no theme prop. + expect(screen.getByTestId('hub-card-session-sess-2b8c4a')).toHaveClass('bg-background-100'); + + cleanup(); + } + }); +}); diff --git a/apps/design-studio/src/fixtures/capabilities-rail-fixtures.tsx b/apps/design-studio/src/fixtures/capabilities-rail-fixtures.tsx new file mode 100644 index 000000000..42a214d83 --- /dev/null +++ b/apps/design-studio/src/fixtures/capabilities-rail-fixtures.tsx @@ -0,0 +1,174 @@ +import { useState, type ReactNode } from 'react'; + +import { + CapabilitiesPageChrome, + type CapabilitiesListView, + type CapabilitiesPageCopy, + type CapabilityView, +} from '@web-developer/capabilities-page-chrome'; + +/** + * Studio fixture for the `/capabilities` surface (plan v221-caps-sessions-rail + * T1). Chrome rendered from static view models with literal copy — no daemon, + * no router, no QueryClient. Four states: registry empty, filtered hit, + * filtered miss (with Clear filter), and the read error. + */ + +/* ------------------------------------------------------------------ */ +/* Caller-owned copy (mirrors locales/en/capabilities.json) */ +/* ------------------------------------------------------------------ */ + +const COPY: CapabilitiesPageCopy = { + title: 'Capabilities', + description: 'The nexus.* capabilities the runtime can invoke.', + filterLabel: 'Filter capabilities', + filterPlaceholder: 'Filter by name', + refresh: 'Refresh', + refreshAria: 'Refresh capabilities', + errorDescription: 'Could not load capabilities.', + loading: 'Loading capabilities…', + emptyTitle: 'No capabilities', + emptyDescription: 'Capabilities will appear here once the runtime registers them.', + filteredMissTitle: (filter) => `No capability matches "${filter}"`, + filteredMissDescription: 'Clear the filter to see all capabilities.', + clearFilter: 'Clear filter', + inputSchema: 'Input schema', + outputSchema: 'Output schema', + userBadge: 'Local', + localOnlyCopy: 'Local module — no distribution or signing', + admissionGatesInfo: + 'Admission gates are enforced by the daemon when this capability is invoked. Gate details are not exposed in the registry list response.', +}; + +/* ------------------------------------------------------------------ */ +/* Fake data */ +/* ------------------------------------------------------------------ */ + +const CAPABILITIES: readonly CapabilityView[] = [ + { + name: 'nexus.world.describe', + inputSchema: '{"type":"object","properties":{"worldId":{"type":"string"}}}', + outputSchema: '{"type":"string"}', + origin: 'builtin', + }, + { + name: 'nexus.world.edit', + inputSchema: '{"type":"object","properties":{"ops":{"type":"array"}}}', + outputSchema: '{"type":"object"}', + origin: 'builtin', + }, + { + name: 'sync.pull', + inputSchema: '{"type":"object","properties":{"remote":{"type":"string"}}}', + outputSchema: '{"type":"string"}', + origin: 'user', + }, +]; + +function list(items: readonly CapabilityView[] | undefined, extra?: Partial): CapabilitiesListView { + return { + items, + isLoading: false, + isError: false, + isRefreshing: false, + onRefresh: noop, + ...extra, + }; +} + +function noop() { + /* Studio fixture only — actions have nowhere to go. */ +} + +/* ------------------------------------------------------------------ */ +/* Frames */ +/* ------------------------------------------------------------------ */ + +function Frame({ + title, + description, + testId, + children, +}: { + title: string; + description: string; + testId: string; + children: ReactNode; +}) { + return ( +
+

{title}

+

{description}

+ {children} +
+ ); +} + +/** One interactive frame — the filter input is live so Clear filter works. */ +function StatefulFrame({ + title, + description, + testId, + list: listView, + initialFilter, +}: { + title: string; + description: string; + testId: string; + list: CapabilitiesListView; + initialFilter: string; +}) { + const [filter, setFilter] = useState(initialFilter); + + return ( + + + + ); +} + +export function CapabilitiesRailFixtures() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx b/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx new file mode 100644 index 000000000..6021b58ed --- /dev/null +++ b/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx @@ -0,0 +1,503 @@ +import { useState, type ReactNode } from 'react'; + +import { + type ComputeProposals, + type EntityPickerEntry, + type ProposalSectionsCopy, + type RunFormCopy, + type RunStatus, + type RunsTableCopy, + type RunTableRow, +} from '@42ch/nexus-ui'; + +import { + ModuleDetailChrome, + ModulesPageChrome, + type ModuleDetailCopy, + type ModuleDetailView, + type ModuleManifestView, + type ModulesListView, + type ModulesPageCopy, + type ModuleSummaryView, +} from '@web-developer/modules-page-chrome'; +import { + RunStudioChrome, + type RunStudioChromeProps, + type RunStudioCopy, +} from '@web-developer/run-studio-chrome'; + +/** + * Studio fixture for the `/modules` surface (plan v221-modules-rail T3). + * + * Renders the three chrome roots from the developer generation — + * `ModulesPageChrome`, `ModuleDetailChrome`, `RunStudioChrome` — from static + * view models and literal English copy: + * + * - list (ready, one selected) + * - detail (manifest fields + the `selectTitle` prompt) + * - run-pending / run-success (proposal visible) / run-error (detail read error) + * + * The chrome is pure presentation: no transport, no router, no i18n, no + * QueryClient. Every string below is literal (Studio is developer-auxiliary + * and excluded from i18n catalogs); every value is fake. Toggle the Studio + * theme to check light + dark — there is no per-fixture theme prop because + * every class is token-backed. + */ + +/* ------------------------------------------------------------------ */ +/* Caller-owned copy (mirrors locales/en/modules.json) */ +/* ------------------------------------------------------------------ */ + +const PAGE_COPY: ModulesPageCopy = { + title: 'Compute Modules', + description: 'Installed compute modules that power the local runtime.', + refreshAria: 'Refresh modules', + refresh: 'Refresh', + loading: 'Loading modules…', + listTitle: 'Installed modules', + listAriaLabel: 'Installed modules', + errorTitle: 'Could not load modules', + errorDescription: 'Could not load compute modules.', + engineUnavailableTitle: 'Orchestration engine not running', + engineUnavailableDescription: 'Start the daemon orchestration engine, then try again.', + emptyTitle: 'No modules installed', + emptyDescription: 'Compute modules will appear here once the runtime registers them.', + selectTitle: 'Select a module', + selectDescription: 'Choose a module from the list to inspect its manifest.', +}; + +const DETAIL_COPY: ModuleDetailCopy = { + title: 'Module manifest', + version: (version) => `v${version}`, + loading: 'Loading module detail…', + errorTitle: 'Could not load module detail', + errorDescription: 'Could not load module detail.', + engineUnavailableTitle: 'Orchestration engine not running', + engineUnavailableDescription: 'Start the daemon orchestration engine, then try again.', + abiVersion: 'ABI version', + author: 'Author', + computeExport: 'Compute export', + initExport: 'Init export', + maxFuel: 'Max fuel', + maxMemory: 'Max memory (MiB)', + maxWallTime: 'Max wall time (ms)', + requiredKeyBlockTypes: 'Required knowledge entry types', + hostFunctions: 'Host functions', + battleReportKind: 'Battle report kind', + schemas: 'Schemas', + keyBlockAttributes: 'Knowledge entry attributes', + keyBlockState: 'Knowledge entry state', + invocation: 'Invocation', + battleReport: 'Battle report', +}; + +const FORM_COPY: RunFormCopy = { + emptyTitle: 'Can’t run this module', + emptyDescription: + 'Manifest is missing fields needed to build a form; use another module or fix the install.', + unsupportedFieldNote: + 'Not available in the guided form — open Advanced: edit invocation JSON below.', + entityPlaceholder: 'Choose a character', + selectPlaceholder: 'Choose a value', + entityEmptyTitle: 'No characters to run', + entityEmptyDescription: 'Add character knowledge entries in this World, then return.', +}; + +const PROPOSAL_COPY: ProposalSectionsCopy = { + reportTitle: 'Report', + knowledgeUpdatesTitle: 'Knowledge updates', + timelineEventsTitle: 'Timeline events', + newKnowledgeTitle: 'New knowledge', + truncatedNote: 'This preview is shortened — open the Run for the full result.', + untitledEventLabel: 'Untitled event', + affectedEntriesLabel: (count) => `Affects ${count} ${count === 1 ? 'entry' : 'entries'}`, + newEntryLabel: 'New entry', +}; + +const RUNS_COPY: RunsTableCopy = { + moduleColumn: 'Module', + worldColumn: 'World', + statusColumn: 'Status', + startedColumn: 'Started', + finishedColumn: 'Finished', + runIdColumn: 'Run ID', + openRunLabel: 'Open', + copyIdLabel: 'Copy', + emptyTitle: 'No runs yet', + emptyDescription: 'Run this module to see history here.', +}; + +const STATUS_LABELS: Record = { + running: 'Running', + succeeded: 'Needs review', + failed: 'Failed', + applied: 'Applied', + discarded: 'Discarded', +}; + +const RUN_COPY: RunStudioCopy = { + title: 'Run Studio', + worldLabel: 'World', + worldPlaceholder: 'Choose a World', + branchNote: 'Branch: root (default).', + noWorldHint: 'Choose a World to run this module.', + worldsErrorTitle: 'Could not load Worlds', + worldsErrorDescription: 'Worlds are unavailable right now. Try again.', + graphErrorTitle: 'Could not load World characters', + graphErrorDescription: 'Character entries are unavailable right now. Try again.', + graphLoading: 'Loading characters…', + advancedJson: 'Advanced: edit invocation JSON', + advancedJsonWarning: 'Form fields sync best-effort when you edit the raw JSON.', + runButton: 'Run', + runPending: 'Running…', + runLoadingDetail: 'Loading Run…', + runErrorTitle: 'Could not load this Run', + runErrorDescription: + 'Run detail is unavailable right now — the Run may have been cleared, or the daemon is unreachable. Try again.', + runsTitle: 'Runs', + statusFilter: 'Filter by status', + allStatuses: 'All statuses', + worldFilter: 'Filter by World', + allWorlds: 'All Worlds', + clearHistory: 'Clear history', + clearDisabledHint: 'Choose a World to clear history.', + runsLoading: 'Loading Runs…', + runsErrorTitle: 'Could not load Runs', + runsErrorDescription: 'Runs history is unavailable right now. Try again.', + loadMore: 'Load more', + loadingMore: 'Loading…', + statusOptions: [ + { value: 'running', label: STATUS_LABELS.running }, + { value: 'succeeded', label: STATUS_LABELS.succeeded }, + { value: 'applied', label: STATUS_LABELS.applied }, + { value: 'discarded', label: STATUS_LABELS.discarded }, + { value: 'failed', label: STATUS_LABELS.failed }, + ], + statusLabel: (status) => STATUS_LABELS[status], + accept: 'Accept', + discard: 'Discard', + acceptAria: 'Accept all proposals for this Run', + discardAria: 'Discard this Run’s proposals', + acceptNote: 'Accept commits all proposals from this Run atomically.', + stoppedTitle: 'Run stopped (limit)', + failedTitle: 'Run failed', + stoppedDescription: 'The module hit a safety limit; simplify inputs or retry. World unchanged.', + failedDescription: (reason) => + `The module returned an error: ${reason}. World unchanged. Open Run for details.`, + failedCodeLabel: 'Error code', + appliedNote: 'Proposals applied to the World.', + discardedNote: 'Proposals discarded. World unchanged.', + runningNote: 'This Run is still executing.', + proposalsCopy: PROPOSAL_COPY, +}; + +/* ------------------------------------------------------------------ */ +/* Fake data */ +/* ------------------------------------------------------------------ */ + +const CHARACTERS: EntityPickerEntry[] = [ + { id: 'char-aria', title: 'Aria', subtitle: 'Level 3 · ATK 12' }, + { id: 'char-brann', title: 'Brann', subtitle: 'Level 2 · ATK 9' }, +]; + +/** Mirrors modules/basic-combat/manifest.json schemas.invocation. */ +const BASIC_COMBAT_SCHEMA = { + type: 'object', + properties: { + attacker_id: { type: 'string' }, + defender_id: { type: 'string' }, + }, +}; + +const BASIC_COMBAT_MANIFEST: ModuleManifestView = { + module_id: 'basic-combat', + name: 'Basic Combat', + version: '1.0.0', + nexus_abi_version: 3, + compute_export: 'compute', + init_export: 'init', + description: 'Simple combat resolution between two characters.', + author: 'Nexus Core', + host_functions: ['read_key_block', 'write_key_block', 'emit_timeline_event'], + required_key_block_types: ['character'], + battle_report_kind: 'combat', + max_fuel: 1_000_000, + max_memory_mib: 64, + max_wall_time_ms: 5_000, + schemas: { + key_block_attributes: { type: 'object', properties: { hp: { type: 'integer' } } }, + key_block_state: { type: 'object', properties: { current_hp: { type: 'integer' } } }, + invocation: BASIC_COMBAT_SCHEMA, + battle_report: { type: 'object', properties: { damage: { type: 'integer' } } }, + }, +}; + +const MODULE_SUMMARIES: ModuleSummaryView[] = [ + { + module_id: 'basic-combat', + name: 'Basic Combat', + version: '1.0.0', + description: 'Simple combat resolution between two characters.', + required_key_block_types: ['character'], + battle_report_kind: 'combat', + }, + { + module_id: 'scouting-party', + name: 'Scouting Party', + version: '0.4.2', + description: 'Reveals adjacent regions for the travelling party.', + required_key_block_types: ['character', 'region'], + }, +]; + +const READY_LIST: ModulesListView = { + items: MODULE_SUMMARIES, + isLoading: false, + isError: false, + isRefreshing: false, + onRefresh: noop, +}; + +const MANIFEST_DETAIL: ModuleDetailView = { + data: BASIC_COMBAT_MANIFEST, + isLoading: false, + isError: false, + onRetry: noop, +}; + +const SUCCESS_PROPOSALS: ComputeProposals = { + schema_version: 1, + state_delta: [ + { + op: 'sub', + path: 'character.current_hp', + target_key_block_id: 'char-brann', + value: { amount: 6 }, + }, + ], + timeline_events: [ + { + title: 'Aria strikes Brann', + summary: 'Brann takes 6 damage and staggers back.', + affected_key_block_ids: ['char-aria', 'char-brann'], + }, + ], + new_key_blocks: [{ title: 'Bruised rib', kind: 'injury', severity: 'minor' }], + battle_report: { kind: 'combat', damage: 6, defender_hp_after: 14 }, +}; + +const RUN_ROWS: RunTableRow[] = [ + { + runId: 'run_9f3a2c', + moduleName: 'Basic Combat', + moduleVersion: '1.0.0', + worldTitle: 'The Lost City', + status: 'running', + statusLabel: STATUS_LABELS.running, + startedAt: '2026-07-31 14:02', + }, + { + runId: 'run_71bd04', + moduleName: 'Basic Combat', + moduleVersion: '1.0.0', + worldTitle: 'The Lost City', + status: 'succeeded', + statusLabel: STATUS_LABELS.succeeded, + startedAt: '2026-07-31 13:47', + finishedAt: '2026-07-31 13:47', + }, +]; + +function noop() { + /* Studio fixture only — actions have nowhere to go. */ +} + +/* ------------------------------------------------------------------ */ +/* View models */ +/* ------------------------------------------------------------------ */ + +/** One Run Studio base — the three run variants differ only in run/proposal. */ +function studioProps( + run: RunStudioChromeProps['run'], + proposal: RunStudioChromeProps['proposal'], +): RunStudioChromeProps { + return { + copy: RUN_COPY, + world: { + options: [ + { world_id: 'w-lost-city', title: 'The Lost City' }, + { world_id: 'w-echo', title: 'Echo Protocol' }, + ], + value: 'w-lost-city', + onChange: noop, + isLoading: false, + isError: false, + onRetry: noop, + }, + form: { + schema: BASIC_COMBAT_SCHEMA, + requiredKeyBlockTypes: ['character'], + values: { attacker_id: 'char-aria', defender_id: 'char-brann' }, + onChange: noop, + entityEntries: { attacker_id: CHARACTERS, defender_id: CHARACTERS }, + copy: FORM_COPY, + graph: null, + }, + advanced: { + open: false, + onOpenChange: noop, + json: '{\n "attacker_id": "char-aria",\n "defender_id": "char-brann"\n}', + onJsonChange: noop, + error: null, + }, + run, + proposal, + runs: { + rows: RUN_ROWS, + copy: RUNS_COPY, + isLoading: false, + isError: false, + onRetry: noop, + statusFilter: '', + onStatusFilterChange: noop, + worldFilter: 'w-lost-city', + onWorldFilterChange: noop, + clearScopeWorldId: 'w-lost-city', + clearPending: false, + onClear: noop, + onOpenRun: noop, + hasNextPage: false, + isFetchingNextPage: false, + onLoadMore: noop, + }, + }; +} + +const IDLE_RUN: RunStudioChromeProps['run'] = { + pending: false, + canRun: true, + onRun: noop, + detailLoading: false, + detailError: false, + onDetailRetry: noop, +}; + +const IDLE_PROPOSAL: RunStudioChromeProps['proposal'] = { + run: null, + truncated: false, + acceptPending: false, + discardPending: false, + onAccept: noop, + onDiscard: noop, +}; + +/* ------------------------------------------------------------------ */ +/* Frames */ +/* ------------------------------------------------------------------ */ + +function Frame({ + title, + description, + testId, + children, +}: { + title: string; + description: string; + testId: string; + children: ReactNode; +}) { + return ( +
+

{title}

+

{description}

+ {children} +
+ ); +} + +export function ModulesRailFixtures() { + // Selection is real state so clicking a row exercises the chrome's own + // selection highlight; the initial value is the fixture's "one selected". + const [selectedId, setSelectedId] = useState('basic-combat'); + + return ( +
+ + } + /> + + + +
+ + +
+ + + + + + + + + + + + + +
+ ); +} diff --git a/apps/design-studio/src/fixtures/sessions-rail-fixtures.tsx b/apps/design-studio/src/fixtures/sessions-rail-fixtures.tsx new file mode 100644 index 000000000..6e900d8b8 --- /dev/null +++ b/apps/design-studio/src/fixtures/sessions-rail-fixtures.tsx @@ -0,0 +1,96 @@ +import { EmptyState } from '@42ch/nexus-ui'; +import { + SessionCardChrome, + SessionCardGrid, + type SessionCardCopy, + type SessionCardLinkArgs, + type SessionCardView, +} from '@web-developer/session-card'; + +/** + * Studio fixture for the `/sessions` rail (plan v221-caps-sessions-rail T2). + * Cards rendered from static view models with literal copy and a plain `` + * link renderer — no router, no QueryClient, no daemon. Two frames: the mixed + * running/completed/failed grid and the zero-session empty state. + */ + +/* ------------------------------------------------------------------ */ +/* Caller-owned copy (mirrors locales/en/sessions.json) */ +/* ------------------------------------------------------------------ */ + +const COPY: SessionCardCopy = { + openRunAria: (id) => `Open run view for session ${id}`, + presetLabel: 'Preset', + creatorLabel: 'Creator', +}; + +/* ------------------------------------------------------------------ */ +/* Fake data */ +/* ------------------------------------------------------------------ */ + +const MIXED: readonly SessionCardView[] = [ + { + sessionId: 'sess-7f3a91c2d4e8', + shortId: 'sess-7f3…d4e8', + status: { label: 'Running', variant: 'running' }, + preset: 'novel-writing', + creator: 'author-01', + }, + { + sessionId: 'sess-2b8c4a', + shortId: 'sess-2b8c4a', + status: { label: 'Completed', variant: 'running' }, + preset: 'world-build', + creator: 'author-02', + }, + { + sessionId: 'sess-9d1e0f', + shortId: 'sess-9d1e0f', + status: { label: 'Failed', variant: 'error' }, + preset: 'outline-pass', + creator: 'author-01', + }, +]; + +/** The gallery's link is a plain anchor; the app injects a router ``. */ +function renderLink({ href, ariaLabel, children }: SessionCardLinkArgs) { + return ( + + {children} + + ); +} + +export function SessionsRailFixtures() { + return ( +
+
+

+ Mixed statuses — whole card opens the run view +

+ + {MIXED.map((card) => ( + + ))} + +
+ +
+

Zero sessions

+ +
+
+ ); +} diff --git a/apps/design-studio/src/fixtures/settings-host-fixtures.tsx b/apps/design-studio/src/fixtures/settings-host-fixtures.tsx index 99e5b050c..0a0f73819 100644 --- a/apps/design-studio/src/fixtures/settings-host-fixtures.tsx +++ b/apps/design-studio/src/fixtures/settings-host-fixtures.tsx @@ -23,7 +23,6 @@ import { useState, type ReactNode } from 'react'; import { Bot, - Cpu, FolderOpen, Palette, RotateCcw, @@ -61,13 +60,12 @@ import { /** * Five-section Settings registry chrome — matches apps/web - * SETTINGS_SECTION_IDS (V1.131 P2): agent, workspace, appearance, modules, advanced. + * SETTINGS_SECTION_IDS (V1.131 P2): agent, workspace, appearance, advanced. */ export type SettingsNavSectionId = | 'agent' | 'workspace' | 'appearance' - | 'modules' | 'advanced'; /** Section body IDs for chrome fixtures; Connection and Setup live inside Advanced. */ @@ -76,8 +74,7 @@ export type SettingsBodySectionId = | 'connection' | 'setup' | 'workspace' - | 'appearance' - | 'modules'; + | 'appearance'; const SETTINGS_NAV_SECTIONS: { id: SettingsNavSectionId; @@ -87,7 +84,6 @@ const SETTINGS_NAV_SECTIONS: { { id: 'agent', label: 'Agent', icon: Bot }, { id: 'workspace', label: 'Workspace', icon: FolderOpen }, { id: 'appearance', label: 'Appearance', icon: Palette }, - { id: 'modules', label: 'Modules', icon: Cpu }, { id: 'advanced', label: 'Advanced', icon: Settings }, ]; @@ -131,13 +127,6 @@ const SETTINGS_SECTIONS: { emptyHint: 'Appearance section body mounts in the shell outlet (see Appearance section fixture).', }, - { - id: 'modules', - label: 'Modules', - icon: Cpu, - emptyHint: - 'Modules section body mounts in the shell outlet (see Modules section fixture).', - }, ]; const SHELL_HELPER = @@ -206,7 +195,7 @@ const FIXTURE_AGENTS: AgentPickerItem[] = [ /** * Secondary section nav inside Settings page chrome. * Not Creator/Orchestrator tabs; not a second app-wide sidebar. - * V1.131 P2: five sections — Agent / Workspace / Appearance / Modules / Advanced. + * V1.131 P2: four sections — Agent / Workspace / Appearance / Advanced. */ function SettingsSectionNav({ active, @@ -547,30 +536,6 @@ function SettingsAppearanceSectionChrome() { ); } -/** Modules section body chrome — compute modules list placeholder (V1.131 P2). */ -function SettingsModulesSectionChrome() { - return ( -
-
-

Modules

-

- Browse installed compute modules available to this workspace. -

-
- - - Installed modules - - -

- Presentational chrome — App mounts the Modules list here. -

-
-
-
- ); -} - function InteractiveSettingsShellPage() { const [active, setActive] = useState('agent'); return ( @@ -584,8 +549,6 @@ function InteractiveSettingsShellPage() { ) : active === 'appearance' ? ( - ) : active === 'modules' ? ( - ) : ( )} @@ -666,8 +629,8 @@ function SettingsShellChromeFixture() { /** * Settings shell chrome + empty section frames for Studio visual acceptance. - * V1.131 P2: section nav shows Agent / Workspace / Appearance / Modules / - * Advanced; Connection and Setup live inside Advanced. + * V1.131 P2: section nav shows Agent / Workspace / Appearance / Advanced; + * Connection and Setup live inside Advanced. */ export function SettingsHostFixtures() { return ( @@ -683,7 +646,7 @@ export function SettingsHostFixtures() { Footer mode switch is the only primary Creator|Orchestrator control. Workspace profiles sit under Orchestrator only. The main panel is the Settings modal frame — section nav (Agent / Workspace / Appearance / - Modules / Advanced) with representative clean section bodies. + Advanced) with representative clean section bodies.

@@ -817,8 +780,8 @@ export function SettingsHostFixtures() {

Static empty outlet frames for Agent / Connection / Setup / Workspace / - Appearance / Modules placeholders. Shell outlet mounts the five-section - registry bodies; empty frames remain as visual reference. + Appearance placeholders. Shell outlet mounts the four-section registry + bodies; empty frames remain as visual reference.

{SETTINGS_SECTIONS.map(({ id }) => ( diff --git a/apps/design-studio/src/fixtures/timeline-compute.tsx b/apps/design-studio/src/fixtures/timeline-compute.tsx index 69f86e10d..abf7c8ca8 100644 --- a/apps/design-studio/src/fixtures/timeline-compute.tsx +++ b/apps/design-studio/src/fixtures/timeline-compute.tsx @@ -290,7 +290,7 @@ function ComputeInspectorFrame() { return (
@web-shell/* + ,{' '} + + @web-developer/* + ), transitional primitives ( @web-ui/* @@ -513,7 +520,9 @@ export function SurfacesIndexPage() { profiles, Header health indicator; AgentPicker states; Canvas — mirrored Outline/Strategy/WorldKB node chrome, Mental Surfacing, NLE/World/Work/Global timelines, Layer Breadcrumb, Conflict Modals; - Daemon status strip; Launch splash; Selection Submenu (6 variants). + Daemon status strip; Launch splash; Selection Submenu (6 variants); + Rail apps — Modules (page, manifest detail, Run Studio), Capabilities + (registry, filter, empty), Sessions (session cards, zero-session). Sources span all four categories: promoted primitives ( @42ch/nexus-ui @@ -542,6 +551,10 @@ export function SurfacesIndexPage() { @web-shell/* + ,{' '} + + @web-developer/* + ), transitional primitives ( @web-ui/* @@ -795,6 +808,75 @@ export function SurfacesShellPage() { + +
+ Modules rail — page, manifest and Run Studio + +

+ v221 modules rail — the{' '} + + components/developer/presentational + {' '} + chrome split out of the product page and Run Studio: list with one + selected module, manifest detail beside the select prompt, and the + Run Studio in pending / success (proposal visible) / deep-linked + detail-error states. Pure view models and literal copy — no router, + no QueryClient, no daemon. Theme follows the Studio toggle. +

+ + + +
+ +
+ + Capabilities rail — registry, filter and empty states + + +

+ v221 capabilities rail — the{' '} + + components/developer/presentational + {' '} + chrome split out of the capabilities page: registry empty (wins over a + filter with no match), filtered hit, filtered miss quoting the query + with a Clear filter action, and the read error. Pure view models and + literal copy — no router, no QueryClient, no daemon. Theme follows the + Studio toggle. +

+ + + +
+ +
+ + Sessions rail — session cards and the zero-session state + + +

+ v221 sessions rail — the Hub card frame reused for one orchestration + session: short id and status pill in the header, Preset / Creator in + the body, and the whole card as the single link into the run view. + Mixed running / completed / failed grid plus the zero-session empty + state. Static view models and literal copy — the gallery passes a + plain anchor where the app passes a router link. Theme follows the + Studio toggle. +

+ + + +
); } diff --git a/apps/design-studio/tsconfig.json b/apps/design-studio/tsconfig.json index cf9f9ecb7..d70339aca 100644 --- a/apps/design-studio/tsconfig.json +++ b/apps/design-studio/tsconfig.json @@ -31,7 +31,8 @@ "../web/src/components/global-timeline/presentational/*" ], "@web-lib/utils": ["../web/src/lib/utils.ts"], - "@web-shell/*": ["../web/src/components/selection-submenu/*"] + "@web-shell/*": ["../web/src/components/selection-submenu/*"], + "@web-developer/*": ["../web/src/components/developer/presentational/*"] }, /* Strictness */ diff --git a/apps/design-studio/vite.config.ts b/apps/design-studio/vite.config.ts index 688454d9e..59c1a17fb 100644 --- a/apps/design-studio/vite.config.ts +++ b/apps/design-studio/vite.config.ts @@ -66,6 +66,11 @@ export default defineConfig({ __dirname, '../web/src/components/selection-submenu', ), + // Gallery-only alias for developer generation chrome (v221 modules rail). + '@web-developer': path.resolve( + __dirname, + '../web/src/components/developer/presentational', + ), }, }, server: { diff --git a/apps/design-studio/vitest.config.ts b/apps/design-studio/vitest.config.ts index efbfdcbdf..bb61c0b4a 100644 --- a/apps/design-studio/vitest.config.ts +++ b/apps/design-studio/vitest.config.ts @@ -51,6 +51,11 @@ export default defineConfig({ import.meta.dirname, '../web/src/components/selection-submenu', ), + // Gallery-only alias for developer generation chrome (v221 modules rail). + '@web-developer': path.resolve( + import.meta.dirname, + '../web/src/components/developer/presentational', + ), }, }, test: { diff --git a/apps/nexus-service/src/main.ts b/apps/nexus-service/src/main.ts index e8c0546c8..4d2e782aa 100755 --- a/apps/nexus-service/src/main.ts +++ b/apps/nexus-service/src/main.ts @@ -76,7 +76,7 @@ async function main(): Promise { if (running.bootstrapUrl) { console.error(`[nexus-service] open: ${running.bootstrapUrl}`); // Auto-open only when this process actually serves the SPA; `--no-open` - // (used by the Electron detached restart) suppresses it. + // (used by the desktop host's detached restart) suppresses it. if (!args.noOpen && args.webDist) openDefaultBrowser(running.bootstrapUrl); } } diff --git a/apps/nexus-service/tests/web-static-serve.test.mjs b/apps/nexus-service/tests/web-static-serve.test.mjs index 78a9cbd4b..f577d4bfd 100644 --- a/apps/nexus-service/tests/web-static-serve.test.mjs +++ b/apps/nexus-service/tests/web-static-serve.test.mjs @@ -278,8 +278,8 @@ describe('web static serve (20261019-web-static-serve)', () => { // The refusal folds case and Win32 trailing dots/spaces per segment as a // pure string check before `stat`, so these are 404 on every host even // where the fixture has no `v1/` tier (pre-fix they fell back to 200). - for (const path of ['/V1/y', '/v1./y']) { - test(`${path} cannot re-open /v1 via case or dot folding`, async () => { + for (const path of ['/V1/y', '/v1./y', '/v1%20/y']) { + test(`${path} cannot re-open /v1 via case, dot or space folding`, async () => { const res = await rawRequest(ENABLED_PORT, path); assert.equal(res.status, 404); assert.equal(errorCode(res), 'not_found'); @@ -623,6 +623,7 @@ describe('web static serve (20261019-web-static-serve)', () => { '/%00', '/%2Fetc%2Fpasswd', '/escape/secret.txt', + 'v1 /x', ]) { test(`${path} resolves to a refusal`, async () => { await assert.rejects( diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3b5a9dcbb..116f317e0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -136,7 +136,7 @@ function StrategyRedirect() { /** * Product routes render against the last safe non-settings location while the - * browser URL is `/settings/*` or `/modules` (modal-primary, V1.131 P2). + * browser URL is `/settings/*` (modal-primary, V1.131 P2). */ function AppRoutes() { const location = useLocation(); @@ -272,7 +272,6 @@ function AppRoutes() { redirect is replaced by the entrance guard: Create bounces to `/works`, Develop renders the browser (AR-15/AR-19). */} } /> - {/* Compatibility only — Settings modal owns Modules (V1.131 P2). */} } /> } /> } /> diff --git a/apps/web/src/components/canvas/canvas-nav-commands.test.tsx b/apps/web/src/components/canvas/canvas-nav-commands.test.tsx index efac390ab..0996bd9bf 100644 --- a/apps/web/src/components/canvas/canvas-nav-commands.test.tsx +++ b/apps/web/src/components/canvas/canvas-nav-commands.test.tsx @@ -362,9 +362,9 @@ describe('CanvasNavCommands — live handler reads current id (no remount)', () describe('CanvasNavCommands — Compute jumps (V1.147 P2 T3)', () => { - // Search-aware probe: the compute commands navigate to Settings URLs that - // carry query params (`/settings/modules?world=…`), so the plain pathname - // probe is not enough here. + // Search-aware probe: the compute commands navigate to `/modules` URLs that + // carry query params (`/modules?world=…`), so the plain pathname probe is + // not enough here. function makeFullProbe() { let last: string | null = null; function LocationProbe(): null { @@ -391,7 +391,7 @@ describe('CanvasNavCommands — Compute jumps (V1.147 P2 T3)', () => { > } /> } /> - {/* Catch-all so Settings deep-link navigations keep the layout + {/* Catch-all so Modules deep-link navigations keep the layout (and its command registrations + probe) mounted. */} } /> @@ -408,7 +408,7 @@ describe('CanvasNavCommands — Compute jumps (V1.147 P2 T3)', () => { act(() => { cmd?.handler(); }); - expect(probe.read()).toBe('/settings/modules'); + expect(probe.read()).toBe('/modules'); }); it('Run Module on this World… is hidden without a worldId in the URL', () => { @@ -423,7 +423,7 @@ describe('CanvasNavCommands — Compute jumps (V1.147 P2 T3)', () => { act(() => { cmd?.handler(); }); - expect(probe.read()).toBe('/settings/modules?world=world-7'); + expect(probe.read()).toBe('/modules?world=world-7'); }); it('encodes the worldId so a space-bearing id stays one query value', () => { @@ -431,6 +431,6 @@ describe('CanvasNavCommands — Compute jumps (V1.147 P2 T3)', () => { act(() => { findById('compute.run-module-on-world')?.handler(); }); - expect(probe.read()).toBe('/settings/modules?world=w%20world'); + expect(probe.read()).toBe('/modules?world=w%20world'); }); }); diff --git a/apps/web/src/components/canvas/canvas-nav-commands.tsx b/apps/web/src/components/canvas/canvas-nav-commands.tsx index 8f1e50a24..745ba2698 100644 --- a/apps/web/src/components/canvas/canvas-nav-commands.tsx +++ b/apps/web/src/components/canvas/canvas-nav-commands.tsx @@ -186,11 +186,11 @@ export function CanvasNavCommands(): null { // // Jump-only per plan ⌘K scope ("navigation jumps into the Timeline Run // Module entry only; no palette form"): - // - Run Module… — always available → Settings → Modules (the shared Run - // Studio entry). + // - Run Module… — always available → `/modules` (the shared Run Studio + // entry). // - Run Module on this World… — gated on a `worldId` in the current URL - // → Settings → Modules with the World pre-filled (the Timeline-scoped - // Run Studio; spec §1 P2 hero shortcut). + // → `/modules` with the World pre-filled (the Timeline-scoped Run + // Studio; spec §1 P2 hero shortcut). useRegisterCommand({ id: 'compute.run-module', labelKey: 'compute.run-module.label', @@ -202,7 +202,7 @@ export function CanvasNavCommands(): null { 'compute.run-module.keywords.wasm', ], icon: Cpu, - handler: () => navigate('/settings/modules'), + handler: () => navigate('/modules'), }); useRegisterCommand({ @@ -218,7 +218,7 @@ export function CanvasNavCommands(): null { icon: Cpu, handler: () => { const { worldId: w } = idsRef.current; - if (w) navigate(`/settings/modules?world=${encodeURIComponent(w)}`); + if (w) navigate(`/modules?world=${encodeURIComponent(w)}`); }, available: () => Boolean(idsRef.current.worldId), }); diff --git a/apps/web/src/components/canvas/timeline-canvas/__tests__/compute-events-canvas.test.tsx b/apps/web/src/components/canvas/timeline-canvas/__tests__/compute-events-canvas.test.tsx index 8f0a4ab89..8a2fc21c4 100644 --- a/apps/web/src/components/canvas/timeline-canvas/__tests__/compute-events-canvas.test.tsx +++ b/apps/web/src/components/canvas/timeline-canvas/__tests__/compute-events-canvas.test.tsx @@ -6,33 +6,27 @@ * nodes (two event families, no double-render). * - A World whose only Narrative content is an accepted compute Run is NOT * the empty state — the node renders. - * - Run Module entry (toolbar) opens the shared Run Studio (Settings → - * Modules) with the World pre-filled; the events query is hard-filtered - * to `event_type=compute_result&status=canon` with no branch override + * - Run Module entry (toolbar) opens the shared Run Studio on the `/modules` + * page with the World pre-filled; the events query is hard-filtered to + * `event_type=compute_result&status=canon` with no branch override * (daemon resolves the World's current branch). * - Compute inspector via the alt-view: compute context sections + Open - * Run deep link (Settings → Modules run detail). + * Run deep link (`/modules` run detail). * - Accept journey: Timeline entry → Run Studio (World pre-filled) → run → - * accept → close Settings → the Compute result node appears WITHOUT a - * manual refresh (accept invalidates `timeline.all`, which covers the - * events query; the canvas stays mounted behind the modal). + * accept → return to the Timeline → the Compute result node appears + * without a manual refresh (the canvas remounts and refetches). * - * Mount strategy mirrors `settings-shell.test.tsx`: the real - * `SettingsModalProvider` + `SettingsModalHost` with a mini route tree so the - * modal-driven navigation is exercised end-to-end (MSW for the daemon wire). + * Mount strategy: a mini route tree with the real `ModulesPage` (the page may + * only own `/modules`; the modal is no longer mounted there) exercising the + * navigation end-to-end (MSW for the daemon wire). */ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Route, Routes, useLocation } from 'react-router'; - -import { - SettingsModalProvider, - useSettingsModal, -} from '@/components/layout/settings-modal-context'; -import { SettingsModalHost } from '@/components/layout/settings-modal-host'; -import { SettingsModulesSection } from '@/pages/settings/settings-modules-section'; +import { Link, Route, Routes } from 'react-router'; + +import { ModulesPage } from '@/components/developer/modules-page'; import { renderInApp } from '@/test/test-providers'; import { useHandlers } from '@/test/msw-server'; import { BrowserClient } from '@/lib/nexus'; @@ -145,40 +139,36 @@ const SUCCESS_PROPOSALS = { battle_report: { kind: 'combat', damage: 6 }, }; -// ─── Mini app (modal + routes) ────────────────────────────────────────────── +// ─── Mini app (routes) ────────────────────────────────────────────────────── -function ModalAppRoutes() { - const location = useLocation(); - const { open, backgroundLocation } = useSettingsModal(); - const routesLocation = open ? backgroundLocation : location; +function PageAppRoutes() { return ( - + } /> - } /> + } /> } /> ); } -/** Programmatic Settings close (the Radix dialog has no visible X in tests). */ -function SettingsCloseButton() { - const { open, requestClose } = useSettingsModal(); - if (!open) return null; +/** Way back to the Timeline from the full-page `/modules` surface. */ +function TimelineReturnLink() { return ( - + + timeline + ); } /** * Full MSW handler set. `state.eventsStore` is mutable: the accept handler - * appends the compute_result event (simulating the daemon's Accept write), so - * the canvas events query refetch picks it up through the `timeline.all` - * invalidation. + * appends the compute_result event (simulating the daemon's Accept write). + * The journey then navigates back to the Timeline — a different route, so the + * canvas remounts and refetches the log; `useAcceptRun`'s `timeline.all` + * invalidation only covers a canvas that stays mounted. */ function computeTimelineJourney( over: { initialEvents?: unknown[]; kbEntities?: unknown[]; branch?: string } = {}, @@ -308,11 +298,10 @@ function renderTimelineApp( ? ['/worlds/world-7/timeline'] : [`/worlds/world-7/timeline?branch=${over.branch}`]; const result = renderInApp( - - - - - , + <> + + + , { client: new BrowserClient(), initialRouterEntries: initial }, ); return { ...result, state }; @@ -410,8 +399,8 @@ describe('TimelineCanvas compute-on-Timeline (V1.147 P2 T3)', () => { await user.click(await screen.findByTestId('timeline-run-module-entry')); - // Settings modal opened on the Modules section. - await screen.findByTestId('settings-modal-body'); + // The `/modules` page renders the shared Run Studio. + await screen.findByTestId('modules-page-body'); await user.click(await screen.findByRole('button', { name: 'Basic Combat' })); await screen.findByTestId('run-studio'); @@ -435,10 +424,10 @@ describe('TimelineCanvas compute-on-Timeline (V1.147 P2 T3)', () => { expect(screen.getByTestId('compute-inspector-section-module')).toBeInTheDocument(); expect(screen.getByText('From module run')).toBeInTheDocument(); - // Open Run → Settings modal on the module detail with the run inspector + // Open Run → `/modules` page on the module detail with the run inspector // deep-linked (run detail query resolves from `?run=run_9`). await user.click(screen.getByTestId('compute-inspector-open-run')); - await screen.findByTestId('settings-modal-body'); + await screen.findByTestId('modules-page-body'); await screen.findByTestId('run-studio'); await screen.findByTestId('run-inspector'); }); @@ -449,7 +438,7 @@ describe('TimelineCanvas compute-on-Timeline (V1.147 P2 T3)', () => { // 1. Entry: Run Module from the Timeline toolbar. await user.click(await screen.findByTestId('timeline-run-module-entry')); - await screen.findByTestId('settings-modal-body'); + await screen.findByTestId('modules-page-body'); await user.click(await screen.findByRole('button', { name: 'Basic Combat' })); await screen.findByTestId('run-studio'); @@ -480,17 +469,15 @@ describe('TimelineCanvas compute-on-Timeline (V1.147 P2 T3)', () => { }); expect((state.runBodies[0] as Record).branch_id).toBeUndefined(); - // 4. Accept — the daemon appends the compute_result event; the canvas - // (still mounted behind the modal) refetches via the timeline.all - // invalidation WITHOUT any manual refresh. + // 4. Accept — the daemon appends the compute_result event. await user.click(screen.getByTestId('run-inspector-accept')); await waitFor(() => expect(state.acceptCalls).toBe(1)); await screen.findByTestId('run-inspector-applied-note'); - // 5. Close Settings → the Timeline is still mounted; the Compute result - // node is already there. (fireEvent: the Radix dialog overlay sits above - // the helper button, so userEvent's pointer check refuses it.) - fireEvent.click(screen.getByTestId('close-settings')); + // 5. Return to the Timeline (`/modules` is a full page, so the canvas + // remounts and refetches) WITHOUT a manual refresh — the accepted + // Compute result node is already there. + await user.click(screen.getByTestId('back-to-timeline')); const computeNode = await screen.findByTestId('compute-result-node-chrome'); expect(within(computeNode).getByText('Aria strikes Brann')).toBeInTheDocument(); expect(within(computeNode).getByText('Compute result')).toBeInTheDocument(); @@ -504,7 +491,7 @@ describe('TimelineCanvas compute-on-Timeline (V1.147 P2 T3)', () => { // 1. Entry: Run Module from the forked-branch Timeline toolbar. The // open URL now carries BOTH `?world=` and `?branch=`. await user.click(await screen.findByTestId('timeline-run-module-entry')); - await screen.findByTestId('settings-modal-body'); + await screen.findByTestId('modules-page-body'); await user.click(await screen.findByRole('button', { name: 'Basic Combat' })); await screen.findByTestId('run-studio'); diff --git a/apps/web/src/components/canvas/timeline-canvas/timeline-canvas-adapter.tsx b/apps/web/src/components/canvas/timeline-canvas/timeline-canvas-adapter.tsx index 991e3655e..5a83e919d 100644 --- a/apps/web/src/components/canvas/timeline-canvas/timeline-canvas-adapter.tsx +++ b/apps/web/src/components/canvas/timeline-canvas/timeline-canvas-adapter.tsx @@ -430,7 +430,7 @@ export interface TimelineCanvasAdapterContext { * V1.147 P2 T3 — Open Run hand-off from the compute node inspector. * Fires with the full run id + module id when the author activates "Open * Run" on a Compute result node; the orchestrator navigates to - * Settings → Modules run detail (deep link). Undefined when the compute + * `/modules` run detail (deep link). Undefined when the compute * inspector is read-only (tests without wiring). */ onOpenRun?: (runId: string, moduleId: string) => void; diff --git a/apps/web/src/components/canvas/timeline-canvas/timeline-canvas.tsx b/apps/web/src/components/canvas/timeline-canvas/timeline-canvas.tsx index e96dbfda9..726da34f5 100644 --- a/apps/web/src/components/canvas/timeline-canvas/timeline-canvas.tsx +++ b/apps/web/src/components/canvas/timeline-canvas/timeline-canvas.tsx @@ -53,7 +53,7 @@ * affordance. */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { useCallback, useContext } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useBeforeUnload, useNavigate, useSearchParams } from 'react-router'; import type { Node } from '@xyflow/react'; @@ -74,7 +74,6 @@ import { useWorks, } from '@/api/queries'; import { useNexusClient } from '@/lib/client-context'; -import { SettingsModalContext } from '@/components/layout/settings-modal-context'; import { LoadingState, ErrorState, EmptyState, Spinner } from '@/components/ui/states'; import { Button } from '@42ch/nexus-ui'; import { useToast } from '@/lib/use-toast'; @@ -396,14 +395,11 @@ export function TimelineCanvas({ worldId, sceneBeatFixture }: TimelineCanvasProp return map; }, [computeModules.data]); - // ── V1.147 P2 T3 — Settings → Modules Run Studio deep links ───────────── + // ── V1.147 P2 T3 — Modules Run Studio deep links ──────────────────────── // // The Run Module entry (toolbar + empty-state) and the compute inspector's - // Open Run hand-off both open the Settings modal with the Modules section. - // The context is consumed null-safely: surfaces rendered outside - // `SettingsModalProvider` (isolated canvas tests) degrade to no entry - // chrome; production always provides the provider (App wraps AppRoutes). - const settingsModal = useContext(SettingsModalContext); + // Open Run hand-off both navigate to the `/modules` page, which owns the + // shared Run Studio. // Timeline-scoped Run Studio: World pre-filled via `?world=` (behavior // spec §1 P2 — context pre-fill + entry chrome, not a second studio). @@ -412,28 +408,22 @@ export function TimelineCanvas({ worldId, sceneBeatFixture }: TimelineCanvasProp // `branch_id` and compute run/accept writes the fork (not the world // root). Root (undefined) → no branch param, unchanged. const onRunModule = useCallback(() => { - settingsModal?.openSettings( - 'modules', - undefined, - undefined, - `?world=${encodeURIComponent(worldId)}${ + navigate( + `/modules?world=${encodeURIComponent(worldId)}${ activeBranchId ? `&branch=${encodeURIComponent(activeBranchId)}` : '' }`, ); - }, [settingsModal, worldId, activeBranchId]); + }, [navigate, worldId, activeBranchId]); - // Open Run from a Compute result node → Settings → Modules run detail - // (deep link selects the module + opens the run inspector). + // Open Run from a Compute result node → `/modules` run detail (deep link + // selects the module + opens the run inspector). const onOpenRun = useCallback( (runId: string, moduleId: string) => { - settingsModal?.openSettings( - 'modules', - undefined, - undefined, - `?module=${encodeURIComponent(moduleId)}&run=${encodeURIComponent(runId)}`, + navigate( + `/modules?module=${encodeURIComponent(moduleId)}&run=${encodeURIComponent(runId)}`, ); }, - [settingsModal], + [navigate], ); // ── V1.123 P3 Task 4 — bound Work (cross-surface navigation) ─────────── @@ -1130,10 +1120,9 @@ export function TimelineCanvas({ worldId, sceneBeatFixture }: TimelineCanvasProp // the CTA's `data-work-id` matches the actual navigation target. boundWorkEventWorkId, onViewInWorkTimeline: boundWorkId ? onViewInWorkTimeline : undefined, - // V1.147 P2 T3 — Open Run hand-off from the compute node inspector. - // Wired only when the Settings modal context exists (production always - // provides it; isolated canvas tests degrade to read-only inspectors). - onOpenRun: settingsModal ? onOpenRun : undefined, + // V1.147 P2 T3 — Open Run hand-off from the compute node inspector to the + // `/modules` page. + onOpenRun, // V1.162 P2 T1 — fork-point hand-off from the compute node inspector. // Opens the fork-create dialog pre-seeded with the picked event (the // fork point); always wired — the button only renders on compute nodes. @@ -1458,17 +1447,15 @@ export function TimelineCanvas({ worldId, sceneBeatFixture }: TimelineCanvasProp title={t('timeline.empty.title')} description={t('timeline.empty.description')} action={ - settingsModal ? ( - - ) : undefined + } /> ) : isBriefEmpty ? ( @@ -1699,9 +1686,8 @@ function TimelineCanvasHeader({ showAltViewToggle: boolean; /** * V1.147 P2 T3 — Run Module entry (behavior spec §1 P2 hero shortcut). - * Opens the shared Run Studio (Settings → Modules) with the World - * pre-filled. The orchestrator omits it when the Settings modal context - * is absent (isolated canvas tests). + * Opens the shared Run Studio on the `/modules` page with the World + * pre-filled. */ onRunModule?: () => void; }) { diff --git a/apps/web/src/components/canvas/timeline-canvas/timeline-compute-inspector.tsx b/apps/web/src/components/canvas/timeline-canvas/timeline-compute-inspector.tsx index 894d32651..a39b2cb19 100644 --- a/apps/web/src/components/canvas/timeline-canvas/timeline-compute-inspector.tsx +++ b/apps/web/src/components/canvas/timeline-canvas/timeline-compute-inspector.tsx @@ -16,8 +16,8 @@ * events). * * Open Run: hands the FULL run id + module id back to the orchestrator via - * `ctxRef.current.onOpenRun`, which navigates to Settings → Modules run - * detail (deep link). The Run section displays the short correlation id + * `ctxRef.current.onOpenRun`, which navigates to the `/modules` run detail + * (deep link). The Run section displays the short correlation id * (spec §4 "Short correlation id; monospace") while the callback carries the * full id. * diff --git a/apps/web/src/components/compute/run-studio.test.tsx b/apps/web/src/components/compute/run-studio.test.tsx index 3aab9ac60..3c332150d 100644 --- a/apps/web/src/components/compute/run-studio.test.tsx +++ b/apps/web/src/components/compute/run-studio.test.tsx @@ -1,8 +1,8 @@ /** * RunStudio deep-link re-sync (V1.147 PR #194 Greptile Issues 1 + 2). * - * The Settings Modules section is NOT remounted when a second compute node - * "Open Run" navigates to the same `/settings/modules?module=…&run=…` route + * The Modules page is NOT remounted when a second compute node "Open Run" + * navigates to the same `/modules?module=…&run=…` route * (search-params-only navigation), so `useState(initialRunId ?? null)` alone * would leave the previous run's inspector open. These tests pin the run * re-sync: a new `initialRunId` switches the inspector, removing it closes diff --git a/apps/web/src/components/compute/run-studio.tsx b/apps/web/src/components/compute/run-studio.tsx index c78775915..ccbcef73a 100644 --- a/apps/web/src/components/compute/run-studio.tsx +++ b/apps/web/src/components/compute/run-studio.tsx @@ -1,19 +1,16 @@ /** * Run Studio — shared app component for the Compute Run author journey - * (V1.147 P1, behavior spec §1 / §2 / §3 / §4 / §6). Thin app wiring over the - * promoted `@42ch/nexus-ui` primitives: this component owns t() copy, data - * hooks, and callbacks only — no form/proposal/table logic is re-implemented - * here. + * (V1.147 P1, behavior spec §1 / §2 / §3 / §4 / §6). * * Flow: World → guided form (pickers filtered by `required_key_block_types`) * → Run → proposal inspector → Accept / Discard (confirm) → Runs history. * - * V1.147 P2 T3 — extracted from `modules-page.tsx` (absorbs qc1 W-004, the + * V1.147 P2 T3 — extracted from the modules page body (absorbs qc1 W-004, the * deferred hook extraction) so the Timeline's Run Module entry can mount the * SAME studio with context pre-fill — the plan's "no parallel studio" hard * gate. `initialWorldId` pre-fills the World selector (Timeline entry: * World/branch pre-filled per spec §1 P2); `initialRunId` deep-links the run - * inspector (compute node "Open Run" → Settings → Modules run detail). + * inspector (compute node "Open Run" → `/modules` run detail). * Selected-event context is intentionally NOT pre-filled (the entry is a * fresh-run context; forcing event context would violate the "don't force * it" constraint — spec §1 P2 optional, plan brief). @@ -23,31 +20,30 @@ * the forked branch — not the World root. Omitted when the entry came from * the root Timeline (no `?branch=`): the daemon defaults to the World's * current branch (root fallback), matching the root Timeline's source. + * + * v221-modules-rail T2 — the markup moved to + * `components/developer/presentational/run-studio-chrome.tsx` (pure view, no + * transport / router / i18n) so the Design Studio fixture can render it with + * static copy. This file keeps every hook, query, mutation, toast and i18n + * lookup, and mounts the two destructive confirm dialogs as siblings of the + * chrome. Public props are unchanged. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import { Play } from 'lucide-react'; -import { - ProposalSections, - RunFormFields, - RunStatusBadge, - RunsTable, - type EntityPickerEntry, - type ProposalSectionsCopy, - type RunFormCopy, - type RunStatus, - type RunsTableCopy, - type RunTableRow, +import type { + EntityPickerEntry, + ProposalSectionsCopy, + RunFormCopy, + RunStatus, + RunsTableCopy, + RunTableRow, } from '@42ch/nexus-ui'; -import type { ModuleDetail, RunDetail, RunResponse } from '@42ch/nexus-contracts'; +import type { ModuleDetail, RunResponse } from '@42ch/nexus-contracts'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; -import { ErrorState, LoadingState } from '@/components/ui/states'; -import { Select } from '@/components/ui/select'; import { flattenPages, useAcceptRun, @@ -59,6 +55,7 @@ import { useRunCompute, } from '@/api/queries'; import { toInvocationSchema } from '@/api/run-studio-schemas'; +import { RunStudioChrome, type RunStudioCopy } from '@/components/developer/presentational/run-studio-chrome'; import { useWorldKbGraph } from '@/lib/canvas/use-world-kb-data'; import { formatDateTime, shortId } from '@/lib/format'; import { useToast } from '@/lib/use-toast'; @@ -117,7 +114,7 @@ export function RunStudio({ const { toast } = useToast(); // ── World scope ──────────────────────────────────────────────────────────── - // No active-World shell state exists for the Settings surface, so the World + // No active-World shell state exists for the modules surface, so the World // selector defaults to the explicit picker (behavior spec §3: "otherwise // explicit picker") — or the Timeline pre-fill when the entry came from the // World Timeline (V1.147 P2 T3). KnowledgeEntry pickers re-derive when it @@ -127,15 +124,15 @@ export function RunStudio({ const worldGraph = useWorldKbGraph(worldId || undefined); // Bugbot 1 — `?branch=` pre-fill re-sync, mirroring the `?world=` pattern - // below: the Settings Modules section stays mounted across Timeline "Run - // Module" entries (search-params-only navigation), so the state seed alone - // would keep the previous fork's branch on the next run request — a Run - // would write the wrong branch. Keyed on the prop VALUE with a ref guard: - // a new `?branch=` switches the scoped branch, `?branch=` removal resets - // to undefined (world root), and an unchanged prop never clobbers state. - // The invocation values are NOT reset here — fork branches share the - // World's KB, so params remain valid within the same World (the `?world=` - // re-sync below owns cross-World resets). + // below: the Modules page stays mounted across Timeline "Run Module" + // entries (search-params-only navigation), so the state seed alone would + // keep the previous fork's branch on the next run request — a Run would + // write the wrong branch. Keyed on the prop VALUE with a ref guard: a new + // `?branch=` switches the scoped branch, `?branch=` removal resets to + // undefined (world root), and an unchanged prop never clobbers state. The + // invocation values are NOT reset here — fork branches share the World's + // KB, so params remain valid within the same World (the `?world=` re-sync + // below owns cross-World resets). const [branchId, setBranchId] = useState(initialBranchId ?? undefined); const lastBranchSeedRef = useRef(initialBranchId); useEffect(() => { @@ -145,15 +142,15 @@ export function RunStudio({ } }, [initialBranchId]); - // V1.147 PR #194 — deep-link `?world=` re-sync. The Settings Modules - // section stays mounted when a second Timeline "Run Module" entry lands on - // the same route (`/settings/modules?module=…&world=…` — search-params-only - // navigation), so the `useState(initialWorldId ?? '')` seed alone would - // keep the previous World selected — a Run would execute against the wrong - // World. The ref tracks the last-seen prop VALUE: a new value switches the - // selector (and `?world=` removal resets it to the empty placeholder), - // while an unchanged prop never clobbers a World the author picked manually - // (mirror of the round-1 `initialRunId` re-sync pattern). + // V1.147 PR #194 — deep-link `?world=` re-sync. The Modules page stays + // mounted when a second Timeline "Run Module" entry lands on the same route + // (`/modules?module=…&world=…` — search-params-only navigation), so the + // `useState(initialWorldId ?? '')` seed alone would keep the previous World + // selected — a Run would execute against the wrong World. The ref tracks + // the last-seen prop VALUE: a new value switches the selector (and + // `?world=` removal resets it to the empty placeholder), while an unchanged + // prop never clobbers a World the author picked manually (mirror of the + // round-1 `initialRunId` re-sync pattern). // // Round 3 — a deep-link switch must also reset the invocation authoring // state exactly like the manual `handleWorldChange`: otherwise the entity @@ -180,6 +177,7 @@ export function RunStudio({ const [jsonText, setJsonText] = useState('{}'); const [jsonDirty, setJsonDirty] = useState(false); const [jsonError, setJsonError] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); // Keep the raw-JSON disclosure in sync with guided-form edits until the // author touches it ("form fields sync best-effort" — JSON edits never @@ -282,7 +280,7 @@ export function RunStudio({ // → Applied/Discarded) renders from refetched data. // // V1.147 P2 T3 — `initialRunId` deep-links the run inspector (compute node - // "Open Run" → Settings → Modules run detail). + // "Open Run" → `/modules` run detail). const [latestRun, setLatestRun] = useState(null); const [inspectorRunId, setInspectorRunId] = useState( initialRunId ?? null, @@ -290,8 +288,8 @@ export function RunStudio({ const [discardTargetId, setDiscardTargetId] = useState(null); // V1.147 PR #194 round 4 — a module switch must reset the invocation - // authoring state exactly like a World switch. The Settings Modules section - // stays mounted when the author clicks another module in the list or a + // authoring state exactly like a World switch. The Modules page stays + // mounted when the author clicks another module in the list or a // `?module=` deep-link switches while the target module's detail is cached // (no LoadingState remount), so the `useState` seeds alone would keep the // previous module's guided values + Advanced JSON — a Run would submit one @@ -315,9 +313,9 @@ export function RunStudio({ } }, [module.module_id]); - // V1.147 PR #194 — deep-link `?run=` re-sync. The Settings Modules section - // stays mounted when a second compute-node "Open Run" navigates to the same - // route (`/settings/modules?module=…&run=…` — search params only), so the + // V1.147 PR #194 — deep-link `?run=` re-sync. The Modules page stays + // mounted when a second compute-node "Open Run" navigates to the same route + // (`/modules?module=…&run=…` — search params only), so the // `useState(initialRunId ?? null)` seed alone would keep the stale run open. // Keyed on the prop VALUE: a new `initialRunId` switches the inspector; // removing `?run=` (module switch deletes the param) closes it. The effect @@ -464,234 +462,93 @@ export function RunStudio({ ); return ( -
-

{t('run.title')}

- - {/* Product chrome: World selector (always present, spec §3). A data-feed - failure here must not collapse into a silent empty select (qc3 W-3 - fix) — surface the ErrorState with a retry instead. */} - {worlds.isError ? ( - worlds.refetch()} - /> - ) : ( -
- - -

- {worldId ? t('run.branchNote') : t('run.noWorldHint')} -

-
- )} - - {worldId !== '' && worldGraph.isError ? ( - worldGraph.refetch()} - /> - ) : worldId !== '' && worldGraph.isLoading ? ( - - ) : ( - - )} - - {/* Advanced JSON escape hatch — collapsed by default; form syncs best-effort. */} -
- - {t('run.advancedJson')} - -

{t('run.advancedJsonWarning')}

-