From 7365981aec22721592e73645e212ed96714d8ebd Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 19:14:33 +0800
Subject: [PATCH 01/16] test(web-static-serve): pin trailing-space fold on the
/v1 refusal (I-000058)
The refusal folds case and per-segment trailing dots AND spaces, but only
the dot half had a regression tripwire. Add `/v1%20/y` to the HTTP fold
matrix (404 not_found, non-INDEX body, nosniff) and `v1 /x` to the
unit-seam traversal array.
Verified as a tripwire: narrowing the fold regex to `[.]+$` turns exactly
these two cases red and leaves the existing fold cases green.
---
apps/nexus-service/tests/web-static-serve.test.mjs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
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(
From 4941afa5c12af06eea00ec957c70b0913d78d4c1 Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 19:21:26 +0800
Subject: [PATCH 02/16] fix(nexus-service): reword --no-open comment so the
entry carries no Electron token (I-000058)
---
apps/nexus-service/src/main.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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);
}
}
From 2626020ee675a772bf4fd372adf9596839e85a9b Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 19:20:19 +0800
Subject: [PATCH 03/16] feat(web): make /modules the sole owner of the modules
surface
Remove the Settings-modal ownership of the modules section: drop the
section id/descriptor/aliases, the develop nav item and route rule, the
modal section wrapper, and the locale key. ModulesPage renders the page
body instead of redirecting; canvas/timeline Run Studio entries navigate
to /modules directly. Settings tests, the timeline compute journeys and
the Design Studio fixture are rebased on the page contract.
---
apps/design-studio/src/App.test.tsx | 8 +-
.../src/fixtures/settings-host-fixtures.tsx | 51 ++---------
.../src/fixtures/timeline-compute.tsx | 2 +-
apps/web/src/App.tsx | 3 +-
.../canvas/canvas-nav-commands.test.tsx | 14 +--
.../components/canvas/canvas-nav-commands.tsx | 12 +--
.../__tests__/compute-events-canvas.test.tsx | 86 ++++++++-----------
.../timeline-canvas-adapter.tsx | 2 +-
.../timeline-canvas/timeline-canvas.tsx | 66 ++++++--------
.../timeline-compute-inspector.tsx | 4 +-
.../components/compute/run-studio.test.tsx | 4 +-
.../web/src/components/compute/run-studio.tsx | 16 ++--
.../src/components/layout/entrance-guard.tsx | 4 +-
.../layout/entrance-registry.test.ts | 5 +-
.../components/layout/entrance-registry.ts | 14 ++-
.../layout/settings-modal-context.tsx | 9 +-
.../layout/settings-section-registry.test.ts | 12 +--
.../layout/settings-section-registry.ts | 18 +---
.../src/components/layout/sidebar.test.tsx | 8 +-
apps/web/src/locales/en/settings.json | 1 -
apps/web/src/locales/zh-CN/settings.json | 1 -
apps/web/src/pages/modules-page.test.tsx | 12 +--
apps/web/src/pages/modules-page.tsx | 19 ++--
.../settings/settings-modules-section.tsx | 14 ---
.../settings/settings-section-frame.test.tsx | 11 ++-
.../pages/settings/settings-shell.test.tsx | 42 +--------
26 files changed, 139 insertions(+), 299 deletions(-)
delete mode 100644 apps/web/src/pages/settings/settings-modules-section.tsx
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/fixtures/settings-host-fixtures.tsx b/apps/design-studio/src/fixtures/settings-host-fixtures.tsx
index 99e5b050c..a10861208 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 =
@@ -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 (
} />
- {/* 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..69c51e7b2 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 '@/pages/modules-page';
import { renderInApp } from '@/test/test-providers';
import { useHandlers } from '@/test/msw-server';
import { BrowserClient } from '@/lib/nexus';
@@ -145,32 +139,27 @@ 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
+
);
}
@@ -308,11 +297,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 +398,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 +423,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 +437,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 +468,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 +490,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..b02e0e938 100644
--- a/apps/web/src/components/compute/run-studio.tsx
+++ b/apps/web/src/components/compute/run-studio.tsx
@@ -13,7 +13,7 @@
* 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).
@@ -145,9 +145,9 @@ 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
+ // 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
@@ -282,7 +282,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,
@@ -315,9 +315,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
diff --git a/apps/web/src/components/layout/entrance-guard.tsx b/apps/web/src/components/layout/entrance-guard.tsx
index da052cb06..e66be87d1 100644
--- a/apps/web/src/components/layout/entrance-guard.tsx
+++ b/apps/web/src/components/layout/entrance-guard.tsx
@@ -29,8 +29,8 @@ export function resolveEntranceBounce(
): string | null {
if (entrance === 'developer') return null; // Develop never bounces
- // Settings-modal paths (`/settings/*`, `/modules`) resolve the section first
- // via the existing machinery and check section-level visibility (AR-19).
+ // Settings-modal paths (`/settings/*`) resolve the section first via the
+ // existing machinery and check section-level visibility (AR-19).
if (isSettingsDrivenPath(pathname)) {
const resolved = resolveSettingsLocation(pathname, hash);
if (resolved) {
diff --git a/apps/web/src/components/layout/entrance-registry.test.ts b/apps/web/src/components/layout/entrance-registry.test.ts
index 21a9eed55..0ec1987b6 100644
--- a/apps/web/src/components/layout/entrance-registry.test.ts
+++ b/apps/web/src/components/layout/entrance-registry.test.ts
@@ -49,7 +49,6 @@ describe('entrance registry (AR-15)', () => {
it('hides the EL §3 settings sections on Create only', () => {
expect(ENTRANCE_BY_ID['content-creator'].hiddenSettingsSections).toEqual([
'agent',
- 'modules',
'advanced',
]);
expect(ENTRANCE_BY_ID['developer'].hiddenSettingsSections).toEqual([]);
@@ -77,7 +76,6 @@ describe('entrance registry (AR-15)', () => {
'/developer',
'/works/:workId/inspector',
'/settings/agent',
- '/settings/modules',
'/settings/advanced',
]);
});
@@ -143,7 +141,7 @@ describe('entrance registry (AR-15)', () => {
// Settings-driven paths resolve via resolveSettingsLocation, not prefix
// matching — the `/settings/*` rules are section rules, not path rules.
expect(matchEntranceRouteRule('/settings/agent')).toBeNull();
- // `/modules` is itself in the develop-only table (settings alias).
+ // `/modules` is itself in the develop-only table (its own page route).
expect(matchEntranceRouteRule('/modules')?.visibility).toBe('develop-only');
});
@@ -168,7 +166,6 @@ describe('entrance registry (AR-15)', () => {
expect(developTargets).toContain('/sessions');
expect(developTargets).toContain('/schedule');
expect(developTargets).toContain('/capabilities');
- expect(developTargets).toContain('/settings/modules');
expect(developTargets).toContain('/memory');
});
});
diff --git a/apps/web/src/components/layout/entrance-registry.ts b/apps/web/src/components/layout/entrance-registry.ts
index 4ccaeaf72..a24668520 100644
--- a/apps/web/src/components/layout/entrance-registry.ts
+++ b/apps/web/src/components/layout/entrance-registry.ts
@@ -21,7 +21,6 @@ import {
BrainCircuit,
CalendarClock,
Compass,
- Cpu,
FileText,
Globe,
History,
@@ -46,7 +45,7 @@ export interface EntranceRouteRule {
visibility: 'both' | 'develop-only';
/** Pass-through on the hidden tree (product lock: strategy canvas OK). */
allowDeepLink?: boolean;
- /** For /settings/* + /modules modal-alias resolution. */
+ /** For /settings/* section resolution. */
settingsSection?: SettingsSectionId;
}
@@ -60,7 +59,7 @@ export interface EntranceDescriptor {
landRoute: string;
/** Entrance-filtered sidebar tree. */
navGroups: readonly ShellNavGroup[];
- /** Sections hidden from the tree (Create: agent | modules | advanced). */
+ /** Sections hidden from the tree (Create: agent | advanced). */
hiddenSettingsSections: readonly SettingsSectionId[];
/** i18n shell key for the guard bounce toast (AR-19). */
bounceToastKey: string;
@@ -79,7 +78,7 @@ export const ENTRANCE_ROUTE_RULES: readonly EntranceRouteRule[] = [
{ path: '/strategies/:presetId', visibility: 'develop-only', allowDeepLink: true },
{ path: '/sessions', visibility: 'develop-only' },
{ path: '/schedule', visibility: 'develop-only' },
- // Settings modal alias (V1.131 P2) — section resolution owns the check.
+ // Compute modules page (developer rail).
{ path: '/modules', visibility: 'develop-only' },
{ path: '/capabilities', visibility: 'develop-only' },
{ path: '/connect', visibility: 'develop-only' },
@@ -87,7 +86,6 @@ export const ENTRANCE_ROUTE_RULES: readonly EntranceRouteRule[] = [
{ path: '/works/:workId/inspector', visibility: 'develop-only' },
// Settings sections — section-level visibility via resolveSettingsLocation.
{ path: '/settings/agent', visibility: 'develop-only', settingsSection: 'agent' },
- { path: '/settings/modules', visibility: 'develop-only', settingsSection: 'modules' },
{ path: '/settings/advanced', visibility: 'develop-only', settingsSection: 'advanced' },
{ path: '/settings/workspace', visibility: 'both', settingsSection: 'workspace' },
{ path: '/settings/appearance', visibility: 'both', settingsSection: 'appearance' },
@@ -144,7 +142,7 @@ export const ENTRANCE_DESCRIPTORS: readonly EntranceDescriptor[] = [
landRoute: '/works',
// EL §3 — reduced tree: primary JTBD (works/worlds), Memory, findings.
navGroups: COMMON_NAV_GROUPS,
- hiddenSettingsSections: ['agent', 'modules', 'advanced'],
+ hiddenSettingsSections: ['agent', 'advanced'],
bounceToastKey: 'entrance.bounceToast',
},
{
@@ -179,7 +177,6 @@ export const ENTRANCE_DESCRIPTORS: readonly EntranceDescriptor[] = [
label: 'nav.compute',
items: [
{ to: '/capabilities', label: 'nav.capabilities', icon: Boxes },
- { to: '/settings/modules', label: 'nav.modules', icon: Cpu },
],
},
MEMORY_GROUP,
@@ -212,8 +209,7 @@ export function firstSettingsSectionFor(entrance: EntranceId): SettingsSectionId
/**
* Longest-prefix rule match for a pathname (AR-19). `:param` segments match
* any single segment; settings-section rules are excluded here — the guard
- * resolves `/settings/*` and `/modules` through {@link resolveSettingsLocation}
- * first. No match → `null` → pass through (the `*` NotFoundPage owns unknowns).
+ * resolves `/settings/*` through {@link resolveSettingsLocation} first. No match → `null` → pass through (the `*` NotFoundPage owns unknowns).
*/
export function matchEntranceRouteRule(pathname: string): EntranceRouteRule | null {
const segments = pathname.split('/').filter(Boolean);
diff --git a/apps/web/src/components/layout/settings-modal-context.tsx b/apps/web/src/components/layout/settings-modal-context.tsx
index 1ba23b852..f073a653d 100644
--- a/apps/web/src/components/layout/settings-modal-context.tsx
+++ b/apps/web/src/components/layout/settings-modal-context.tsx
@@ -10,10 +10,9 @@
* Settings URL until the user confirms (BrowserRouter-compatible equivalent of
* `useBlocker`; data-router migration is tracked separately).
*
- * V1.147 P2 T3 — the context object is exported for null-safe consumption
- * (the Timeline canvas opens Settings → Modules with a World pre-fill deep
- * link; surfaces rendered outside the provider degrade gracefully instead of
- * throwing from `useSettingsModal`).
+ * V1.147 P2 T3 — the context object is exported for null-safe consumption:
+ * surfaces rendered outside the provider degrade gracefully instead of
+ * throwing from `useSettingsModal`.
*/
import {
@@ -123,7 +122,7 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) {
// Normalize aliases and unknown sections to the canonical Settings path.
// V1.147 P2 T3: `search` is preserved verbatim so deep links into a section
- // (`/settings/modules?module=…&run=…`) survive the canonicalization instead
+ // (e.g. `/settings/advanced?x=…`) survive the canonicalization instead
// of having their query params dropped on arrival.
useEffect(() => {
if (!resolved) return;
diff --git a/apps/web/src/components/layout/settings-section-registry.test.ts b/apps/web/src/components/layout/settings-section-registry.test.ts
index 4ba75948e..b5a170ed5 100644
--- a/apps/web/src/components/layout/settings-section-registry.test.ts
+++ b/apps/web/src/components/layout/settings-section-registry.test.ts
@@ -19,7 +19,6 @@ describe('settings-section-registry', () => {
'agent',
'workspace',
'appearance',
- 'modules',
'advanced',
]);
expect(SETTINGS_SECTION_DESCRIPTORS.map((d) => d.id)).toEqual([
@@ -34,10 +33,10 @@ describe('settings-section-registry', () => {
}
});
- it('treats /settings/* and /modules as settings-driven', () => {
+ it('treats only /settings/* as settings-driven', () => {
expect(isSettingsDrivenPath('/settings')).toBe(true);
expect(isSettingsDrivenPath('/settings/agent')).toBe(true);
- expect(isSettingsDrivenPath('/modules')).toBe(true);
+ expect(isSettingsDrivenPath('/modules')).toBe(false);
expect(isSettingsDrivenPath('/works')).toBe(false);
});
@@ -57,11 +56,7 @@ describe('settings-section-registry', () => {
hash: 'setup',
pathname: '/settings/advanced',
});
- expect(resolveSettingsLocation('/modules')).toEqual({
- section: 'modules',
- hash: '',
- pathname: '/settings/modules',
- });
+ expect(resolveSettingsLocation('/modules')).toBeNull();
expect(resolveSettingsLocation('/settings/nope')).toEqual({
section: DEFAULT_SETTINGS_SECTION,
hash: '',
@@ -75,7 +70,6 @@ describe('settings-section-registry', () => {
});
it('builds canonical settings paths', () => {
- expect(settingsPathFor('modules')).toBe('/settings/modules');
expect(settingsPathFor('advanced', 'setup')).toBe('/settings/advanced#setup');
});
});
diff --git a/apps/web/src/components/layout/settings-section-registry.ts b/apps/web/src/components/layout/settings-section-registry.ts
index fadfb5173..6a481cee9 100644
--- a/apps/web/src/components/layout/settings-section-registry.ts
+++ b/apps/web/src/components/layout/settings-section-registry.ts
@@ -3,13 +3,12 @@
*
* Single ordered **descriptor** SSOT for the Settings modal host and demoted
* test shell: each entry carries `id`, i18n label key, icon, and content
- * renderer. Aliases normalize `/settings`, `/settings/connection`,
- * `/settings/setup`, and `/modules` without parallel route trees.
+ * renderer. Aliases normalize `/settings`, `/settings/connection` and
+ * `/settings/setup` without parallel route trees.
*/
import {
Bot,
- Cpu,
FolderOpen,
Palette,
Settings,
@@ -20,14 +19,12 @@ import type { ComponentType } from 'react';
import { SettingsAgentSection } from '@/pages/settings/settings-agent-section';
import { SettingsAdvancedSection } from '@/pages/settings/settings-advanced-section';
import { SettingsAppearanceSection } from '@/pages/settings/settings-appearance-section';
-import { SettingsModulesSection } from '@/pages/settings/settings-modules-section';
import { SettingsWorkspaceSection } from '@/pages/settings/settings-workspace-section';
export type SettingsSectionId =
| 'agent'
| 'workspace'
| 'appearance'
- | 'modules'
| 'advanced';
/** Ordered section descriptor — chrome + content share this SSOT. */
@@ -59,12 +56,6 @@ export const SETTINGS_SECTION_DESCRIPTORS: readonly SettingsSectionDescriptor[]
icon: Palette,
Content: SettingsAppearanceSection,
},
- {
- id: 'modules',
- labelKey: 'nav.modules',
- icon: Cpu,
- Content: SettingsModulesSection,
- },
{
id: 'advanced',
labelKey: 'nav.advanced',
@@ -108,7 +99,6 @@ function isSectionId(value: string): value is SettingsSectionId {
/** True when the browser URL should drive the Settings modal (not a product page). */
export function isSettingsDrivenPath(pathname: string): boolean {
return (
- pathname === '/modules' ||
pathname === '/settings' ||
pathname.startsWith('/settings/')
);
@@ -126,10 +116,6 @@ export function resolveSettingsLocation(
const hashId = hash.startsWith('#') ? hash.slice(1) : hash;
- if (pathname === '/modules') {
- return { section: 'modules', hash: '', pathname: '/settings/modules' };
- }
-
if (pathname === '/settings' || pathname === '/settings/') {
return {
section: DEFAULT_SETTINGS_SECTION,
diff --git a/apps/web/src/components/layout/sidebar.test.tsx b/apps/web/src/components/layout/sidebar.test.tsx
index 596a9ca28..a3f3066d9 100644
--- a/apps/web/src/components/layout/sidebar.test.tsx
+++ b/apps/web/src/components/layout/sidebar.test.tsx
@@ -123,7 +123,7 @@ describe('Sidebar', () => {
await user.click(screen.getByRole('tab', { name: 'Orchestrator' }));
// Develop hub + full config surface: works/worlds, strategies, runtime,
- // compute (capability browser + modules), memory.
+ // compute (capability browser), memory.
expect(screen.getByRole('link', { name: 'Develop' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Works' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Worlds' })).toBeInTheDocument();
@@ -134,10 +134,8 @@ describe('Sidebar', () => {
'href',
'/capabilities',
);
- expect(screen.getByRole('link', { name: 'Modules' })).toHaveAttribute(
- 'href',
- '/settings/modules',
- );
+ // Modules is a rail app (/modules), not a legacy sidebar link.
+ expect(screen.queryByRole('link', { name: 'Modules' })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Memory' })).toBeInTheDocument();
});
diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json
index 3ffd2fb4a..88324929b 100644
--- a/apps/web/src/locales/en/settings.json
+++ b/apps/web/src/locales/en/settings.json
@@ -8,7 +8,6 @@
"agent": "Agent",
"workspace": "Profiles",
"appearance": "Appearance",
- "modules": "Modules",
"advanced": "Advanced"
},
"discard": {
diff --git a/apps/web/src/locales/zh-CN/settings.json b/apps/web/src/locales/zh-CN/settings.json
index fe4868cf1..65bc98d97 100644
--- a/apps/web/src/locales/zh-CN/settings.json
+++ b/apps/web/src/locales/zh-CN/settings.json
@@ -8,7 +8,6 @@
"agent": "智能体",
"workspace": "Profiles",
"appearance": "外观",
- "modules": "模块",
"advanced": "高级"
},
"discard": {
diff --git a/apps/web/src/pages/modules-page.test.tsx b/apps/web/src/pages/modules-page.test.tsx
index 0bd7f6c09..de7a76833 100644
--- a/apps/web/src/pages/modules-page.test.tsx
+++ b/apps/web/src/pages/modules-page.test.tsx
@@ -813,8 +813,8 @@ describe('ModulesPage Run Studio (V1.147 P1 T3)', () => {
describe('ModulesPage deep-link selection (V1.147 P2 T3)', () => {
// `?module=basic-combat&run=run_9&world=w1` — the compute node "Open Run"
// deep link (module + run) and the Timeline Run Module entry (`world`
- // pre-fill) land here from `/settings/modules`.
- function renderDeepLinked(initialUrl = '/settings/modules?module=basic-combat&run=run_9&world=w1') {
+ // pre-fill) land here from `/modules`.
+ function renderDeepLinked(initialUrl = '/modules?module=basic-combat&run=run_9&world=w1') {
return renderInApp(, {
client: client(),
initialRouterEntries: [initialUrl],
@@ -983,8 +983,8 @@ describe('ModulesPage deep-link re-sync (V1.147 PR #194)', () => {
useHandlers(...moduleSwitchHandlers());
renderWithNav(
- '/settings/modules?module=basic-combat',
- '/settings/modules?module=scouting-party',
+ '/modules?module=basic-combat',
+ '/modules?module=scouting-party',
);
// Deep link opens the basic-combat detail (author copy is detail-only).
@@ -1011,7 +1011,7 @@ describe('ModulesPage deep-link re-sync (V1.147 PR #194)', () => {
const user = userEvent.setup();
useHandlers(...moduleSwitchHandlers());
- renderWithNav('/settings/modules?module=basic-combat', '/settings/modules');
+ renderWithNav('/modules?module=basic-combat', '/modules');
// Deep link opens the basic-combat detail.
await screen.findByText('Nexus Team');
@@ -1019,7 +1019,7 @@ describe('ModulesPage deep-link re-sync (V1.147 PR #194)', () => {
// `?module=` removed (browser back to the plain list) — the selection
// must clear and the list placeholder return.
- await user.click(screen.getByRole('button', { name: /go-\/settings\/modules$/ }));
+ await user.click(screen.getByRole('button', { name: /go-\/modules$/ }));
await screen.findByText('Select a module');
expect(screen.queryByTestId('run-studio')).not.toBeInTheDocument();
diff --git a/apps/web/src/pages/modules-page.tsx b/apps/web/src/pages/modules-page.tsx
index 317264c7a..25d35e8f5 100644
--- a/apps/web/src/pages/modules-page.tsx
+++ b/apps/web/src/pages/modules-page.tsx
@@ -1,9 +1,8 @@
/**
- * Compute Modules body (Control Room — READ + Run) — V1.114 P2 T4 / V1.131 P2 /
- * V1.147 P1 T3 / V1.147 P2 T3.
+ * Compute Modules page (Control Room — READ + Run) — V1.114 P2 T4 / V1.147 P1 T3
+ * / V1.147 P2 T3.
*
- * List/detail/query/error live here once. Settings modal mounts the body as
- * the `modules` section; `/modules` is a compatibility redirect only.
+ * List/detail/query/error live here once, rendered as the `/modules` page.
*
* V1.147 P1: the detail panel gains the Run Studio — World selector, guided
* form (manifest `schemas.invocation` → first-class controls), Advanced JSON
@@ -15,7 +14,7 @@
* `components/compute/run-studio.tsx` (qc1 W-004 hook extraction — the
* Timeline Run Module entry mounts the SAME studio with context pre-fill).
* This page gains the deep-link selection mechanism: `?module=&run=`
- * (compute node "Open Run" → Settings → Modules run detail) and
+ * (compute node "Open Run" → Modules run detail) and
* `?world=` + `?branch=` (Timeline Run Module entry → World + fork
* branch pre-filled — Bugbot 1). Module
* selection + run opening write the params back so refresh keeps the detail.
@@ -23,7 +22,7 @@
import { Cpu, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Navigate, useSearchParams } from 'react-router';
+import { useSearchParams } from 'react-router';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -34,12 +33,12 @@ import { isOrchestrationEngineUnavailable } from '@/lib/nexus/errors';
import { RunStudio } from '@/components/compute/run-studio';
import { cn } from '@/lib/utils';
-/** Compatibility adapter — product entry is Settings modal `modules` section. */
+/** `/modules` page entry — the single owner of the Modules surface. */
export function ModulesPage() {
- return ;
+ return ;
}
-/** Shared list/detail body — reused by SettingsModulesSection (no duplicate hooks). */
+/** List/detail body (kept separate so the page and its tests share one body). */
export function ModulesPageBody() {
const { t } = useTranslation('modules');
const modules = useComputeModules();
@@ -56,7 +55,7 @@ export function ModulesPageBody() {
// V1.147 PR #194 — deep-link `?module=` re-sync. The Settings Modules
// section stays mounted when a second compute-node "Open Run" navigates to
- // the same route (`/settings/modules?module=…` — search-params-only), so
+ // the same route (`/modules?module=…` — search-params-only), so
// the `useState` seed alone would keep the stale module selected. Keyed on
// the URL VALUE: external navigation (Timeline "Open Run", back/forward)
// switches the detail panel, and `?module=` removal clears the selection.
diff --git a/apps/web/src/pages/settings/settings-modules-section.tsx b/apps/web/src/pages/settings/settings-modules-section.tsx
deleted file mode 100644
index b955a90c2..000000000
--- a/apps/web/src/pages/settings/settings-modules-section.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Settings Modules section — V1.131 P2 (`DF-V1130-COMPUTE-IN-SETTINGS`).
- *
- * Reuses {@link ModulesPageBody} query/detail implementation; no nested dialog.
- */
-import { ModulesPageBody } from '@/pages/modules-page';
-
-export function SettingsModulesSection() {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/pages/settings/settings-section-frame.test.tsx b/apps/web/src/pages/settings/settings-section-frame.test.tsx
index df50e772f..99dcf5650 100644
--- a/apps/web/src/pages/settings/settings-section-frame.test.tsx
+++ b/apps/web/src/pages/settings/settings-section-frame.test.tsx
@@ -2,7 +2,7 @@
* SettingsSectionFrame — entrance-filtered rail (V1.170 P1 — plan QC W-2).
*
* The frame is presentational: the host supplies `hiddenSettingsSections`
- * from the entrance registry (Create hides agent/modules/advanced). This
+ * from the entrance registry (Create hides agent/advanced). This
* pins the rail filter — a hidden section must not render a nav button, so
* the Create tree cannot advertise a develop-only section that the guard
* would bounce.
@@ -40,25 +40,24 @@ describe('SettingsSectionFrame rail (W-2)', () => {
renderFrame([]);
const nav = screen.getByTestId('settings-section-nav');
- for (const id of ['agent', 'workspace', 'appearance', 'modules', 'advanced']) {
+ for (const id of ['agent', 'workspace', 'appearance', 'advanced']) {
expect(within(nav).getByTestId(`settings-section-nav-${id}`)).toBeInTheDocument();
}
});
- it('hides the develop-only sections on Create (agent/modules/advanced)', () => {
- renderFrame(['agent', 'modules', 'advanced']);
+ it('hides the develop-only sections on Create (agent/advanced)', () => {
+ renderFrame(['agent', 'advanced']);
const nav = screen.getByTestId('settings-section-nav');
expect(within(nav).getByTestId('settings-section-nav-workspace')).toBeInTheDocument();
expect(within(nav).getByTestId('settings-section-nav-appearance')).toBeInTheDocument();
expect(within(nav).queryByTestId('settings-section-nav-agent')).not.toBeInTheDocument();
- expect(within(nav).queryByTestId('settings-section-nav-modules')).not.toBeInTheDocument();
expect(within(nav).queryByTestId('settings-section-nav-advanced')).not.toBeInTheDocument();
});
it('forwards a visible-section click to the host', async () => {
const user = userEvent.setup();
- const { onSelectSection } = renderFrame(['agent', 'modules', 'advanced']);
+ const { onSelectSection } = renderFrame(['agent', 'advanced']);
await user.click(screen.getByTestId('settings-section-nav-appearance'));
diff --git a/apps/web/src/pages/settings/settings-shell.test.tsx b/apps/web/src/pages/settings/settings-shell.test.tsx
index 9ef064ce1..a9611635e 100644
--- a/apps/web/src/pages/settings/settings-shell.test.tsx
+++ b/apps/web/src/pages/settings/settings-shell.test.tsx
@@ -124,12 +124,6 @@ function healthHandler() {
);
}
-function modulesListHandler() {
- return http.get('/v1/daemon/compute/modules', () =>
- HttpResponse.json({ items: [] }),
- );
-}
-
function renderModalApp(initialRouterEntries: string[]) {
// The modal suite pins the full-Control-Room settings surface (modal →
// agent, full rail, all sections). Post-entrance-split that surface is the
@@ -171,7 +165,7 @@ describe('Settings modal primary', () => {
expect(screen.queryByTestId('settings-shell-page-chrome')).not.toBeInTheDocument();
});
- it('renders section nav with Agent, Profiles, Appearance, Modules, and Advanced', async () => {
+ it('renders section nav with Agent, Profiles, Appearance, and Advanced', async () => {
useHandlers(scanHandler(), creatorsHandler(), healthHandler());
renderModalApp(['/settings/agent']);
@@ -186,9 +180,6 @@ describe('Settings modal primary', () => {
expect(within(nav).getByTestId('settings-section-nav-appearance')).toHaveTextContent(
'Appearance',
);
- expect(within(nav).getByTestId('settings-section-nav-modules')).toHaveTextContent(
- 'Modules',
- );
expect(within(nav).getByTestId('settings-section-nav-advanced')).toHaveTextContent(
'Advanced',
);
@@ -204,7 +195,6 @@ describe('Settings modal primary', () => {
);
const nav = screen.getByTestId('settings-section-nav');
expect(within(nav).getByTestId('settings-section-nav-agent')).toHaveTextContent('智能体');
- expect(within(nav).getByTestId('settings-section-nav-modules')).toHaveTextContent('模块');
expect(within(nav).getByTestId('settings-section-nav-advanced')).toHaveTextContent('高级');
});
@@ -226,36 +216,6 @@ describe('Settings modal primary', () => {
expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/advanced');
});
- it('mounts Modules section body inside the modal', async () => {
- useHandlers(
- scanHandler(),
- creatorsHandler(),
- healthHandler(),
- modulesListHandler(),
- );
- renderModalApp(['/settings/modules']);
-
- await waitFor(() =>
- expect(screen.getByTestId('settings-modules-section')).toBeInTheDocument(),
- );
- expect(screen.getByTestId('modules-page-body')).toBeInTheDocument();
- });
-
- it('normalizes /modules into the Modules section', async () => {
- useHandlers(
- scanHandler(),
- creatorsHandler(),
- healthHandler(),
- modulesListHandler(),
- );
- renderModalApp(['/modules']);
-
- await waitFor(() =>
- expect(screen.getByTestId('settings-modules-section')).toBeInTheDocument(),
- );
- expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/modules');
- });
-
it('normalizes /settings/connection to advanced#connection', async () => {
useHandlers(scanHandler(), creatorsHandler(), healthHandler());
renderModalApp(['/settings/connection']);
From 692354680f4d81813af27bf8051cbf412928c94d Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 19:35:16 +0800
Subject: [PATCH 04/16] refactor(web): move /modules body into the developer
generation with presentational chrome
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- components/developer/modules-page.tsx keeps the app wiring (queries, deep-link
router reads, copy assembly); pages/modules-page.tsx is deleted
- presentational/modules-page-chrome.tsx: ModulesPageChrome + ModuleDetailChrome
(+ DetailField, SchemaBlock), pure view with a runStudio slot
- presentational/run-studio-chrome.tsx: RunStudioChrome, pure view driven by
copy/world/form/advanced/run/proposal/runs view models
- compute/run-studio.tsx keeps every hook, mutation, toast and the two confirm
dialogs; public props unchanged, canvas entry untouched
- app-registry modules.load → the new page
- close T1 residuals F1-F5 (stale SSOT/section comments, dead shell.nav.modules
locale key, unexported dead SettingsModalContext)
---
.../src/fixtures/settings-host-fixtures.tsx | 2 +-
.../__tests__/compute-events-canvas.test.tsx | 2 +-
.../web/src/components/compute/run-studio.tsx | 547 ++++++------------
.../developer}/modules-page.test.tsx | 2 +-
.../src/components/developer/modules-page.tsx | 222 +++++++
.../presentational/modules-page-chrome.tsx | 392 +++++++++++++
.../presentational/run-studio-chrome.tsx | 518 +++++++++++++++++
.../layout/settings-modal-context.tsx | 5 +-
.../src/components/layout/sidebar.test.tsx | 2 +-
apps/web/src/lib/app-registry.ts | 3 +-
apps/web/src/locales/en/shell.json | 1 -
apps/web/src/locales/zh-CN/shell.json | 1 -
apps/web/src/pages/modules-page.tsx | 361 ------------
13 files changed, 1321 insertions(+), 737 deletions(-)
rename apps/web/src/{pages => components/developer}/modules-page.test.tsx (99%)
create mode 100644 apps/web/src/components/developer/modules-page.tsx
create mode 100644 apps/web/src/components/developer/presentational/modules-page-chrome.tsx
create mode 100644 apps/web/src/components/developer/presentational/run-studio-chrome.tsx
delete mode 100644 apps/web/src/pages/modules-page.tsx
diff --git a/apps/design-studio/src/fixtures/settings-host-fixtures.tsx b/apps/design-studio/src/fixtures/settings-host-fixtures.tsx
index a10861208..0a0f73819 100644
--- a/apps/design-studio/src/fixtures/settings-host-fixtures.tsx
+++ b/apps/design-studio/src/fixtures/settings-host-fixtures.tsx
@@ -195,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,
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 69c51e7b2..7abb13c14 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
@@ -26,7 +26,7 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Link, Route, Routes } from 'react-router';
-import { ModulesPage } from '@/pages/modules-page';
+import { ModulesPage } from '@/components/developer/modules-page';
import { renderInApp } from '@/test/test-providers';
import { useHandlers } from '@/test/msw-server';
import { BrowserClient } from '@/lib/nexus';
diff --git a/apps/web/src/components/compute/run-studio.tsx b/apps/web/src/components/compute/run-studio.tsx
index b02e0e938..d6904c25f 100644
--- a/apps/web/src/components/compute/run-studio.tsx
+++ b/apps/web/src/components/compute/run-studio.tsx
@@ -1,14 +1,11 @@
/**
* 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:
@@ -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(() => {
@@ -147,13 +144,13 @@ export function RunStudio({
// 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).
+ // (`/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
@@ -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
@@ -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..05b5c8b2f
--- /dev/null
+++ b/apps/web/src/components/developer/presentational/run-studio-chrome.tsx
@@ -0,0 +1,518 @@
+/**
+ * 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 { 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;
+ formCopy: RunFormCopy;
+ proposalsCopy: ProposalSectionsCopy;
+ runsTableCopy: RunsTableCopy;
+}
+
+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) {
+ 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 && (
+
+ )}
+
- );
-}
From f2339aa0cae511e8584d35685dc0513d76521230 Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 19:45:36 +0800
Subject: [PATCH 05/16] feat(studio): fixture the modules rail chrome and
register its extract alias
- fixtures/modules-rail-fixtures.tsx: ModulesPageChrome + ModuleDetailChrome +
RunStudioChrome from static view models and literal copy (list with one
selected, manifest detail beside the select prompt, run pending/success/
detail-error); theme follows the Studio toggle
- fixtures/__tests__/modules-rail-fixtures.test.tsx: one assertion per variant
testid plus a both-theme render smoke
- @web-developer alias in tsconfig + vite + vitest (Studio resolves every
@web-* extract through all three)
- surface-source-badge: classify @web-developer/* as an app presentational
extract (test case mirrored)
- gallery-index: surfaces-rail-apps shell entry + overview row
- surfaces.tsx: render the fixture in SurfacesShellPage under
data-testid=surfaces-rail-apps (no new route)
---
.../__tests__/surface-source-badge.test.tsx | 6 +
.../src/components/surface-source-badge.tsx | 1 +
.../__tests__/modules-rail-fixtures.test.tsx | 101 ++++
.../src/fixtures/modules-rail-fixtures.tsx | 505 ++++++++++++++++++
apps/design-studio/src/lib/gallery-index.ts | 5 +
apps/design-studio/src/pages/surfaces.tsx | 26 +
apps/design-studio/tsconfig.json | 3 +-
apps/design-studio/vite.config.ts | 5 +
apps/design-studio/vitest.config.ts | 5 +
9 files changed, 656 insertions(+), 1 deletion(-)
create mode 100644 apps/design-studio/src/fixtures/__tests__/modules-rail-fixtures.test.tsx
create mode 100644 apps/design-studio/src/fixtures/modules-rail-fixtures.tsx
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__/modules-rail-fixtures.test.tsx b/apps/design-studio/src/fixtures/__tests__/modules-rail-fixtures.test.tsx
new file mode 100644
index 000000000..2fb9d6792
--- /dev/null
+++ b/apps/design-studio/src/fixtures/__tests__/modules-rail-fixtures.test.tsx
@@ -0,0 +1,101 @@
+import { cleanup, render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { ModulesRailFixtures } from '@/fixtures/modules-rail-fixtures';
+
+/**
+ * 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('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 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 classes survive the toggle — the chrome has no theme prop.
+ expect(screen.getByTestId('modules-rail-fixture-list')).toHaveClass('bg-background-100');
+
+ cleanup();
+ }
+ });
+});
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..464fa2388
--- /dev/null
+++ b/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx
@@ -0,0 +1,505 @@
+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.',
+ formCopy: FORM_COPY,
+ proposalsCopy: PROPOSAL_COPY,
+ runsTableCopy: RUNS_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.
+
+
+
+
+
);
}
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: {
From 4990fb0801e549e29924c3adaf66de9d7f68a451 Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 19:59:33 +0800
Subject: [PATCH 06/16] fix(web): drop unused RunStudioCopy
formCopy/runsTableCopy props (Q3-01)
---
apps/design-studio/src/fixtures/modules-rail-fixtures.tsx | 2 --
apps/web/src/components/compute/run-studio.tsx | 2 --
.../components/developer/presentational/run-studio-chrome.tsx | 2 --
3 files changed, 6 deletions(-)
diff --git a/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx b/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx
index 464fa2388..6021b58ed 100644
--- a/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx
+++ b/apps/design-studio/src/fixtures/modules-rail-fixtures.tsx
@@ -188,9 +188,7 @@ const RUN_COPY: RunStudioCopy = {
appliedNote: 'Proposals applied to the World.',
discardedNote: 'Proposals discarded. World unchanged.',
runningNote: 'This Run is still executing.',
- formCopy: FORM_COPY,
proposalsCopy: PROPOSAL_COPY,
- runsTableCopy: RUNS_COPY,
};
/* ------------------------------------------------------------------ */
diff --git a/apps/web/src/components/compute/run-studio.tsx b/apps/web/src/components/compute/run-studio.tsx
index d6904c25f..ccbcef73a 100644
--- a/apps/web/src/components/compute/run-studio.tsx
+++ b/apps/web/src/components/compute/run-studio.tsx
@@ -704,9 +704,7 @@ function runStudioCopy(t: TFunction): RunStudioCopy {
appliedNote: t('run.appliedNote'),
discardedNote: t('run.discardedNote'),
runningNote: t('run.runningNote'),
- formCopy: runFormCopy(t),
proposalsCopy: proposalCopy(t),
- runsTableCopy: runsTableCopy(t),
};
}
diff --git a/apps/web/src/components/developer/presentational/run-studio-chrome.tsx b/apps/web/src/components/developer/presentational/run-studio-chrome.tsx
index 05b5c8b2f..5d46f52fa 100644
--- a/apps/web/src/components/developer/presentational/run-studio-chrome.tsx
+++ b/apps/web/src/components/developer/presentational/run-studio-chrome.tsx
@@ -158,9 +158,7 @@ export interface RunStudioCopy {
appliedNote: string;
discardedNote: string;
runningNote: string;
- formCopy: RunFormCopy;
proposalsCopy: ProposalSectionsCopy;
- runsTableCopy: RunsTableCopy;
}
export interface RunStudioChromeProps {
From 5cf6b3827935648f6c30209a48ce42969e9d2c84 Mon Sep 17 00:00:00 2001
From: Auto Wood
Date: Sat, 24 Oct 2026 20:07:12 +0800
Subject: [PATCH 07/16] feat(studio): split the capabilities page chrome and
fixture it
Move the /capabilities markup into
components/developer/presentational/capabilities-page-chrome.tsx (copy as
props, no transport/router/i18n) and relocate the page to
components/developer/capabilities-page.tsx as pure wiring, so the Design
Studio fixture renders it from static view models.
Split the collapsed empty state into two branches: registry-empty (unchanged
copy, wins over a filter) and filtered-miss (new keys quoting the query plus
a Clear filter action via the EmptyState action slot). Match semantics and
the read-only ?filter= seed are unchanged.
Register the four-variant fixture (empty registry, filtered hit, filtered
miss, error) in the Studio surfaces shell gallery, both themes.
---
.../capabilities-rail-fixtures.test.tsx | 99 ++++++++++
.../fixtures/capabilities-rail-fixtures.tsx | 174 +++++++++++++++++
apps/design-studio/src/lib/gallery-index.ts | 3 +
apps/design-studio/src/pages/surfaces.tsx | 24 +++
.../developer}/capabilities-page.test.tsx | 88 ++++++++-
.../developer/capabilities-page.tsx | 77 ++++++++
.../capabilities-page-chrome.tsx | 183 ++++++++++++++++++
apps/web/src/lib/app-registry.ts | 4 +-
apps/web/src/locales/en/capabilities.json | 3 +
apps/web/src/locales/zh-CN/capabilities.json | 3 +
apps/web/src/pages/capabilities-page.tsx | 114 -----------
11 files changed, 656 insertions(+), 116 deletions(-)
create mode 100644 apps/design-studio/src/fixtures/__tests__/capabilities-rail-fixtures.test.tsx
create mode 100644 apps/design-studio/src/fixtures/capabilities-rail-fixtures.tsx
rename apps/web/src/{pages => components/developer}/capabilities-page.test.tsx (67%)
create mode 100644 apps/web/src/components/developer/capabilities-page.tsx
create mode 100644 apps/web/src/components/developer/presentational/capabilities-page-chrome.tsx
delete mode 100644 apps/web/src/pages/capabilities-page.tsx
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..8ef74aff4
--- /dev/null
+++ b/apps/design-studio/src/fixtures/__tests__/capabilities-rail-fixtures.test.tsx
@@ -0,0 +1,99 @@
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { CapabilitiesRailFixtures } from '@/fixtures/capabilities-rail-fixtures';
+
+/**
+ * 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('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 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 classes survive the toggle — the chrome has no theme prop.
+ expect(screen.getByTestId('capabilities-rail-fixture-hit')).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 (
+
+ 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.
+
+
+
+
+
);
}
diff --git a/apps/web/src/pages/capabilities-page.test.tsx b/apps/web/src/components/developer/capabilities-page.test.tsx
similarity index 67%
rename from apps/web/src/pages/capabilities-page.test.tsx
rename to apps/web/src/components/developer/capabilities-page.test.tsx
index 3f60c6857..81fe6f430 100644
--- a/apps/web/src/pages/capabilities-page.test.tsx
+++ b/apps/web/src/components/developer/capabilities-page.test.tsx
@@ -7,6 +7,11 @@
*
* Also verifies the AR-42 provenance surface: `origin: "user"` capabilities
* render a Local badge + local-only copy; builtins render a plain row.
+ *
+ * v221-caps-sessions-rail T1 — relocated beside the page and extended with the
+ * two branches the chrome split apart: a filtered miss (its own title quoting
+ * the query + a Clear filter action) and registry-empty precedence over that
+ * miss when no capability is registered at all.
*/
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -15,8 +20,9 @@ import { renderInApp } from '@/test/test-providers';
import { useHandlers } from '@/test/msw-server';
import { BrowserClient } from '@/lib/nexus';
import { i18n } from '@/lib/i18n/config';
-import { CapabilitiesPage } from '@/pages/capabilities-page';
+import { CapabilitiesPage } from '@/components/developer/capabilities-page';
import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import enCapabilities from '@/locales/en/capabilities.json';
import zhCapabilities from '@/locales/zh-CN/capabilities.json';
@@ -121,6 +127,86 @@ describe('CapabilitiesPage', () => {
expect(screen.getByDisplayValue('nexus.world.describe')).toBeInTheDocument();
});
+ it('renders the filtered-miss state for a deep link that matches nothing', async () => {
+ useHandlers(
+ http.get('/v1/daemon/orchestration/capabilities', () =>
+ HttpResponse.json({
+ items: [
+ {
+ name: 'nexus.world.describe',
+ inputSchema: '{"type":"object"}',
+ outputSchema: '{"type":"string"}',
+ },
+ ],
+ pagination: { limit: 20, has_more: false },
+ }),
+ ),
+ );
+
+ renderInApp(, {
+ client: client(),
+ initialRouterEntries: ['/capabilities?filter=no.such.cap'],
+ });
+
+ // The miss is a state, not an error: its own title quotes the query.
+ expect(
+ await screen.findByText('No capability matches "no.such.cap"'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Clear the filter to see all capabilities.')).toBeInTheDocument();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.queryByText('No capabilities')).not.toBeInTheDocument();
+ });
+
+ it('clears the filter in place from the filtered-miss action', async () => {
+ const user = userEvent.setup();
+ useHandlers(
+ http.get('/v1/daemon/orchestration/capabilities', () =>
+ HttpResponse.json({
+ items: [
+ {
+ name: 'nexus.world.describe',
+ inputSchema: '{"type":"object"}',
+ outputSchema: '{"type":"string"}',
+ },
+ ],
+ pagination: { limit: 20, has_more: false },
+ }),
+ ),
+ );
+
+ renderInApp(, {
+ client: client(),
+ initialRouterEntries: ['/capabilities?filter=no.such.cap'],
+ });
+
+ await user.click(await screen.findByRole('button', { name: 'Clear filter' }));
+
+ // The full registry shows in place and the input is emptied.
+ expect(await screen.findByText('nexus.world.describe')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('')).toBeInTheDocument();
+ expect(screen.queryByText('No capability matches "no.such.cap"')).not.toBeInTheDocument();
+ });
+
+ it('renders the registry-empty state when a filter is typed but nothing is registered', async () => {
+ useHandlers(
+ http.get('/v1/daemon/orchestration/capabilities', () =>
+ HttpResponse.json({ items: [], pagination: { limit: 20, has_more: false } }),
+ ),
+ );
+
+ renderInApp(, {
+ client: client(),
+ initialRouterEntries: ['/capabilities?filter=nexus'],
+ });
+
+ // Registry-empty wins: the filter cannot be blamed for an empty registry.
+ expect(
+ await screen.findByText('Capabilities will appear here once the runtime registers them.'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/No capability matches/)).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument();
+ });
+
it('switches to zh-CN locale without remounting', async () => {
useHandlers(
http.get('/v1/daemon/orchestration/capabilities', () =>
diff --git a/apps/web/src/components/developer/capabilities-page.tsx b/apps/web/src/components/developer/capabilities-page.tsx
new file mode 100644
index 000000000..e1e18af29
--- /dev/null
+++ b/apps/web/src/components/developer/capabilities-page.tsx
@@ -0,0 +1,77 @@
+/**
+ * Capability registry browser (Control Room — READ) — web-ui.md §6.1 #4 /
+ * plan v221-caps-sessions-rail T1.
+ *
+ * Lists every `nexus.*` capability the runtime exposes with its I/O schemas.
+ * Surfaces the V1.34 agent tool bridge so authors can see what presets can
+ * invoke. Admission-gate details are enforced at invocation time and are not
+ * included in the list response; the per-card note surfaces that limitation.
+ *
+ * v221-caps-sessions-rail T1 — the markup moved to
+ * `components/developer/presentational/capabilities-page-chrome.tsx` (pure
+ * view, no transport / router / i18n) so the Design Studio fixture can render
+ * it with static copy. This file keeps only app wiring: the query, the router
+ * read and the copy assembly.
+ */
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSearchParams } from 'react-router';
+import type { TFunction } from 'i18next';
+
+import { useCapabilities } from '@/api/queries';
+import {
+ CapabilitiesPageChrome,
+ type CapabilitiesPageCopy,
+} from '@/components/developer/presentational/capabilities-page-chrome';
+
+/** `/capabilities` page entry — the registry's `load` target. */
+export function CapabilitiesPage() {
+ const { t } = useTranslation('capabilities');
+ const caps = useCapabilities();
+ const [searchParams] = useSearchParams();
+ // V1.171 P1 (PL-13) — the preset profile deep-links required capabilities
+ // with `?filter=`; seed the local filter so the linked schema is
+ // visible on arrival. The param is a read-only seed: typing does not write
+ // it back and a later param change does not re-seed. No param → full list.
+ const [filter, setFilter] = useState(() => searchParams.get('filter') ?? '');
+
+ return (
+ caps.refetch(),
+ }}
+ filter={filter}
+ onFilterChange={setFilter}
+ />
+ );
+}
+
+/* ── Caller-owned copy (i18n lives in the app, not in the chrome) ─────────── */
+
+function pageCopy(t: TFunction): CapabilitiesPageCopy {
+ return {
+ title: t('title'),
+ description: t('description'),
+ filterLabel: t('filterLabel'),
+ filterPlaceholder: t('filterPlaceholder'),
+ refresh: t('refresh'),
+ refreshAria: t('refreshAria'),
+ errorDescription: t('errorDescription'),
+ loading: t('loading'),
+ emptyTitle: t('emptyTitle'),
+ emptyDescription: t('emptyDescription'),
+ filteredMissTitle: (filter) => t('filteredMissTitle', { filter }),
+ filteredMissDescription: t('filteredMissDescription'),
+ clearFilter: t('clearFilter'),
+ inputSchema: t('inputSchema'),
+ outputSchema: t('outputSchema'),
+ userBadge: t('userBadge'),
+ localOnlyCopy: t('localOnlyCopy'),
+ admissionGatesInfo: t('admissionGatesInfo'),
+ };
+}
diff --git a/apps/web/src/components/developer/presentational/capabilities-page-chrome.tsx b/apps/web/src/components/developer/presentational/capabilities-page-chrome.tsx
new file mode 100644
index 000000000..0f29577a3
--- /dev/null
+++ b/apps/web/src/components/developer/presentational/capabilities-page-chrome.tsx
@@ -0,0 +1,183 @@
+/**
+ * Presentational body of the `/capabilities` surface (plan
+ * v221-caps-sessions-rail T1).
+ *
+ * The product page (`components/developer/capabilities-page.tsx`) owns the
+ * query, the router read and the i18n lookups; this module owns the markup so
+ * the Design Studio fixture can render the same tree from static view models
+ * with literal copy.
+ *
+ * Boundary: no transport, no router, no i18n, no QueryClient. Copy arrives in
+ * `copy` props, data as view models, behavior as callbacks.
+ */
+import { Info, RefreshCw } from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { EmptyState, ErrorState, LoadingState } from '@/components/ui/states';
+
+/** A capability row, reduced to what the chrome renders. */
+export interface CapabilityView {
+ name: string;
+ inputSchema: string;
+ outputSchema: string;
+ origin?: 'builtin' | 'user';
+}
+
+export interface CapabilitiesPageCopy {
+ title: string;
+ description: string;
+ filterLabel: string;
+ filterPlaceholder: string;
+ refresh: string;
+ refreshAria: string;
+ errorDescription: string;
+ loading: string;
+ /** Registry-empty branch (no capability items at all). */
+ emptyTitle: string;
+ emptyDescription: string;
+ /** Filtered-miss branch (registry non-empty, filter matched nothing). */
+ filteredMissTitle: (filter: string) => string;
+ filteredMissDescription: string;
+ clearFilter: string;
+ inputSchema: string;
+ outputSchema: string;
+ userBadge: string;
+ localOnlyCopy: string;
+ admissionGatesInfo: string;
+}
+
+/** The list query, reduced to what the chrome renders. */
+export interface CapabilitiesListView {
+ items?: readonly CapabilityView[];
+ isLoading: boolean;
+ isError: boolean;
+ isRefreshing: boolean;
+ onRefresh: () => void;
+}
+
+export interface CapabilitiesPageChromeProps {
+ copy: CapabilitiesPageCopy;
+ list: CapabilitiesListView;
+ /** Controlled filter text (the caller owns where the seed comes from). */
+ filter: string;
+ onFilterChange: (filter: string) => void;
+}
+
+/**
+ * Capability registry browser — header, filter, card list and the four
+ * content states. Matching is case-insensitive substring on `name`
+ * (unchanged); registry-empty takes precedence over filtered-miss.
+ */
+export function CapabilitiesPageChrome({
+ copy,
+ list,
+ filter,
+ onFilterChange,
+}: CapabilitiesPageChromeProps) {
+ const items = list.items ?? [];
+ const query = filter.trim().toLowerCase();
+ const matched = query
+ ? items.filter((c) => c.name.toLowerCase().includes(query))
+ : items;
+
+ return (
+
+
+
+ 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/web/src/components/developer/hub-card.test.tsx b/apps/web/src/components/developer/hub-card.test.tsx
index 87946d66a..9acc8dd79 100644
--- a/apps/web/src/components/developer/hub-card.test.tsx
+++ b/apps/web/src/components/developer/hub-card.test.tsx
@@ -38,6 +38,23 @@ describe('HubCard', () => {
expect(screen.queryAllByRole('textbox')).toHaveLength(0);
});
+ // v221-caps-sessions-rail T2 — the sessions card needs an inline node title
+ // (mono short id) and `error` for a `failed` session; the markup is unchanged.
+ it('accepts a node title and an error pill', () => {
+ renderInApp(
+ sess-0001}
+ status={{ label: 'Failed', variant: 'error' }}
+ />,
+ );
+
+ const card = screen.getByTestId('hub-card-session');
+ expect(screen.getByText('sess-0001')).toBeInTheDocument();
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+ expect(card.innerHTML).toContain('nexus-ui-badge-soft-error-bg');
+ });
+
it('renders a title-only card without a navigation link', () => {
renderInApp();
diff --git a/apps/web/src/components/developer/hub-card.tsx b/apps/web/src/components/developer/hub-card.tsx
index 67da35cf6..c90a4a655 100644
--- a/apps/web/src/components/developer/hub-card.tsx
+++ b/apps/web/src/components/developer/hub-card.tsx
@@ -8,17 +8,18 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
-import { Badge } from '@/components/ui/badge';
+import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export interface HubCardProps {
/** Stable test-id stem (`service` → `hub-card-service`). */
id: string;
- title: string;
+ /** Title text, or a node when the owning surface needs inline markup. */
+ title: ReactNode;
/** Status pill in the header (e.g. 运行中 / 已连接). */
status?: {
label: string;
- variant: 'running' | 'warning' | 'neutral';
+ variant: BadgeProps['variant'];
};
/** Navigation-only link into the owning rail app. */
go?: { to: string; label: string };
diff --git a/apps/web/src/components/developer/presentational/session-card.tsx b/apps/web/src/components/developer/presentational/session-card.tsx
new file mode 100644
index 000000000..2f06e7989
--- /dev/null
+++ b/apps/web/src/components/developer/presentational/session-card.tsx
@@ -0,0 +1,88 @@
+/**
+ * Presentational session card for the `/sessions` rail (plan
+ * v221-caps-sessions-rail T2).
+ *
+ * The product page (`components/developer/sessions-page.tsx`) owns the query,
+ * the i18n lookups and the router `Link`; this module owns the card markup so
+ * the Design Studio fixture can mount it from static view models with literal
+ * copy. The whole card is the link: `renderLink` wraps the frame, so a card has
+ * exactly one focus stop and `HubCard`'s `go` slot stays unused (no nested link).
+ *
+ * Boundary: no transport, no router, no i18n, no formatting. The caller
+ * resolves the short ids, the humanized status label and the pill variant from
+ * the `SessionSummary` contract.
+ */
+import type { ReactNode } from 'react';
+
+// Relative (not `@/`) so the Design Studio gallery resolves it through the
+// `@web-developer/session-card` alias; Studio's `@` points at its own src.
+import { HubCard } from '../hub-card';
+import type { BadgeProps } from '@/components/ui/badge';
+
+/** One session reduced to the fields the card renders. */
+export interface SessionCardView {
+ /** Full contract id — carried in the run-view href. */
+ sessionId: string;
+ /** `shortId(session_id)` — the visible title. */
+ shortId: string;
+ /** `humanizeStatus(status)` + `statusVariant(status)` — unchanged mapping. */
+ status: { label: string; variant: BadgeProps['variant'] };
+ /** `shortId(preset_id)`. */
+ preset: string;
+ /** `shortId(creator_id)`. */
+ creator: string;
+}
+
+export interface SessionCardCopy {
+ /** `Open run view for session {{id}}` with the short id. */
+ openRunAria: (shortId: string) => string;
+ presetLabel: string;
+ creatorLabel: string;
+}
+
+export interface SessionCardLinkArgs {
+ /** `/sessions/` — the run view for this card. */
+ href: string;
+ ariaLabel: string;
+ children: ReactNode;
+}
+
+/** Caller-owned link element (`` in the app, `` in the gallery). */
+export type SessionCardLinkRenderer = (args: SessionCardLinkArgs) => ReactNode;
+
+export interface SessionCardChromeProps {
+ card: SessionCardView;
+ copy: SessionCardCopy;
+ renderLink: SessionCardLinkRenderer;
+}
+
+export function SessionCardChrome({ card, copy, renderLink }: SessionCardChromeProps) {
+ return renderLink({
+ href: `/sessions/${encodeURIComponent(card.sessionId)}`,
+ ariaLabel: copy.openRunAria(card.shortId),
+ children: (
+ {card.shortId}}
+ status={card.status}
+ >
+
+
+
+ ),
+ });
+}
+
+/** The card grid — shared by the page and the fixture so the list layout has one home. */
+export function SessionCardGrid({ children }: { children: ReactNode }) {
+ return