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()} - /> - ) : ( -
- - -

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

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

{t('run.advancedJsonWarning')}

-