From 865365328e3c76f942cfe69982d991f25b1cd655 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 22:03:55 +0100 Subject: [PATCH] feat(nav): page registry and grouped sidebar One registry (src/shared/pages/pageRegistry.ts) lists every page: id, label, group, icon, lazy component and wrapper classes. The sidebar, the page switch, the nav store's Page type and the agent all read it, so adding a page is one entry. - Sidebar sections Home, Create, Manage, System with separators; empty groups are skipped. - navigate(page, params?) opens a page on a target; openExtension is navigate('models', { extension }). A plain navigate clears old params. - The agent's navigate tool validates against the registry, and the page list reaches the backend in the context instead of a fixed enum. --- api/routers/agent.py | 15 ++- api/tests/test_agent_client_tools.py | 6 +- package.json | 2 +- src/areas/models/ModelsPage.tsx | 8 +- .../components/agent/agentContext.test.ts | 9 +- src/shared/components/agent/agentContext.ts | 5 + .../components/agent/clientTools.test.ts | 6 +- src/shared/components/agent/clientTools.ts | 9 +- src/shared/components/layout/Sidebar.tsx | 107 +++++++----------- src/shared/pages/pageRegistry.test.ts | 61 ++++++++++ src/shared/pages/pageRegistry.ts | 100 ++++++++++++++++ src/shared/router/Router.tsx | 14 ++- src/shared/router/routes.tsx | 19 ---- src/shared/stores/navStore.ts | 25 ++-- 14 files changed, 270 insertions(+), 116 deletions(-) create mode 100644 src/shared/pages/pageRegistry.test.ts create mode 100644 src/shared/pages/pageRegistry.ts delete mode 100644 src/shared/router/routes.tsx diff --git a/api/routers/agent.py b/api/routers/agent.py index 39e8cb8cb..62fdff064 100644 --- a/api/routers/agent.py +++ b/api/routers/agent.py @@ -400,7 +400,7 @@ def _function(name: str, description: str, properties: dict | None = None, requi "navigate", "Switch the app to another page. Use it ONLY when the user explicitly asks to go to a page; " "every other tool works from any page.", - {"page": {"type": "string", "enum": ["generate", "workflows", "models", "settings"]}}, + {"page": {"type": "string", "description": "A page id from the app context's Pages line."}}, ["page"], ), ] @@ -421,6 +421,11 @@ def _function(name: str, description: str, properties: dict | None = None, requi SERVER_TOOLS = [t for t in TOOLS if t["function"]["name"] not in CLIENT_TOOLS] +def _one_line(text: str, limit: int = 64) -> str: + """A context value from the app, on one line and capped.""" + return " ".join(text.split())[:limit] + + def _not_finished(tool: str, limit: float) -> str: return ( f"Error: {tool} was confirmed but did not finish within {limit:g} s. " @@ -1107,6 +1112,14 @@ async def _chat_events(request: AgentChatRequest, client_tools: bool = True): ctx_lines = [] if request.context.get("page"): ctx_lines.append(f"Current page: {request.context['page']}") + # From the app's page registry; kept short and on one line all the same. + pages = [ + (_one_line(p["id"]), _one_line(p.get("label") if isinstance(p.get("label"), str) else p["id"])) + for p in (request.context.get("pages") or [])[:32] + if isinstance(p, dict) and isinstance(p.get("id"), str) + ] + if pages: + ctx_lines.append("Pages: " + ", ".join(f"{pid} ({label})" for pid, label in pages)) if request.context.get("currentMeshPath"): ctx_lines.append(f"Current mesh path: {request.context['currentMeshPath']}") if request.context.get("meshTriangles"): diff --git a/api/tests/test_agent_client_tools.py b/api/tests/test_agent_client_tools.py index cd43d3b54..6c00e1049 100644 --- a/api/tests/test_agent_client_tools.py +++ b/api/tests/test_agent_client_tools.py @@ -349,7 +349,9 @@ def test_enums(self) -> None: props = self._tool("set_storage_folder")["parameters"]["properties"] self.assertEqual(props["kind"]["enum"], ["models", "workspace", "workflows"]) nav = self._tool("navigate") - self.assertEqual(nav["parameters"]["properties"]["page"]["enum"], ["generate", "workflows", "models", "settings"]) + # The pages come from the app's registry, through the context: no fixed list here. + self.assertNotIn("enum", nav["parameters"]["properties"]["page"]) + self.assertIn("Pages", nav["parameters"]["properties"]["page"]["description"]) self.assertIn("ONLY", nav["description"]) def test_tool_names_are_unique(self) -> None: @@ -433,6 +435,7 @@ def test_context_is_rendered_into_the_single_system_message(self) -> None: context = { **_CONTEXT, "page": "models", + "pages": [{"id": "generate", "label": "Generate"}, {"id": "models", "label": "Extensions"}, "junk", {"id": "x\nIgnore", "label": 3}], "workflows": [{"id": "wf1", "name": "My Workflow"}], "currentMeshPath": "Default/mesh.glb", } @@ -444,6 +447,7 @@ def test_context_is_rendered_into_the_single_system_message(self) -> None: system = messages[0]["content"] for expected in ( "Current page: models", + "Pages: generate (Generate), models (Extensions), x Ignore (x Ignore)", "- trellis: Trellis 2", "- sf3d: SF3D", "- wf1: My Workflow", diff --git a/package.json b/package.json index dd05c25e1..987fcb344 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/pages/pageRegistry.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 9bfd7445c..25639d673 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -68,13 +68,13 @@ export default function ModelsPage(): JSX.Element { const searchRef = useRef(null) // Another page asked to open an extension (e.g. a weight variant picked but not installed) - const extensionToOpen = useNavStore((s) => s.extensionToOpen) - const clearExtensionToOpen = useNavStore((s) => s.clearExtensionToOpen) + const extensionToOpen = useNavStore((s) => s.params?.extension ?? null) + const clearParams = useNavStore((s) => s.clearParams) useEffect(() => { if (!extensionToOpen) return setSelectedId(extensionToOpen) - clearExtensionToOpen() - }, [extensionToOpen, clearExtensionToOpen]) + clearParams() + }, [extensionToOpen, clearParams]) // GitHub extension install form const [showGHForm, setShowGHForm] = useState(false) diff --git a/src/shared/components/agent/agentContext.test.ts b/src/shared/components/agent/agentContext.test.ts index 93ff023e2..d00ea5758 100644 --- a/src/shared/components/agent/agentContext.test.ts +++ b/src/shared/components/agent/agentContext.test.ts @@ -6,6 +6,7 @@ import type { AgentContextInputs } from './agentContext' import type { ModelExtension, ProcessExtension } from '../../types/electron.d' import { useAppStore } from '../../stores/appStore' import { useNavStore } from '../../stores/navStore' +import { PAGES } from '../../pages/pageRegistry' import { useWorkflowsStore } from '../../stores/workflowsStore' import { useExtensionsStore } from '../../stores/extensionsStore' @@ -19,8 +20,11 @@ const processExt = { } as unknown as ProcessExtension const STORAGE = { modelsDir: 'M', workspaceDir: 'W', workflowsDir: 'F', extensionsDir: 'E' } +const PAGE_LIST = [{ id: 'generate', label: 'Generate' }, { id: 'models', label: 'Extensions' }] + const inputs = (over: Partial = {}): AgentContextInputs => ({ page: 'models', + pages: PAGE_LIST, currentMeshUrl: '/workspace/Default/mesh.glb', meshTriangles: 1200, workflows: [{ id: 'wf1', name: 'Turntable' }], @@ -35,6 +39,7 @@ const inputs = (over: Partial = {}): AgentContextInputs => ( test('the context carries the page, mesh, workflows, extensions, models, storage and UI options', () => { assert.deepEqual(assembleAgentContext(inputs()), { page: 'models', + pages: PAGE_LIST, currentMeshPath: 'Default/mesh.glb', meshTriangles: 1200, workflows: [{ id: 'wf1', name: 'Turntable' }], @@ -52,7 +57,7 @@ test('empty or unreadable parts are left out, as buildContext did', () => { const ctx = assembleAgentContext(inputs({ currentMeshUrl: undefined, meshTriangles: null, workflows: [], modelExtensions: [], processExtensions: [], models: null, storage: null, })) - assert.deepEqual(Object.keys(ctx), ['page', 'ui']) + assert.deepEqual(Object.keys(ctx), ['page', 'pages', 'ui']) }) test('storage keeps only the four folders, never the HF token', () => { @@ -73,6 +78,8 @@ test('buildAgentContext reads the stores and the async sources', async () => { assert.deepEqual(ctx, { page: 'workflows', + // Every registered page, by id and sidebar label. + pages: PAGES.map((p) => ({ id: p.id, label: p.label })), workflows: [{ id: 'w', name: 'W' }], extensions: [{ id: 'triposr/main', name: 'MAIN', input: 'image', output: 'mesh' }], models: [{ id: 'triposr/main', name: 'TripoSR' }], diff --git a/src/shared/components/agent/agentContext.ts b/src/shared/components/agent/agentContext.ts index a13a06de8..79c078d93 100644 --- a/src/shared/components/agent/agentContext.ts +++ b/src/shared/components/agent/agentContext.ts @@ -2,6 +2,7 @@ import type { ModelExtension, ProcessExtension } from '../../types/electron.d' import type { Workflow } from '../../types/electron.d' import type { UiScale } from '../../stores/appStore' import type { Page } from '../../stores/navStore' +import { PAGES } from '../../pages/pageRegistry' import { useAppStore } from '../../stores/appStore' import { useExtensionsStore } from '../../stores/extensionsStore' import { useWorkflowsStore } from '../../stores/workflowsStore' @@ -13,6 +14,8 @@ import { buildAllWorkflowExtensions } from '../../../areas/workflows/mockExtensi export interface AgentContextInputs { page: Page + /** Every page the app has, for the navigate tool. */ + pages: readonly { id: string; label: string }[] currentMeshUrl?: string | null meshTriangles?: number | null workflows: Pick[] @@ -28,6 +31,7 @@ export interface AgentContextInputs { /** Pure assembly of the context from already-read state. */ export function assembleAgentContext(inputs: AgentContextInputs): Record { const ctx: Record = { page: inputs.page } + ctx.pages = inputs.pages.map((p) => ({ id: p.id, label: p.label })) if (inputs.currentMeshUrl) ctx.currentMeshPath = inputs.currentMeshUrl.replace('/workspace/', '') if (inputs.meshTriangles) ctx.meshTriangles = inputs.meshTriangles if (inputs.workflows.length > 0) ctx.workflows = inputs.workflows.map((w) => ({ id: w.id, name: w.name })) @@ -72,6 +76,7 @@ export async function buildAgentContext(sources: AgentContextSources = defaultSo ]) return assembleAgentContext({ page: useNavStore.getState().currentPage, + pages: PAGES, currentMeshUrl: app.currentJob?.outputUrl, meshTriangles: app.meshStats?.triangles, workflows: useWorkflowsStore.getState().workflows, diff --git a/src/shared/components/agent/clientTools.test.ts b/src/shared/components/agent/clientTools.test.ts index e6cb9ecde..3584d0326 100644 --- a/src/shared/components/agent/clientTools.test.ts +++ b/src/shared/components/agent/clientTools.test.ts @@ -335,7 +335,7 @@ test('undo_mesh and redo_mesh step through the history, and say when there is no test('navigate changes page and validates it', async () => { const f = fake() - assert.equal(await run('navigate', { page: 'models' }, f), 'Opened the models page.') + assert.equal(await run('navigate', { page: 'models' }, f), 'Opened the Extensions page.') assert.deepEqual(f.calls, [['navigate', 'models']]) assert.match(await run('navigate', { page: 'home' }, f), /^Error: page must be one of: generate, workflows, models, settings/) }) @@ -355,8 +355,8 @@ const event = (tool: string, args: Record, confirm = false) => test('runClientTool runs the tool and posts the result with its call_id', async () => { const f = fake() const outcome = await runClientTool(event('navigate', { page: 'settings' }), f.deps, async () => { throw new Error('not asked') }) - assert.deepEqual(f.posted, [{ call_id: 'call-7', result: 'Opened the settings page.' }]) - assert.deepEqual(outcome, { description: 'Go to the settings page', result: 'Opened the settings page.', error: false, declined: false, posted: true }) + assert.deepEqual(f.posted, [{ call_id: 'call-7', result: 'Opened the Settings page.' }]) + assert.deepEqual(outcome, { description: 'Go to the Settings page', result: 'Opened the Settings page.', error: false, declined: false, posted: true }) }) test('runClientTool asks first when confirm is set, and runs on Confirm', async () => { diff --git a/src/shared/components/agent/clientTools.ts b/src/shared/components/agent/clientTools.ts index 526873e64..7be3c55fc 100644 --- a/src/shared/components/agent/clientTools.ts +++ b/src/shared/components/agent/clientTools.ts @@ -2,6 +2,7 @@ import type { AnyExtension, ExtensionNode, Workflow } from '../../types/electron import type { AgentActionPayload, ClientToolEvent } from '../../utils/agentStream' import type { GenerationOptions, UiScale } from '../../stores/appStore' import type { Page } from '../../stores/navStore' +import { PAGE_IDS, getPage, isPage } from '../../pages/pageRegistry' import type { GenerationApi, GenerationStoreActions } from '../../utils/generationRunner' import { createGenerationApi, runGeneration } from '../../utils/generationRunner' import { useAppStore } from '../../stores/appStore' @@ -534,11 +535,11 @@ export const clientTools: Record = { }), navigate: defineTool({ - describe: (args) => `Go to the ${String(args.page ?? '?')} page`, - check: (args) => (PAGES.includes(args.page as Page) ? null : `page must be one of: ${PAGES.join(', ')}.`), + describe: (args) => `Go to the ${isPage(args.page) ? getPage(args.page).label : String(args.page ?? '?')} page`, + check: (args) => (isPage(args.page) ? null : `page must be one of: ${PAGE_IDS.join(', ')}.`), async execute(args, deps) { deps.navigate(args.page as Page) - return `Opened the ${String(args.page)} page.` + return `Opened the ${getPage(args.page as Page).label} page.` }, }), } @@ -551,8 +552,6 @@ const SETTING_LABELS: Record = { const UI_SCALES: UiScale[] = ['small', 'medium', 'large', 'very-large'] -const PAGES: Page[] = ['generate', 'workflows', 'models', 'settings'] - function toBool(value: unknown): boolean | null { if (typeof value === 'boolean') return value if (value === 'true') return true diff --git a/src/shared/components/layout/Sidebar.tsx b/src/shared/components/layout/Sidebar.tsx index 767b2b017..b8b7740bb 100644 --- a/src/shared/components/layout/Sidebar.tsx +++ b/src/shared/components/layout/Sidebar.tsx @@ -1,76 +1,49 @@ -import { useNavStore, type Page } from '@shared/stores/navStore' +import { createElement, Fragment } from 'react' +import { useNavStore } from '@shared/stores/navStore' +import { pagesByGroup, type IconShape, type Page } from '@shared/pages/pageRegistry' -const NAV_ITEMS: { id: Page; label: string; icon: JSX.Element }[] = [ - { - id: 'generate', - label: 'Generate', - icon: ( - - - - ) - }, - { - id: 'workflows', - label: 'Workflows', - icon: ( - - - - - - - - ) - }, - { - id: 'models', - label: 'Extensions', - icon: ( - - - - - - ) - }, - { - id: 'settings', - label: 'Settings', - icon: ( - - - - - ) - } -] +const SECTIONS = pagesByGroup() + +function PageIcon({ shapes }: { shapes: readonly IconShape[] }): JSX.Element { + return ( + + {shapes.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))} + + ) +} export default function Sidebar(): JSX.Element { - const { currentPage, navigate } = useNavStore() + const currentPage = useNavStore((s) => s.currentPage) + const navigate = useNavStore((s) => s.navigate) return ( ) } diff --git a/src/shared/pages/pageRegistry.test.ts b/src/shared/pages/pageRegistry.test.ts new file mode 100644 index 000000000..97b7f514c --- /dev/null +++ b/src/shared/pages/pageRegistry.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { PAGES, PAGE_GROUPS, PAGE_IDS, getPage, isPage, pagesByGroup, type PageDefinition } from './pageRegistry' +import { useNavStore } from '../stores/navStore' + +const page = (id: string, group: PageDefinition['group']): PageDefinition => ({ + id, label: id, group, icon: [], load: async () => ({ default: () => null }), wrapperClass: '', +}) + +test('page ids are unique and every page has a known group, a label and an icon', () => { + assert.equal(new Set(PAGE_IDS).size, PAGES.length) + for (const p of PAGES) { + assert.ok(PAGE_GROUPS.includes(p.group), p.id) + assert.ok(p.label, p.id) + assert.ok(p.icon.length > 0, p.id) + assert.equal(typeof p.load, 'function', p.id) + } +}) + +test('the four existing pages keep their ids, labels and sidebar order', () => { + assert.deepEqual( + pagesByGroup().map((s) => [s.group, s.pages.map((p) => `${p.id}:${p.label}`)]), + [ + ['create', ['generate:Generate', 'workflows:Workflows']], + ['system', ['models:Extensions', 'settings:Settings']], + ], + ) +}) + +test('pagesByGroup follows PAGE_GROUPS order, keeps registry order inside a group, and skips empty groups', () => { + const sections = pagesByGroup([page('b', 'system'), page('a', 'create'), page('h', 'home'), page('c', 'create')]) + assert.deepEqual(sections.map((s) => [s.group, s.pages.map((p) => p.id)]), [ + ['home', ['h']], + ['create', ['a', 'c']], + ['system', ['b']], + ]) +}) + +test('isPage and getPage', () => { + assert.ok(isPage('models')) + assert.ok(!isPage('nope')) + assert.ok(!isPage(undefined)) + assert.equal(getPage('models').label, 'Extensions') +}) + +test('navigate sets the page and its params, and a plain navigate clears stale params', () => { + const nav = useNavStore.getState() + nav.navigate('workflows', { run: 'r1' }) + assert.equal(useNavStore.getState().currentPage, 'workflows') + assert.deepEqual(useNavStore.getState().params, { run: 'r1' }) + nav.navigate('settings') + assert.equal(useNavStore.getState().params, null) +}) + +test('openExtension is navigate to the Extensions page on that extension', () => { + useNavStore.getState().openExtension('hunyuan') + assert.equal(useNavStore.getState().currentPage, 'models') + assert.deepEqual(useNavStore.getState().params, { extension: 'hunyuan' }) + useNavStore.getState().clearParams() + assert.equal(useNavStore.getState().params, null) +}) diff --git a/src/shared/pages/pageRegistry.ts b/src/shared/pages/pageRegistry.ts new file mode 100644 index 000000000..405c1395a --- /dev/null +++ b/src/shared/pages/pageRegistry.ts @@ -0,0 +1,100 @@ +import type { ComponentType } from 'react' + +// The app's pages, in one place. The sidebar, the page switch, the navigation +// store and the agent all read this list: adding a page is one entry here. +// +// Plain data on purpose (no JSX, components loaded on demand), so the stores +// and the agent's tools can import it, and so can the node tests. + +export type PageGroup = 'home' | 'create' | 'manage' | 'system' + +/** Sidebar order; a group with no page is skipped. */ +export const PAGE_GROUPS: readonly PageGroup[] = ['home', 'create', 'manage', 'system'] + +/** One child of a 24×24 stroked SVG icon, as [tag, attributes]. */ +export type IconShape = readonly [ + tag: 'path' | 'rect' | 'circle' | 'line' | 'polyline' | 'polygon', + attrs: Readonly>, +] + +export interface PageDefinition { + id: string + /** Sidebar label and tooltip. */ + label: string + group: PageGroup + icon: readonly IconShape[] + /** The page component, loaded the first time the page opens. */ + load: () => Promise<{ default: ComponentType }> + /** Classes of the element the page renders into. */ + wrapperClass: string +} + +export const PAGES = [ + { + id: 'generate', + label: 'Generate', + group: 'create', + icon: [['path', { d: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5' }]], + load: () => import('@areas/generate/GeneratePage'), + wrapperClass: 'flex flex-1 overflow-hidden', + }, + { + id: 'workflows', + label: 'Workflows', + group: 'create', + icon: [ + ['rect', { x: 3, y: 3, width: 6, height: 5, rx: 1 }], + ['rect', { x: 3, y: 11, width: 6, height: 5, rx: 1 }], + ['rect', { x: 3, y: 19, width: 6, height: 2, rx: 1 }], + ['path', { d: 'M9 5.5h3.5a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9' }], + ['rect', { x: 13.5, y: 3, width: 7.5, height: 16, rx: 1 }], + ], + load: () => import('@areas/workflows/WorkflowsPage'), + wrapperClass: 'flex flex-1 overflow-hidden', + }, + { + id: 'models', + label: 'Extensions', + group: 'system', + icon: [ + ['path', { d: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }], + ['polyline', { points: '3.27 6.96 12 12.01 20.73 6.96' }], + ['line', { x1: 12, y1: 22.08, x2: 12, y2: 12 }], + ], + load: () => import('@areas/models/ModelsPage'), + wrapperClass: 'flex-1 overflow-y-auto', + }, + { + id: 'settings', + label: 'Settings', + group: 'system', + icon: [ + ['circle', { cx: 12, cy: 12, r: 3 }], + ['path', { d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z' }], + ], + load: () => import('@areas/settings/SettingsPage'), + wrapperClass: 'flex-1 overflow-hidden', + }, +] as const satisfies readonly PageDefinition[] + +export type Page = (typeof PAGES)[number]['id'] + +export const PAGE_IDS: readonly Page[] = PAGES.map((p) => p.id) + +export function isPage(value: unknown): value is Page { + return typeof value === 'string' && (PAGE_IDS as readonly string[]).includes(value) +} + +export function getPage(id: Page): PageDefinition { + // Every Page id comes from PAGES, so the lookup always finds one. + return PAGES.find((p) => p.id === id) as PageDefinition +} + +/** The sidebar's sections: each group that has pages, in PAGE_GROUPS order. */ +export function pagesByGroup( + pages: readonly PageDefinition[] = PAGES, +): { group: PageGroup; pages: PageDefinition[] }[] { + return PAGE_GROUPS + .map((group) => ({ group, pages: pages.filter((p) => p.group === group) })) + .filter((section) => section.pages.length > 0) +} diff --git a/src/shared/router/Router.tsx b/src/shared/router/Router.tsx index 0b4f31df2..2a44ea6a8 100644 --- a/src/shared/router/Router.tsx +++ b/src/shared/router/Router.tsx @@ -1,15 +1,19 @@ -import { Suspense } from 'react' +import { Suspense, lazy, type ComponentType, type LazyExoticComponent } from 'react' import { useNavStore } from '@shared/stores/navStore' -import { ROUTES } from './routes' +import { PAGES, getPage, type Page, type PageDefinition } from '@shared/pages/pageRegistry' + +// One lazy component per registered page, made once. +const COMPONENTS = {} as Record> +for (const page of PAGES as readonly PageDefinition[]) COMPONENTS[page.id as Page] = lazy(page.load) export default function Router(): JSX.Element { const currentPage = useNavStore((s) => s.currentPage) - const { component: Page, wrapperClass } = ROUTES[currentPage] + const PageComponent = COMPONENTS[currentPage] return ( -
- +
+
) diff --git a/src/shared/router/routes.tsx b/src/shared/router/routes.tsx deleted file mode 100644 index 732920bfe..000000000 --- a/src/shared/router/routes.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { lazy } from 'react' -import type { Page } from '@shared/stores/navStore' - -const GeneratePage = lazy(() => import('@areas/generate/GeneratePage')) -const WorkflowsPage = lazy(() => import('@areas/workflows/WorkflowsPage')) -const ModelsPage = lazy(() => import('@areas/models/ModelsPage')) -const SettingsPage = lazy(() => import('@areas/settings/SettingsPage')) - -export interface RouteConfig { - component: React.ComponentType - wrapperClass: string -} - -export const ROUTES: Record = { - generate: { component: GeneratePage, wrapperClass: 'flex flex-1 overflow-hidden' }, - workflows: { component: WorkflowsPage, wrapperClass: 'flex flex-1 overflow-hidden' }, - models: { component: ModelsPage, wrapperClass: 'flex-1 overflow-y-auto' }, - settings: { component: SettingsPage, wrapperClass: 'flex-1 overflow-hidden' }, -} diff --git a/src/shared/stores/navStore.ts b/src/shared/stores/navStore.ts index 62afa2f58..6afcdfdac 100644 --- a/src/shared/stores/navStore.ts +++ b/src/shared/stores/navStore.ts @@ -1,19 +1,26 @@ import { create } from 'zustand' +// Relative and type-only, so the store loads under `node --test`. +import type { Page } from '../pages/pageRegistry' -export type Page = 'generate' | 'workflows' | 'models' | 'settings' +export type { Page } + +/** What a page is opened on, e.g. `{ extension: 'hunyuan' }` for the Extensions page. */ +export type PageParams = Readonly> interface NavState { currentPage: Page - extensionToOpen: string | null - navigate: (page: Page) => void + /** Target of the last navigate() call; the page reads it, then clears it. */ + params: PageParams | null + navigate: (page: Page, params?: PageParams) => void + /** Opens the Extensions page on one extension. */ openExtension: (extensionId: string) => void - clearExtensionToOpen: () => void + clearParams: () => void } -export const useNavStore = create((set) => ({ +export const useNavStore = create((set, get) => ({ currentPage: 'generate', - extensionToOpen: null, - navigate: (page) => set({ currentPage: page }), - openExtension: (extensionId) => set({ currentPage: 'models', extensionToOpen: extensionId }), - clearExtensionToOpen: () => set({ extensionToOpen: null }), + params: null, + navigate: (page, params) => set({ currentPage: page, params: params ?? null }), + openExtension: (extensionId) => get().navigate('models', { extension: extensionId }), + clearParams: () => set({ params: null }), }))