+ );
+}
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 (
+
+ 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()}
- />
- ) : (
-
-
- {inspectorRunId && runDetail.isLoading ? (
-
- ) : inspectorRunId && runDetail.isError ? (
- // A deep-linked Run whose detail read fails (cleared by Clear history,
- // deleted, or transport down) must SAY so — rendering nothing hides the
- // failure behind an empty space where the inspector should be.
- void runDetail.refetch()}
- />
- ) : (
- inspectorRun && (
- {
- const runId = inspectorRunId ?? latestRun?.run_id;
- if (runId) setDiscardTargetId(runId);
- }}
- />
- )
- )}
-
-
-
-
- {t('run.runsTitle')}
-
- {/* Filter chrome (spec §4 + plan Global Constraints). Status + World
- narrow the module-scoped history. Clear history needs a daemon
- DELETE API that does not exist in P1 — filtering ships now and a
- server-backed Clear is registered as a residual (no invented
- backend route, no disabled "coming soon" affordance). */}
-
-
-
-
-
-
-
-
-
- {/* Clear history (V1.147 P3 T2) — server-backed DELETE. The
- daemon REQUIRES `world_id`, so the button is gated on the
- World filter (never a world-wide purge); running / needs-review
- rows are always kept. */}
-
-
-
-
-
-
- {/* Data-feed errors must not collapse into "No runs yet" (qc3 W-3
- fix): a failed runs query renders ErrorState, and a loading list
- renders a placeholder instead of flashing the empty state. */}
- {runs.isLoading ? (
-
- ) : runs.isError ? (
- runs.refetch()}
- />
- ) : (
-
- )}
-
- {/* Cursor pagination (qc1 W-002 / qc3 W-2 fix): runs beyond the first
- page — including older Needs-review rows — must stay reachable. */}
- {runs.hasNextPage && (
-
- )}
-
+ );
+}
diff --git a/apps/web/src/components/developer/presentational/run-studio-chrome.tsx b/apps/web/src/components/developer/presentational/run-studio-chrome.tsx
new file mode 100644
index 000000000..e698a8ad8
--- /dev/null
+++ b/apps/web/src/components/developer/presentational/run-studio-chrome.tsx
@@ -0,0 +1,525 @@
+/**
+ * Presentational body of the Run Studio (plan v221-modules-rail T2).
+ *
+ * The product studio (`components/compute/run-studio.tsx`) owns every hook,
+ * query, mutation, toast and i18n lookup; this module owns the markup so the
+ * Design Studio fixture can render the run-pending / run-success / run-error
+ * variants from static view models with literal copy.
+ *
+ * Boundary: no transport, no router, no i18n, no QueryClient. Every string
+ * arrives through `copy`, data as view models, behavior as callbacks. The two
+ * destructive confirm dialogs stay app-owned (they are mounted as siblings of
+ * this tree); the chrome only renders their trigger buttons.
+ */
+import { useId } from 'react';
+
+import { Play } from 'lucide-react';
+
+import {
+ ProposalSections,
+ RunFormFields,
+ RunStatusBadge,
+ RunsTable,
+ type ComputeProposals,
+ type EntityPickerEntry,
+ type InvocationSchema,
+ type ProposalSectionsCopy,
+ type RunFormCopy,
+ type RunStatus,
+ type RunsTableCopy,
+ type RunTableRow,
+} from '@42ch/nexus-ui';
+
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Select } from '@/components/ui/select';
+import { ErrorState, LoadingState } from '@/components/ui/states';
+
+export interface RunStudioWorldView {
+ options: readonly { world_id: string; title: string }[];
+ value: string;
+ onChange: (worldId: string) => void;
+ isLoading: boolean;
+ isError: boolean;
+ onRetry: () => void;
+}
+
+export interface RunStudioFormView {
+ schema: InvocationSchema | null | undefined;
+ requiredKeyBlockTypes?: string[];
+ values: Record;
+ onChange: (name: string, value: unknown) => void;
+ entityEntries?: Record;
+ copy: RunFormCopy;
+ /**
+ * World-scoped KB graph feed (the entity pickers' source). `null` while no
+ * World is selected — the form then renders without gating on that feed.
+ */
+ graph: { isLoading: boolean; isError: boolean; onRetry: () => void } | null;
+}
+
+export interface RunStudioAdvancedView {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ json: string;
+ onJsonChange: (value: string) => void;
+ /** Parse error shown under the textarea (caller-owned copy). */
+ error: string | null;
+}
+
+export interface RunStudioRunView {
+ pending: boolean;
+ canRun: boolean;
+ onRun: () => void;
+ /** Deep-linked run-detail read: own loading / error states above the inspector. */
+ detailLoading: boolean;
+ detailError: boolean;
+ onDetailRetry: () => void;
+}
+
+export interface RunStudioProposalView {
+ /** Inspected run (fresh POST response or detail read); `null` → no inspector. */
+ run: {
+ /** Short correlation id, caller-shortened for display. */
+ runIdShort: string;
+ status: RunStatus;
+ proposals?: ComputeProposals;
+ error?: { code: string; message?: string };
+ } | null;
+ truncated: boolean;
+ acceptPending: boolean;
+ discardPending: boolean;
+ onAccept: () => void;
+ onDiscard: () => void;
+}
+
+export interface RunStudioRunsView {
+ rows: RunTableRow[];
+ copy: RunsTableCopy;
+ isLoading: boolean;
+ isError: boolean;
+ onRetry: () => void;
+ statusFilter: '' | RunStatus;
+ onStatusFilterChange: (status: '' | RunStatus) => void;
+ worldFilter: string;
+ onWorldFilterChange: (worldId: string) => void;
+ /** Clear history is World-scoped; `null` disables the affordance. */
+ clearScopeWorldId: string | null;
+ clearPending: boolean;
+ onClear: () => void;
+ onOpenRun: (runId: string) => void;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ onLoadMore: () => void;
+}
+
+export interface RunStudioCopy {
+ title: string;
+ worldLabel: string;
+ worldPlaceholder: string;
+ branchNote: string;
+ noWorldHint: string;
+ worldsErrorTitle: string;
+ worldsErrorDescription: string;
+ graphErrorTitle: string;
+ graphErrorDescription: string;
+ graphLoading: string;
+ advancedJson: string;
+ advancedJsonWarning: string;
+ runButton: string;
+ runPending: string;
+ runLoadingDetail: string;
+ runErrorTitle: string;
+ runErrorDescription: string;
+ runsTitle: string;
+ statusFilter: string;
+ allStatuses: string;
+ worldFilter: string;
+ allWorlds: string;
+ clearHistory: string;
+ clearDisabledHint: string;
+ runsLoading: string;
+ runsErrorTitle: string;
+ runsErrorDescription: string;
+ loadMore: string;
+ loadingMore: string;
+ /** Product status options for the Runs filter (labels caller-owned). */
+ statusOptions: readonly { value: RunStatus; label: string }[];
+ /** Product status label per wire status (Needs review = `succeeded`). */
+ statusLabel: (status: RunStatus) => string;
+ accept: string;
+ discard: string;
+ acceptAria: string;
+ discardAria: string;
+ acceptNote: string;
+ stoppedTitle: string;
+ failedTitle: string;
+ stoppedDescription: string;
+ failedDescription: (reason: string) => string;
+ failedCodeLabel: string;
+ appliedNote: string;
+ discardedNote: string;
+ runningNote: string;
+ proposalsCopy: ProposalSectionsCopy;
+}
+
+export interface RunStudioChromeProps {
+ copy: RunStudioCopy;
+ world: RunStudioWorldView;
+ form: RunStudioFormView;
+ advanced: RunStudioAdvancedView;
+ run: RunStudioRunView;
+ proposal: RunStudioProposalView;
+ runs: RunStudioRunsView;
+}
+
+/** Guided form → Run → proposal inspector → Runs history. */
+export function RunStudioChrome({
+ copy,
+ world,
+ form,
+ advanced,
+ run,
+ proposal,
+ runs,
+}: RunStudioChromeProps) {
+ // `useId` — the gallery mounts this chrome three times; static ids would
+ // collide and every label after the first would point at frame 1's control.
+ const worldSelectId = useId();
+ const statusFilterId = useId();
+ const worldFilterId = useId();
+ const formIdPrefix = useId();
+
+ return (
+
+
{copy.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. */}
+ {world.isError ? (
+
+ ) : (
+
+
+ {run.detailLoading ? (
+
+ ) : run.detailError ? (
+ // A deep-linked Run whose detail read fails (cleared by Clear history,
+ // deleted, or transport down) must SAY so — rendering nothing hides the
+ // failure behind an empty space where the inspector should be.
+
+ ) : (
+ proposal.run && (
+
+ )
+ )}
+
+
+
+
{copy.runsTitle}
+ {/* Filter chrome (spec §4 + plan Global Constraints). Status + World
+ narrow the module-scoped history; Clear history is World-scoped
+ because the daemon DELETE requires a World. */}
+
+
+
+
+
+
+
+
+
+ {/* Clear history (V1.147 P3 T2) — server-backed DELETE. The daemon
+ REQUIRES `world_id`, so the button is gated on the World filter
+ (never a world-wide purge); running / needs-review rows are
+ always kept. */}
+
+
+
+
+
+
+ {/* Data-feed errors must not collapse into "No runs yet" (qc3 W-3
+ fix): a failed runs query renders ErrorState, and a loading list
+ renders a placeholder instead of flashing the empty state. */}
+ {runs.isLoading ? (
+
+ ) : runs.isError ? (
+
+ ) : (
+
+ )}
+
+ {/* Cursor pagination (qc1 W-002 / qc3 W-2 fix): runs beyond the first
+ page — including older Needs-review rows — must stay reachable. */}
+ {runs.hasNextPage && (
+
+ )}
+