From d75dff908a69c5288f7dcd8bea1c5b7805914006 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sun, 4 Oct 2026 10:52:07 +0800 Subject: [PATCH 1/4] refactor(settings): consolidate pages and preserve legacy navigation --- .../technical/product-control-open-audit.json | 14 +- scripts/generate-interactive-capabilities.mjs | 53 +- scripts/i18n-dynamic-key-allowlist.json | 2 +- scripts/interactive-capabilities.test.mjs | 25 +- .../interactive-capabilities/catalog.json | 14 +- .../interactiveCapabilityActivator.test.ts | 22 +- .../interactiveCapabilityCatalog.ts | 18 +- .../global-search/productActionActivator.ts | 2 +- .../settingsCapabilityDestination.ts | 21 + src/web-ui/src/app/scenes/settings/AGENTS.md | 41 + .../SettingsContentTypography.test.ts | 4 +- .../app/scenes/settings/SettingsNav.test.tsx | 37 +- .../src/app/scenes/settings/SettingsNav.tsx | 274 +-- .../scenes/settings/SettingsScene.test.tsx | 8 +- .../src/app/scenes/settings/SettingsScene.tsx | 14 +- .../src/app/scenes/settings/appearance.ts | 20 +- .../components/KeyboardShortcutsTab.scss | 108 - .../components/SettingsViewPage.appearance.ts | 16 - .../settings/components/SettingsViewPage.scss | 49 - .../settings/components/SettingsViewPage.tsx | 82 - ...utomationSettingsPage.presentation.test.ts | 32 - .../pages/AutomationSettingsPage.scss | 44 - .../settings/pages/AutomationSettingsPage.tsx | 59 - .../settings/pages/EditorSettingsPage.tsx | 7 - .../settings/pages/ExecutionSettingsPage.tsx | 11 - .../pages/ai/DefaultHarnessSection.test.tsx} | 6 +- .../pages/ai/DefaultHarnessSection.tsx} | 24 +- .../pages/ai/ExecutionSettingsPage.tsx | 484 +++++ ...emorySettingsSection.presentation.test.ts} | 8 +- .../pages/ai/MemorySettingsSection.tsx} | 50 +- .../pages/ai}/ModelSettingsPage.appearance.ts | 0 .../ModelSettingsPage.presentation.test.ts | 0 .../settings/pages/ai}/ModelSettingsPage.scss | 0 .../settings/pages/ai}/ModelSettingsPage.tsx | 1467 +++++++------- .../pages/ai/PermissionsSettingsPage.tsx | 249 +++ .../pages/ai}/ReviewCapacitySection.test.tsx | 4 +- .../pages/ai}/ReviewCapacitySection.tsx | 10 +- .../pages/ai/SessionMemorySettingsPage.tsx | 19 + .../ai/SessionTitleSection.appearance.ts} | 0 .../SessionTitleSection.presentation.test.ts} | 4 +- .../pages/ai/SessionTitleSection.tsx} | 22 +- .../pages/ai}/ToolJsonRepairSection.tsx | 8 +- .../AppearanceSettingsPage.appearance.ts | 4 +- .../application}/AppearanceSettingsPage.scss | 13 - .../AppearanceSettingsPage.test.tsx | 19 +- .../application/AppearanceSettingsPage.tsx | 37 + .../pages/application/GeneralSettingsPage.tsx | 553 +++++ .../pages/application/InputSettingsPage.tsx | 12 + .../KeyboardShortcutsSection.appearance.ts} | 2 +- ...oardShortcutsSection.presentation.test.ts} | 15 +- .../application/KeyboardShortcutsSection.scss | 69 + .../application/KeyboardShortcutsSection.tsx} | 539 +++-- .../application/LanguageSettingsSection.tsx | 40 + .../LocalVoiceModels.presentation.test.ts} | 4 +- .../application/PetAssistantSettingsPage.tsx | 13 + .../pages/application/PetSettingsSection.tsx | 392 ++++ .../RealtimeVoiceSettingsSection.test.tsx} | 56 +- .../RealtimeVoiceSettingsSection.tsx | 171 ++ .../VoiceSettingsSection.appearance.ts} | 0 ...VoiceSettingsSection.presentation.test.ts} | 18 +- .../application/VoiceSettingsSection.tsx} | 235 +-- ...rchivedSessionsSettingsPage.appearance.ts} | 0 .../data/ArchivedSessionsSettingsPage.scss} | 0 .../data/ArchivedSessionsSettingsPage.tsx} | 116 +- .../pages/data/DiagnosticsSettingsPage.tsx | 287 +++ ...UsageStatisticsSettingsPage.appearance.ts} | 0 .../data/UsageStatisticsSettingsPage.scss} | 0 .../UsageStatisticsSettingsPage.test.tsx} | 10 +- .../data/UsageStatisticsSettingsPage.tsx} | 28 +- .../EditorSettingsPage.appearance.ts} | 0 .../development/EditorSettingsPage.scss} | 0 .../development/EditorSettingsPage.test.tsx} | 8 +- .../pages/development/EditorSettingsPage.tsx} | 84 +- .../GitCommitSettingsSection.test.tsx | 60 + .../development/GitCommitSettingsSection.tsx | 81 + .../development/TerminalSettingsPage.tsx | 164 ++ .../development/WorkspaceGitSettingsPage.tsx | 14 + .../development/WorkspaceSearchSection.tsx | 55 + .../WorktreeSettingsSection.appearance.ts} | 0 .../development/WorktreeSettingsSection.scss} | 0 .../WorktreeSettingsSection.test.tsx} | 31 +- .../development/WorktreeSettingsSection.tsx} | 76 +- .../shared/ApplicationSettings.appearance.ts} | 2 +- .../ApplicationSettings.presentation.test.ts} | 28 +- .../pages/shared/ApplicationSettings.scss} | 0 .../shared/RuntimeSettings.appearance.ts} | 6 +- .../RuntimeSettings.presentation.test.ts | 90 + .../pages/shared/RuntimeSettings.scss} | 158 +- .../pages/shared/RuntimeSettings.test.tsx} | 32 +- .../settings/pages/shared/SettingsPage.tsx | 26 + .../shared/SettingsPageComposition.test.ts | 23 + .../shared/useSettingsSectionAnchor.test.tsx | 46 + .../pages/shared/useSettingsSectionAnchor.ts | 36 + .../AutomationSettingsPage.appearance.ts} | 0 .../pages/tools/AutomationSettingsPage.tsx | 16 + .../pages/tools/DeviceControlSettingsPage.tsx | 825 ++++++++ .../ExternalAgentsSettingsPage.tsx} | 10 +- .../tools/HooksSettingsSection.test.tsx} | 29 +- .../pages/tools/HooksSettingsSection.tsx} | 225 +-- .../settings/pages/tools/McpSettingsPage.tsx | 1 + ...QuickActionsSettingsSection.appearance.ts} | 0 ...tionsSettingsSection.presentation.test.ts} | 4 +- .../tools/QuickActionsSettingsSection.scss} | 0 .../tools/QuickActionsSettingsSection.tsx} | 231 +-- ...WebSearchSettingsPage.presentation.test.ts | 0 .../pages/tools}/WebSearchSettingsPage.scss | 0 .../pages/tools}/WebSearchSettingsPage.tsx | 23 +- .../scenes/settings/settingsDestination.ts | 71 +- .../settingsDestinationMigrations.json | 211 ++ .../scenes/settings/settingsRegistry.test.ts | 248 +-- .../app/scenes/settings/settingsRegistry.ts | 367 ++-- .../src/app/scenes/settings/settingsStore.ts | 37 +- .../src/app/scenes/settings/settingsTypes.ts | 39 +- .../startupPerformanceContract.test.ts | 50 +- .../src/flow_chat/components/ChatInput.tsx | 2 +- .../components/voice/useComposerVoiceInput.ts | 2 +- .../components/voice/useRealtimeVoiceCall.ts | 2 +- .../action-bar/DeepReviewActionBar.tsx | 2 +- .../tool-cards/BuiltinMarketDialog.tsx | 2 +- .../registry/defaultAppearanceRegistry.ts | 28 +- .../config/components/AcpAgentsConfig.tsx | 2 +- .../components/AppearanceSettingsPage.tsx | 98 - .../components/ApplicationSettingsPages.tsx | 1029 ---------- .../GlobalPermissionRulesDialog.tsx | 2 +- .../ModelServiceCollapse.presentation.test.ts | 4 +- .../ModelSettingsDialog.presentation.test.ts | 4 +- .../RuntimeSettingsPages.presentation.test.ts | 115 -- .../components/RuntimeSettingsPages.tsx | 1789 ----------------- .../components/common/ConfigPageLayout.tsx | 4 +- .../infrastructure/config/components/index.ts | 16 - src/web-ui/src/infrastructure/config/index.ts | 2 - .../services/AgentCompanionPetService.ts | 12 + src/web-ui/src/locales/en-US/settings.json | 75 +- .../locales/en-US/settings/application.json | 4 +- .../src/locales/en-US/settings/hooks.json | 2 +- .../src/locales/en-US/settings/memory.json | 7 +- .../src/locales/en-US/settings/runtime.json | 2 + .../locales/en-US/settings/voice-input.json | 5 +- src/web-ui/src/locales/zh-CN/settings.json | 73 +- .../locales/zh-CN/settings/application.json | 4 +- .../src/locales/zh-CN/settings/hooks.json | 2 +- .../src/locales/zh-CN/settings/memory.json | 7 +- .../src/locales/zh-CN/settings/runtime.json | 2 + .../locales/zh-CN/settings/voice-input.json | 5 +- src/web-ui/src/locales/zh-TW/settings.json | 77 +- .../locales/zh-TW/settings/application.json | 4 +- .../src/locales/zh-TW/settings/hooks.json | 2 +- .../src/locales/zh-TW/settings/memory.json | 7 +- .../src/locales/zh-TW/settings/runtime.json | 2 + .../locales/zh-TW/settings/voice-input.json | 5 +- .../DialogFooterComposition.contract.test.ts | 8 +- 151 files changed, 6663 insertions(+), 6117 deletions(-) create mode 100644 src/web-ui/src/app/global-search/settingsCapabilityDestination.ts create mode 100644 src/web-ui/src/app/scenes/settings/AGENTS.md delete mode 100644 src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss delete mode 100644 src/web-ui/src/app/scenes/settings/components/SettingsViewPage.appearance.ts delete mode 100644 src/web-ui/src/app/scenes/settings/components/SettingsViewPage.scss delete mode 100644 src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx delete mode 100644 src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.presentation.test.ts delete mode 100644 src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.scss delete mode 100644 src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.tsx delete mode 100644 src/web-ui/src/app/scenes/settings/pages/EditorSettingsPage.tsx delete mode 100644 src/web-ui/src/app/scenes/settings/pages/ExecutionSettingsPage.tsx rename src/web-ui/src/{infrastructure/config/components/DefaultHarnessConfig.test.tsx => app/scenes/settings/pages/ai/DefaultHarnessSection.test.tsx} (97%) rename src/web-ui/src/{infrastructure/config/components/DefaultHarnessConfig.tsx => app/scenes/settings/pages/ai/DefaultHarnessSection.tsx} (95%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx rename src/web-ui/src/{infrastructure/config/components/MemorySettingsPage.presentation.test.ts => app/scenes/settings/pages/ai/MemorySettingsSection.presentation.test.ts} (87%) rename src/web-ui/src/{infrastructure/config/components/MemorySettingsPage.tsx => app/scenes/settings/pages/ai/MemorySettingsSection.tsx} (96%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ModelSettingsPage.appearance.ts (100%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ModelSettingsPage.presentation.test.ts (100%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ModelSettingsPage.scss (100%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ModelSettingsPage.tsx (81%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/PermissionsSettingsPage.tsx rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ReviewCapacitySection.test.tsx (98%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ReviewCapacitySection.tsx (98%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/SessionMemorySettingsPage.tsx rename src/web-ui/src/{infrastructure/config/components/SessionTitleConfig.appearance.ts => app/scenes/settings/pages/ai/SessionTitleSection.appearance.ts} (100%) rename src/web-ui/src/{infrastructure/config/components/SessionTitleConfig.presentation.test.ts => app/scenes/settings/pages/ai/SessionTitleSection.presentation.test.ts} (90%) rename src/web-ui/src/{infrastructure/config/components/SessionTitleConfig.tsx => app/scenes/settings/pages/ai/SessionTitleSection.tsx} (92%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/ai}/ToolJsonRepairSection.tsx (92%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/application}/AppearanceSettingsPage.appearance.ts (94%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/application}/AppearanceSettingsPage.scss (99%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/application}/AppearanceSettingsPage.test.tsx (76%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/AppearanceSettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/GeneralSettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/InputSettingsPage.tsx rename src/web-ui/src/app/scenes/settings/{components/KeyboardShortcutsTab.appearance.ts => pages/application/KeyboardShortcutsSection.appearance.ts} (89%) rename src/web-ui/src/app/scenes/settings/{components/KeyboardShortcutsTab.presentation.test.ts => pages/application/KeyboardShortcutsSection.presentation.test.ts} (56%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.scss rename src/web-ui/src/app/scenes/settings/{components/KeyboardShortcutsTab.tsx => pages/application/KeyboardShortcutsSection.tsx} (59%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/LanguageSettingsSection.tsx rename src/web-ui/src/app/scenes/settings/pages/{VoiceSettingsPage.presentation.test.ts => application/LocalVoiceModels.presentation.test.ts} (86%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/PetAssistantSettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx rename src/web-ui/src/{infrastructure/config/components/VoiceInputConfig.test.tsx => app/scenes/settings/pages/application/RealtimeVoiceSettingsSection.test.tsx} (75%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/RealtimeVoiceSettingsSection.tsx rename src/web-ui/src/{infrastructure/config/components/VoiceInputConfig.appearance.ts => app/scenes/settings/pages/application/VoiceSettingsSection.appearance.ts} (100%) rename src/web-ui/src/{infrastructure/config/components/VoiceInputConfig.presentation.test.ts => app/scenes/settings/pages/application/VoiceSettingsSection.presentation.test.ts} (87%) rename src/web-ui/src/{infrastructure/config/components/VoiceInputConfig.tsx => app/scenes/settings/pages/application/VoiceSettingsSection.tsx} (66%) rename src/web-ui/src/app/scenes/settings/{components/ArchivedSessionsConfig.appearance.ts => pages/data/ArchivedSessionsSettingsPage.appearance.ts} (100%) rename src/web-ui/src/app/scenes/settings/{components/ArchivedSessionsConfig.scss => pages/data/ArchivedSessionsSettingsPage.scss} (100%) rename src/web-ui/src/app/scenes/settings/{components/ArchivedSessionsConfig.tsx => pages/data/ArchivedSessionsSettingsPage.tsx} (87%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/data/DiagnosticsSettingsPage.tsx rename src/web-ui/src/{infrastructure/config/components/UsageStatisticsConfig.appearance.ts => app/scenes/settings/pages/data/UsageStatisticsSettingsPage.appearance.ts} (100%) rename src/web-ui/src/{infrastructure/config/components/UsageStatisticsConfig.scss => app/scenes/settings/pages/data/UsageStatisticsSettingsPage.scss} (100%) rename src/web-ui/src/{infrastructure/config/components/UsageStatisticsConfig.test.tsx => app/scenes/settings/pages/data/UsageStatisticsSettingsPage.test.tsx} (96%) rename src/web-ui/src/{infrastructure/config/components/UsageStatisticsConfig.tsx => app/scenes/settings/pages/data/UsageStatisticsSettingsPage.tsx} (98%) rename src/web-ui/src/{infrastructure/config/components/EditorConfig.appearance.ts => app/scenes/settings/pages/development/EditorSettingsPage.appearance.ts} (100%) rename src/web-ui/src/{infrastructure/config/components/EditorConfig.scss => app/scenes/settings/pages/development/EditorSettingsPage.scss} (100%) rename src/web-ui/src/{infrastructure/config/components/EditorConfig.test.tsx => app/scenes/settings/pages/development/EditorSettingsPage.test.tsx} (91%) rename src/web-ui/src/{infrastructure/config/components/EditorConfig.tsx => app/scenes/settings/pages/development/EditorSettingsPage.tsx} (98%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/development/GitCommitSettingsSection.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/development/GitCommitSettingsSection.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/development/TerminalSettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/development/WorkspaceGitSettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/development/WorkspaceSearchSection.tsx rename src/web-ui/src/{infrastructure/config/components/WorktreeSettingsPage.appearance.ts => app/scenes/settings/pages/development/WorktreeSettingsSection.appearance.ts} (100%) rename src/web-ui/src/{infrastructure/config/components/WorktreeSettingsPage.scss => app/scenes/settings/pages/development/WorktreeSettingsSection.scss} (100%) rename src/web-ui/src/{infrastructure/config/components/WorktreeSettingsPage.test.tsx => app/scenes/settings/pages/development/WorktreeSettingsSection.test.tsx} (93%) rename src/web-ui/src/{infrastructure/config/components/WorktreeSettingsPage.tsx => app/scenes/settings/pages/development/WorktreeSettingsSection.tsx} (96%) rename src/web-ui/src/{infrastructure/config/components/ApplicationSettingsPages.appearance.ts => app/scenes/settings/pages/shared/ApplicationSettings.appearance.ts} (75%) rename src/web-ui/src/{infrastructure/config/components/ApplicationSettingsPages.presentation.test.ts => app/scenes/settings/pages/shared/ApplicationSettings.presentation.test.ts} (66%) rename src/web-ui/src/{infrastructure/config/components/ApplicationSettingsPages.scss => app/scenes/settings/pages/shared/ApplicationSettings.scss} (100%) rename src/web-ui/src/{infrastructure/config/components/RuntimeSettingsPages.appearance.ts => app/scenes/settings/pages/shared/RuntimeSettings.appearance.ts} (92%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.presentation.test.ts rename src/web-ui/src/{infrastructure/config/components/RuntimeSettingsPages.scss => app/scenes/settings/pages/shared/RuntimeSettings.scss} (63%) rename src/web-ui/src/{infrastructure/config/components/RuntimeSettingsPages.test.tsx => app/scenes/settings/pages/shared/RuntimeSettings.test.tsx} (83%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/shared/SettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/shared/SettingsPageComposition.test.ts create mode 100644 src/web-ui/src/app/scenes/settings/pages/shared/useSettingsSectionAnchor.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/shared/useSettingsSectionAnchor.ts rename src/web-ui/src/app/scenes/settings/pages/{appearance.ts => tools/AutomationSettingsPage.appearance.ts} (100%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/tools/AutomationSettingsPage.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/tools/DeviceControlSettingsPage.tsx rename src/web-ui/src/app/scenes/settings/pages/{AcpSettingsPage.tsx => tools/ExternalAgentsSettingsPage.tsx} (63%) rename src/web-ui/src/{infrastructure/config/components/HooksConfig.test.tsx => app/scenes/settings/pages/tools/HooksSettingsSection.test.tsx} (93%) rename src/web-ui/src/{infrastructure/config/components/HooksConfig.tsx => app/scenes/settings/pages/tools/HooksSettingsSection.tsx} (81%) create mode 100644 src/web-ui/src/app/scenes/settings/pages/tools/McpSettingsPage.tsx rename src/web-ui/src/{infrastructure/config/components/QuickActionsConfig.appearance.ts => app/scenes/settings/pages/tools/QuickActionsSettingsSection.appearance.ts} (100%) rename src/web-ui/src/{infrastructure/config/components/QuickActionsConfig.presentation.test.ts => app/scenes/settings/pages/tools/QuickActionsSettingsSection.presentation.test.ts} (90%) rename src/web-ui/src/{infrastructure/config/components/QuickActionsConfig.scss => app/scenes/settings/pages/tools/QuickActionsSettingsSection.scss} (100%) rename src/web-ui/src/{infrastructure/config/components/QuickActionsConfig.tsx => app/scenes/settings/pages/tools/QuickActionsSettingsSection.tsx} (74%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/tools}/WebSearchSettingsPage.presentation.test.ts (100%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/tools}/WebSearchSettingsPage.scss (100%) rename src/web-ui/src/{infrastructure/config/components => app/scenes/settings/pages/tools}/WebSearchSettingsPage.tsx (98%) create mode 100644 src/web-ui/src/app/scenes/settings/settingsDestinationMigrations.json delete mode 100644 src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.tsx delete mode 100644 src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx delete mode 100644 src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.presentation.test.ts delete mode 100644 src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 9dbb4c4993..535df60892 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -2639,7 +2639,7 @@ "pageId": "application.shortcuts" }, "evidence": [ - "source:src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx#lists every known shortcut" + "source:src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.tsx#lists every known shortcut" ] }, { @@ -2655,8 +2655,8 @@ "pageId": "application.shortcuts" }, "evidence": [ - "source:src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx#buildFinalConflictMap", - "source:src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx#buildStoredKeybindings({})" + "source:src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.tsx#buildFinalConflictMap", + "source:src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.tsx#buildStoredKeybindings({})" ] }, { @@ -2795,9 +2795,9 @@ "pageId": "ai.models" }, "evidence": [ - "source:src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx#custom_headers", - "source:src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx#custom_request_body", - "source:src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx#skip_ssl_verify" + "source:src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx#custom_headers", + "source:src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx#custom_request_body", + "source:src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx#skip_ssl_verify" ] }, { @@ -2882,7 +2882,7 @@ "pageId": "workspace.session" }, "evidence": [ - "source:src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.tsx#DefaultHarnessConfig", + "source:src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx#DefaultHarnessSection", "source:src/web-ui/src/locales/zh-CN/settings/runtime.json#defaultHarness.title" ] }, diff --git a/scripts/generate-interactive-capabilities.mjs b/scripts/generate-interactive-capabilities.mjs index 9b50a9b31a..f6967839bd 100644 --- a/scripts/generate-interactive-capabilities.mjs +++ b/scripts/generate-interactive-capabilities.mjs @@ -18,6 +18,10 @@ const APPEARANCE_BOOTSTRAP_PATH = path.join( ); const SETTINGS_REGISTRY = path.join(REPO_ROOT, 'src/web-ui/src/app/scenes/settings/settingsRegistry.ts'); const SETTINGS_TYPES = path.join(REPO_ROOT, 'src/web-ui/src/app/scenes/settings/settingsTypes.ts'); +const SETTINGS_MIGRATIONS = JSON.parse(readFileSync( + path.join(REPO_ROOT, 'src/web-ui/src/app/scenes/settings/settingsDestinationMigrations.json'), + 'utf8', +)); const PRODUCT_ACTION_CATALOG = path.join(REPO_ROOT, 'src/web-ui/src/app/global-search/productActionCatalog.ts'); const SCENE_TYPES = path.join(REPO_ROOT, 'src/web-ui/src/app/components/SceneBar/types.ts'); const TOOL_PROVIDER_GROUPS = path.join( @@ -381,6 +385,23 @@ function controlCoverage(source) { return coverage; } +// Keep historical destinations on the wire for older presentation surfaces. +// Validate coverage against the same migration data used by the current Web UI. +export function projectSettingsCatalogDestination(capabilityId, destination, itemId) { + if (destination.kind !== 'settings') return destination; + const capability = SETTINGS_MIGRATIONS.capabilities[capabilityId]; + const input = capability?.items?.[itemId] ?? capability?.destination ?? destination; + const migratedView = SETTINGS_MIGRATIONS.views[input.pageId]?.[input.viewId]; + const migrated = migratedView ?? SETTINGS_MIGRATIONS.pages[input.pageId]; + const sectionId = input.sectionId ?? migrated?.sectionId; + return { + kind: 'settings', + pageId: migrated?.pageId ?? input.pageId, + ...(sectionId ? { sectionId } : {}), + ...(!migratedView && input.viewId ? { viewId: input.viewId } : {}), + }; +} + function validateSource(source) { if (source.schemaVersion !== 5) throw new Error('Interactive capability source must use schemaVersion 5'); const capabilityIds = new Set(); @@ -389,6 +410,7 @@ function validateSource(source) { [...settingsSource.matchAll(/definePage\(\{\s*id:\s*'([^']+)'/gu)].map((match) => match[1]), ); const settingsViewIds = extractTypeUnionIds(readFileSync(SETTINGS_TYPES, 'utf8'), 'SettingsViewId'); + const settingsSectionIds = extractTypeUnionIds(readFileSync(SETTINGS_TYPES, 'utf8'), 'SettingsSectionId'); const actionIds = extractSingleQuotedIds(readFileSync(PRODUCT_ACTION_CATALOG, 'utf8')); const sceneIds = new Set([ ...readFileSync(SCENE_TYPES, 'utf8').matchAll(/^\s*\|\s*'([^']+)'/gmu), @@ -396,7 +418,9 @@ function validateSource(source) { const productToolNames = extractProductToolNames(readFileSync(TOOL_PROVIDER_GROUPS, 'utf8')); const delegatedToolNames = new Set(); - const validateDestination = (destination, owner) => { + const validateDestination = (original, capabilityId, itemId) => { + const owner = itemId ? `${capabilityId}.${itemId}` : capabilityId; + const destination = projectSettingsCatalogDestination(capabilityId, original, itemId); if (destination.kind === 'settings') { if (!settingsIds.has(destination.pageId)) { throw new Error(`${owner} targets unknown settings page ${destination.pageId}`); @@ -404,6 +428,9 @@ function validateSource(source) { if (destination.viewId && !settingsViewIds.has(destination.viewId)) { throw new Error(`${owner} targets unknown settings view ${destination.viewId}`); } + if (destination.sectionId && !settingsSectionIds.has(destination.sectionId)) { + throw new Error(`${owner} targets unknown settings section ${destination.sectionId}`); + } return; } if (destination.kind === 'action' && !actionIds.has(destination.actionId)) { @@ -470,7 +497,7 @@ function validateSource(source) { itemIds.add(item.id); assertBilingual({ ...item, id: `${capability.id}.${item.id}` }, 'title'); if (item.destination) { - validateDestination(item.destination, `${capability.id}.${item.id}`); + validateDestination(item.destination, capability.id, item.id); if ( item.destination.kind === 'settings' && capability.destination.kind === 'settings' @@ -691,9 +718,16 @@ function validateSource(source) { throw new Error('User-facing capabilities may not silently ship as unsupported Agent controls'); } - const semanticSettings = new Set(source.capabilities + const projectedSettingsDestinations = source.capabilities .filter(({ kind }) => kind === 'setting') - .map(({ destination }) => destination.kind === 'settings' ? destination.pageId : null) + .flatMap((capability) => [ + projectSettingsCatalogDestination(capability.id, capability.destination), + ...capability.items.map((item) => projectSettingsCatalogDestination( + capability.id, item.destination ?? capability.destination, item.id, + )), + ]); + const semanticSettings = new Set(projectedSettingsDestinations + .map((destination) => destination.kind === 'settings' ? destination.pageId : null) .filter(Boolean)); const missingSettings = [...settingsIds].filter((id) => !semanticSettings.has(id)); const staleSettings = [...semanticSettings].filter((id) => !settingsIds.has(id)); @@ -704,10 +738,9 @@ function validateSource(source) { ].filter(Boolean).join('\n')); } - const semanticSettingsViews = new Set(source.capabilities.flatMap((capability) => - capability.items - .map(({ destination }) => destination?.kind === 'settings' ? destination.viewId : null) - .filter(Boolean))); + const semanticSettingsViews = new Set(projectedSettingsDestinations + .map((destination) => destination.kind === 'settings' ? destination.viewId : null) + .filter(Boolean)); const missingSettingsViews = [...settingsViewIds].filter((id) => !semanticSettingsViews.has(id)); const staleSettingsViews = [...semanticSettingsViews].filter((id) => !settingsViewIds.has(id)); if (missingSettingsViews.length || staleSettingsViews.length) { @@ -1034,7 +1067,7 @@ function loadOwnerDefinitions() { const result = spawnSync( 'cargo', ['run', '--quiet', '-p', 'openbitfun-product-domains', '--bin', PRODUCT_CONTROL_OWNER_EXPORT], - { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }, ); if (result.status !== 0) { throw new Error([ @@ -1067,7 +1100,7 @@ export function loadRemoteSurfaceRegistry() { const result = spawnSync( 'cargo', ['run', '--quiet', '-p', 'openbitfun-product-domains', '--bin', REMOTE_SURFACE_EXPORT], - { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }, ); if (result.status !== 0) { throw new Error([ diff --git a/scripts/i18n-dynamic-key-allowlist.json b/scripts/i18n-dynamic-key-allowlist.json index e02921e554..5702f0c196 100644 --- a/scripts/i18n-dynamic-key-allowlist.json +++ b/scripts/i18n-dynamic-key-allowlist.json @@ -83,7 +83,7 @@ "id": "web-editor-option-metadata", "surface": "web-ui", "namespace": "settings/editor", - "owner": "src/web-ui/src/infrastructure/config/components/EditorConfig.tsx", + "owner": "src/web-ui/src/app/scenes/settings/pages/development/EditorSettingsPage.tsx", "description": "Editor option descriptors store labelKey metadata resolved with the settings/editor namespace.", "sourceReferences": [ "labelKey: 'appearance.cursorStyles.line'", diff --git a/scripts/interactive-capabilities.test.mjs b/scripts/interactive-capabilities.test.mjs index 8b16622bc4..e9275d4237 100644 --- a/scripts/interactive-capabilities.test.mjs +++ b/scripts/interactive-capabilities.test.mjs @@ -8,12 +8,33 @@ import { buildCapabilityCatalog, loadRemoteSurfaceRegistry, parseRegisteredCommands, + projectSettingsCatalogDestination, renderRemoteSurfaceTsBindings, } from './generate-interactive-capabilities.mjs'; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = (relativePath) => readFile(path.join(repositoryRoot, relativePath), 'utf8'); +test('historical settings wire targets resolve through the current presentation migration', () => { + const { runtimeCatalog } = buildCapabilityCatalog(); + const execution = runtimeCatalog.capabilities.find(({ id }) => id === 'setting.tools.execution'); + const permissions = execution.items.find(({ id }) => id === 'permission-mode'); + assert.deepEqual(permissions.destination, { kind: 'settings', pageId: 'tools.execution', viewId: 'common' }); + assert.deepEqual(projectSettingsCatalogDestination(execution.id, permissions.destination, permissions.id), { + kind: 'settings', pageId: 'ai.permissions', + }); + const automation = runtimeCatalog.capabilities.find(({ id }) => id === 'setting.tools.automation'); + const hooks = automation.items.find(({ id }) => id === 'hooks-enabled'); + assert.deepEqual(hooks.destination, { kind: 'settings', pageId: 'tools.automation', viewId: 'hooks' }); + assert.deepEqual(projectSettingsCatalogDestination(automation.id, hooks.destination, hooks.id), { + kind: 'settings', pageId: 'tools.automation', sectionId: 'hooks', + }); + const computerUse = runtimeCatalog.capabilities.find(({ id }) => id === 'feature.computer-use'); + assert.deepEqual(projectSettingsCatalogDestination(computerUse.id, computerUse.destination), { + kind: 'settings', pageId: 'tools.desktop-control', + }); +}); + test('the public contract is a compact feature-and-settings manual', () => { const { publicCatalog, runtimeCatalog } = buildCapabilityCatalog(); const featureCount = publicCatalog.capabilities.filter(({ kind }) => kind === 'feature').length; @@ -352,7 +373,9 @@ test('docs, runtime, and technical views are generated projections of one semant assert.ok(interactionAudit.files.some(({ sourceFile }) => sourceFile.endsWith('/AssistantDefaultsPage.tsx'))); assert.ok(interactionAudit.files.some(({ sourceFile }) => - sourceFile.endsWith('/AppearanceSettingsPage.tsx'))); + sourceFile.endsWith('/LanguageSettingsSection.tsx'))); + assert.ok(interactionAudit.files.some(({ sourceFile }) => + sourceFile.endsWith('/AppearancePackageConfigSection.tsx'))); assert.equal(publicCatalog.source, 'src/shared/interactive-capabilities/catalog.json'); const appearance = runtimeCatalog.capabilities diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 3c589192db..a8715ee327 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -6037,7 +6037,7 @@ "reasonEn": "“Search and browse all app, scene, editor, and terminal shortcuts” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." }, "evidence": [ - "source:src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx#lists every known shortcut" + "source:src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.tsx#lists every known shortcut" ] }, { @@ -6055,8 +6055,8 @@ "reasonEn": "“Record new shortcuts, detect conflicts, revert one binding, or reset all custom bindings” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." }, "evidence": [ - "source:src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx#buildFinalConflictMap", - "source:src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx#buildStoredKeybindings({})" + "source:src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.tsx#buildFinalConflictMap", + "source:src/web-ui/src/app/scenes/settings/pages/application/KeyboardShortcutsSection.tsx#buildStoredKeybindings({})" ] } ], @@ -6666,9 +6666,9 @@ "reasonEn": "Custom headers and request bodies may contain tokens or other secrets, so they must be edited in the protected model configuration flow and cannot enter Agent-readable discovery data." }, "evidence": [ - "source:src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx#custom_headers", - "source:src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx#custom_request_body", - "source:src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx#skip_ssl_verify" + "source:src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx#custom_headers", + "source:src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx#custom_request_body", + "source:src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx#skip_ssl_verify" ] }, { @@ -7076,7 +7076,7 @@ "reasonEn": "“Make regular new sessions follow the last ChatInput selection or use a fixed Harness” is managed by related controls on the session settings page and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the selection visible to the user." }, "evidence": [ - "source:src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.tsx#DefaultHarnessConfig", + "source:src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx#DefaultHarnessSection", "source:src/web-ui/src/locales/zh-CN/settings/runtime.json#defaultHarness.title" ] }, diff --git a/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts b/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts index 582d6af2b9..fc062ba671 100644 --- a/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts +++ b/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts @@ -34,7 +34,7 @@ describe('activateInteractiveCapability', () => { expect(mocks.openDestination).toHaveBeenCalledWith({ kind: 'settings', - pageId: 'application.shortcuts', + pageId: 'application.input', sectionId: 'shortcuts', }); expect(mocks.openScene).toHaveBeenCalledWith('settings'); }); @@ -45,7 +45,7 @@ describe('activateInteractiveCapability', () => { }); expect(mocks.openDestination).toHaveBeenLastCalledWith({ kind: 'settings', - pageId: 'application.terminal', + pageId: 'development.terminal', }); await activateInteractiveCapability('setting.application.development', { @@ -53,10 +53,26 @@ describe('activateInteractiveCapability', () => { }); expect(mocks.openDestination).toHaveBeenLastCalledWith({ kind: 'settings', - pageId: 'application.editor', + pageId: 'development.editor', }); }); + + it.each([ + ['feature.computer-use', undefined, { pageId: 'tools.desktop-control' }], + ['setting.application.input', 'voice-enabled', { pageId: 'application.input', sectionId: 'voice' }], + ['setting.application.appearance', 'language', { pageId: 'application.general' }], + ['setting.workspace.session', 'default-agent-harness', { pageId: 'ai.session-memory', sectionId: 'session' }], + ['setting.workspace.session', 'accelerated-search', { pageId: 'development.workspace', sectionId: 'workspace-search' }], + ['setting.tools.execution', 'permission-mode', { pageId: 'ai.permissions' }], + ['setting.tools.execution', 'timeouts', { pageId: 'ai.execution' }], + ['setting.tools.execution', 'computer-use', { pageId: 'tools.desktop-control' }], + ['setting.tools.automation', 'hooks-enabled', { pageId: 'tools.automation', sectionId: 'hooks' }], + ])('opens the reorganized destination for %s / %s using the stable host ID', async (capabilityId, itemId, destination) => { + await activateInteractiveCapability(capabilityId, { itemId }); + expect(mocks.openDestination).toHaveBeenLastCalledWith({ kind: 'settings', ...destination }); + }); + it('rejects stale item IDs instead of silently opening the wrong place', async () => { await expect(activateInteractiveCapability('setting.application.shortcuts', { itemId: 'missing-item', diff --git a/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts b/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts index 0b6058303c..6d4633c377 100644 --- a/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts +++ b/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts @@ -3,6 +3,7 @@ import type { SettingsDestination } from '@/app/scenes/settings/settingsTypes'; import type { ProductActionId } from './productActionCatalog'; import generatedCatalog from './generated/interactive-capabilities.json'; import { WORKSPACE_SEARCH_AVAILABLE } from '@/infrastructure/config/workspaceSearchAvailability'; +import { projectSettingsCapabilityDestination } from './settingsCapabilityDestination'; export type InteractiveCapabilityKind = 'feature' | 'setting'; export type InteractiveCapabilityRisk = 'read' | 'write' | 'ui' | 'execute' | 'destructive'; @@ -142,7 +143,22 @@ export interface InteractiveCapabilityCatalog { } // Keep the host contract intact while filtering controls unavailable for the workspace. -const catalog = generatedCatalog as InteractiveCapabilityCatalog; +const hostCatalog = generatedCatalog as InteractiveCapabilityCatalog; +const catalog: InteractiveCapabilityCatalog = { + ...hostCatalog, + capabilities: hostCatalog.capabilities.map(capability => ({ + ...capability, + destination: projectSettingsCapabilityDestination(capability.id, capability.destination), + items: capability.items.map(item => ({ + ...item, + destination: projectSettingsCapabilityDestination(capability.id, item.destination ?? capability.destination, item.id), + })), + })), + definitions: hostCatalog.definitions.map(definition => ({ + ...definition, + presentationTarget: projectSettingsCapabilityDestination(definition.capabilityId, definition.presentationTarget, definition.itemIds[0]), + })), +}; const suspendedSearchItems = new Set(['accelerated-search', 'search-index']); export function getInteractiveCapabilityCatalog( workspaceSearchAvailable = WORKSPACE_SEARCH_AVAILABLE, diff --git a/src/web-ui/src/app/global-search/productActionActivator.ts b/src/web-ui/src/app/global-search/productActionActivator.ts index 7362465ff3..8d63c0adc3 100644 --- a/src/web-ui/src/app/global-search/productActionActivator.ts +++ b/src/web-ui/src/app/global-search/productActionActivator.ts @@ -78,7 +78,7 @@ export async function activateProductAction( return; case 'settings.shortcuts.open': useSettingsStore.getState().openDestination({ - pageId: 'application.shortcuts', + pageId: 'application.input', sectionId: 'shortcuts', }); sceneStore.openScene('settings'); return; diff --git a/src/web-ui/src/app/global-search/settingsCapabilityDestination.ts b/src/web-ui/src/app/global-search/settingsCapabilityDestination.ts new file mode 100644 index 0000000000..09e7666afa --- /dev/null +++ b/src/web-ui/src/app/global-search/settingsCapabilityDestination.ts @@ -0,0 +1,21 @@ +import { resolveSettingsDestination } from '@/app/scenes/settings/settingsDestination'; +import type { SettingsDestinationInput } from '@/app/scenes/settings/settingsDestination'; +import migrations from '@/app/scenes/settings/settingsDestinationMigrations.json'; +import type { InteractiveCapabilityDestination } from './interactiveCapabilityCatalog'; + +// Capability IDs are stable host contracts. Only their frontend destinations move. +const CAPABILITY_DESTINATIONS: Readonly>; +}>> = migrations.capabilities; + +export function projectSettingsCapabilityDestination( + capabilityId: string, + destination: InteractiveCapabilityDestination, + itemId?: string, +): InteractiveCapabilityDestination { + if (destination.kind !== 'settings') return destination; + const migration = CAPABILITY_DESTINATIONS[capabilityId]; + const override = (itemId ? migration?.items?.[itemId] : undefined) ?? migration?.destination; + return { kind: 'settings', ...resolveSettingsDestination(override ?? destination) }; +} diff --git a/src/web-ui/src/app/scenes/settings/AGENTS.md b/src/web-ui/src/app/scenes/settings/AGENTS.md new file mode 100644 index 0000000000..7c3ccb6533 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/AGENTS.md @@ -0,0 +1,41 @@ +# Settings scene + +Follow `src/web-ui/AGENTS.md` and the settings control sizing rules in +`src/web-ui/src/infrastructure/config/AGENTS.md`. + +## Ownership + +- `settingsRegistry.ts` owns category order, page destinations, lazy loading, + namespace preloading, and searchable inline sections. +- `pages/{application,ai,development,tools,data}` owns settings presentation. + Configuration services, persisted shapes, host adapters, and reusable + configuration editors remain in infrastructure or their feature owners. +- Name destination components `*SettingsPage` and inline content `*Section`. + Composed pages use `pages/shared/SettingsPage.tsx` for one title and one scroll + owner. Sections must not add page shells or secondary navigation. Existing + ACP connection modes are owned by the external-agent editor. +- Keep Pet & assistant as a separate page containing pet preferences and real-time + voice calls. Voice dictation stays in Input & interaction. Keyboard shortcuts use one searchable list; + shortcut scopes still determine runtime dispatch and conflict semantics. +- Navigation aliases belong in `settingsDestinationMigrations.json`, shared by + `settingsDestination.ts` and the capability catalog validator. Preserve old page and + view payloads; new callers use canonical destinations. `sectionId` scrolls + inside the current page and must not discard drafts or trigger a leave prompt. +- Stable product-control capability IDs are host contracts. Project their UI + destinations in `app/global-search/settingsCapabilityDestination.ts`; do not + rename generated protocol IDs to match sidebar labels. +- Moving a setting must preserve its execution host, remote capability gates, + unsupported states, persistence keys, and draft registration. + +## Focused verification + +```bash +pnpm --dir src/web-ui run test:run src/app/scenes/settings src/app/global-search/interactiveCapabilityActivator.test.ts src/infrastructure/config/settingsDraftRegistry.test.ts +pnpm --dir src/web-ui run test:run src/infrastructure/config/components/common/SettingsControlSizing.test.ts +``` + +Follow the parent guide for `check:web`, i18n, and Appearance registration checks. +When changing destination migrations or catalog source evidence, also run +`pnpm run capabilities:generate`, `pnpm run capabilities:check`, and +`pnpm run capabilities:test`; retain historical wire destinations for older clients. +These source and DOM tests do not establish visual or remote runtime behavior. diff --git a/src/web-ui/src/app/scenes/settings/SettingsContentTypography.test.ts b/src/web-ui/src/app/scenes/settings/SettingsContentTypography.test.ts index 7fd79a089d..b868fe226f 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsContentTypography.test.ts +++ b/src/web-ui/src/app/scenes/settings/SettingsContentTypography.test.ts @@ -11,8 +11,8 @@ const headerStyles = readSource('../../../infrastructure/config/components/commo const pageHeaderStyles = readSource('../../../../../../design-system/packages/ui/src/components/PageHeader/PageHeader.module.css'); const layoutStyles = readSource('../../../infrastructure/config/components/common/ConfigPageLayout.scss'); const formStyles = readSource('../../../infrastructure/config/components/ConfigForm.scss'); -const appearanceStyles = readSource('../../../infrastructure/config/components/AppearanceSettingsPage.scss'); -const shortcutStyles = readSource('./components/KeyboardShortcutsTab.scss'); +const appearanceStyles = readSource('./pages/application/AppearanceSettingsPage.scss'); +const shortcutStyles = readSource('./pages/application/KeyboardShortcutsSection.scss'); describe('Settings content typography', () => { it('maps the shared settings hierarchy to canonical semantic type roles', () => { diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx index 11235ccbef..a5434e6484 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx @@ -10,15 +10,24 @@ vi.mock('./settingsRegistry', () => { const pages = [ { id: 'application.general', categoryId: 'application', labelKey: 'General' }, { id: 'application.appearance', categoryId: 'application', labelKey: 'Appearance' }, + { + id: 'application.pet', categoryId: 'application', labelKey: 'Pet & assistant', + descriptionKey: 'Desktop pet and realtime voice calls', + sections: [ + { id: 'pet', labelKey: 'Pet', keywords: ['companion'], searchPhrases: [] }, + { id: 'voice-call', labelKey: 'Realtime voice calls', keywords: ['volcengine'], searchPhrases: [] }, + ], + }, { id: 'ai.models', categoryId: 'ai', labelKey: 'Models' }, - ].map((page) => ({ ...page, descriptionKey: 'Description', keywords: ['settings'], searchPhrases: [] })); + ].map((page) => ({ descriptionKey: 'Description', keywords: ['settings'], searchPhrases: [], ...page })); return { DEFAULT_SETTINGS_PAGE_ID: 'application.general', SETTINGS_PAGE_MANIFESTS: pages, SETTINGS_CATEGORIES: [ - { id: 'application', labelKey: 'Application', pages: pages.slice(0, 2) }, - { id: 'ai', labelKey: 'AI', pages: pages.slice(2) }, + { id: 'application', labelKey: 'Application', pages: pages.slice(0, 3) }, + { id: 'ai', labelKey: 'AI', pages: pages.slice(3) }, ], + getSettingsPageManifest: (id: string) => pages.find(page => page.id === id), isSettingsPageId: (value: string) => pages.some((page) => page.id === value), preloadSettingsPage: vi.fn(async () => undefined), }; @@ -91,7 +100,7 @@ describe('SettingsNav shared component composition', () => { expect(content.querySelectorAll('[data-openbitfun-part="heading-label"]')).toHaveLength(2); const caption = content.querySelector('.openbitfun-settings-nav__category-label')!; expect(caption.parentElement?.getAttribute('data-openbitfun-part')).toBe('heading-label'); - expect(content.querySelectorAll('[data-testid="settings-nav-page"]')).toHaveLength(3); + expect(content.querySelectorAll('[data-testid="settings-nav-page"]')).toHaveLength(4); }); it('drives the shared selected state from the active destination', async () => { @@ -113,7 +122,7 @@ describe('SettingsNav shared component composition', () => { it('keeps two-line search results and keyboard selection working with real navigation items', async () => { const input = await search('settings'); const results = container.querySelector('[role="listbox"]')!; - expect(results.querySelectorAll('[role="option"]')).toHaveLength(3); + expect(results.querySelectorAll('[role="option"]')).toHaveLength(4); const first = results.querySelector('[role="option"]')!; const label = first.querySelector('[data-openbitfun-part="label"]')!; expect(label.querySelector('.openbitfun-settings-nav__search-result-line')).not.toBeNull(); @@ -137,7 +146,7 @@ describe('SettingsNav shared component composition', () => { expect(container.querySelector('[role="status"]')).not.toBeNull(); act(() => pressKey(input, 'Escape')); expect(input.value).toBe(''); - expect(container.querySelectorAll('[data-testid="settings-nav-page"]')).toHaveLength(3); + expect(container.querySelectorAll('[data-testid="settings-nav-page"]')).toHaveLength(4); expect(container.querySelector('[role="status"]')).toBeNull(); }); @@ -158,4 +167,20 @@ describe('SettingsNav shared component composition', () => { expect(models?.querySelector('[data-openbitfun-part="dirtyMarker"]')).not.toBeNull(); expect(general?.querySelector('[data-openbitfun-part="dirtyMarker"]')).toBeNull(); }); + + it('opens a matching inline section without matching its siblings through the page description', async () => { + const input = await search('realtime'); + expect(container.querySelectorAll('[role="option"]')).toHaveLength(1); + await act(async () => pressKey(input, 'Enter')); + expect(useSettingsStore.getState().activePageId).toBe('application.pet'); + expect(useSettingsStore.getState().activeSectionId).toBe('voice-call'); + }); + + it('offers the page when only its shared name matches', async () => { + const input = await search('assistant'); + expect(container.querySelectorAll('[role="option"]')).toHaveLength(1); + await act(async () => pressKey(input, 'Enter')); + expect(useSettingsStore.getState().activePageId).toBe('application.pet'); + expect(useSettingsStore.getState().activeSectionId).toBeNull(); + }); }); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index 630a784742..8a03a68c85 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -1,14 +1,7 @@ -import React, { - startTransition, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import type { i18n as I18nApi } from 'i18next'; -import { useTranslation } from 'react-i18next'; -import { OverflowText, +import { useSettingsDraftSnapshot } from '@/infrastructure/config/settingsDraftRegistry'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { getInteractionMotion } from '@/shared/utils/motionPreference'; +import { Icon, NavigationPanel, NavigationPanelBody, @@ -16,11 +9,20 @@ import { OverflowText, NavigationPanelHeader, NavigationPanelItem, NavigationPanelSection, + OverflowText, SearchField, } from '@openbitfun/ui'; -import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; -import { useSettingsDraftSnapshot } from '@/infrastructure/config/settingsDraftRegistry'; -import { getInteractionMotion } from '@/shared/utils/motionPreference'; +import type { i18n as I18nApi } from 'i18next'; +import React, { + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import './SettingsNav.scss'; import { SETTINGS_CATEGORIES, SETTINGS_PAGE_MANIFESTS, @@ -29,7 +31,6 @@ import { } from './settingsRegistry'; import { useSettingsStore } from './settingsStore'; import type { SettingsDestination, SettingsPageId } from './settingsTypes'; -import './SettingsNav.scss'; const SEARCH_DEBOUNCE_MS = 150; type SettingsT = (key: string, options?: Record) => unknown; @@ -38,7 +39,7 @@ export interface SettingsSearchRow { destination: SettingsDestination; categoryLabel: string; pageLabel: string; - viewLabel?: string; + targetLabel?: string; description: string; haystack: string; } @@ -70,33 +71,35 @@ function buildSettingsSearchIndex(t: SettingsT, i18n: I18nApi): SettingsSearchRo const pageContent = resolvePhrases(i18n, page.searchPhrases); const base = [categoryLabel, pageLabel, description, page.id, ...page.keywords, pageContent]; - if (!page.views?.length) { - return [{ + const targets = [ + ...(page.sections ?? []).map(section => ({ ...section, destination: { pageId: page.id, sectionId: section.id } })), + ...(page.views ?? []).map(view => ({ ...view, destination: { pageId: page.id, viewId: view.id } })), + ]; + return [ + { destination: { pageId: page.id }, categoryLabel, pageLabel, description, haystack: base.join(' ').toLowerCase(), - }]; - } - - return page.views.map((view) => { - const viewLabel = translateString(t, view.labelKey, view.id); - return { - destination: { pageId: page.id, viewId: view.id }, - categoryLabel, - pageLabel, - viewLabel, - description, - haystack: [ - ...base, - viewLabel, - view.id, - ...view.keywords, - resolvePhrases(i18n, view.searchPhrases), - ].join(' ').toLowerCase(), - }; - }); + }, + ...targets.map((target) => { + const targetLabel = translateString(t, target.labelKey, target.id); + return { + destination: target.destination, + categoryLabel, + pageLabel, + targetLabel, + description, + haystack: [ + targetLabel, + target.id, + ...target.keywords, + resolvePhrases(i18n, target.searchPhrases), + ].join(' ').toLowerCase(), + }; + }), + ]; }); } @@ -125,6 +128,7 @@ const SettingsNav: React.FC = () => { const { t: tComponents } = useI18n('components'); const activePageId = useSettingsStore((state) => state.activePageId); const activeViewId = useSettingsStore((state) => state.activeViewId); + const activeSectionId = useSettingsStore((state) => state.activeSectionId); const { resources: draftResources } = useSettingsDraftSnapshot(); const openDestination = useSettingsStore((state) => state.openDestination); const searchQuery = useSettingsStore((state) => state.searchQuery); @@ -143,7 +147,12 @@ const SettingsNav: React.FC = () => { const searchIndex = useMemo(() => buildSettingsSearchIndex(t, i18n), [i18n, t]); const results = useMemo(() => { const query = searchQuery.trim().toLowerCase(); - return query ? searchIndex.filter((row) => row.haystack.includes(query)) : []; + if (!query) return []; + const matches = searchIndex.filter((row) => row.haystack.includes(query)); + const matchedSections = new Set(matches + .filter((row) => row.targetLabel) + .map((row) => row.destination.pageId)); + return matches.filter((row) => row.targetLabel || !matchedSections.has(row.destination.pageId)); }, [searchIndex, searchQuery]); const isSearchMode = draftQuery.trim().length > 0; const dirtyPageIds = useMemo(() => new Set( @@ -266,102 +275,103 @@ const SettingsNav: React.FC = () => { {isSearchMode ? ( - results.length ? ( -
= 0 - ? `settings-nav-result-${highlightedIndex}` - : undefined} - > - {results.map((row, index) => { - const active = activePageId === row.destination.pageId - && (!row.destination.viewId || row.destination.viewId === activeViewId); - const selected = index === highlightedIndex; - const path = [row.categoryLabel, row.pageLabel, row.viewLabel].filter(Boolean).join(' › '); - return ( - = 0 + ? `settings-nav-result-${highlightedIndex}` + : undefined} + > + {results.map((row, index) => { + const active = activePageId === row.destination.pageId + && (!row.destination.viewId || row.destination.viewId === activeViewId) + && (!row.destination.sectionId || row.destination.sectionId === activeSectionId); + const selected = index === highlightedIndex; + const path = [row.categoryLabel, row.pageLabel, row.targetLabel].filter(Boolean).join(' › '); + return ( + activate(row.destination, true)} + onMouseEnter={() => { + setHighlightedIndex(index); + preload(row.destination.pageId); + }} + onFocus={() => preload(row.destination.pageId)} + > + + + {highlightFirstMatch(path, searchQuery)} + + + {highlightFirstMatch(row.description, searchQuery)} + + + {dirtyMarker(row.destination.pageId)} + + ); + })} +
+ ) : ( +
+ {t('navigation.search.empty')} +
+ ) + ) : SETTINGS_CATEGORIES.map((category) => ( + activate(row.destination, true)} - onMouseEnter={() => { - setHighlightedIndex(index); - preload(row.destination.pageId); - }} - onFocus={() => preload(row.destination.pageId)} + data-openbitfun-part="categoryHeader" > - - - {highlightFirstMatch(path, searchQuery)} - - - {highlightFirstMatch(row.description, searchQuery)} - - - {dirtyMarker(row.destination.pageId)} - - ); - })} - - ) : ( -
- {t('navigation.search.empty')} -
- ) - ) : SETTINGS_CATEGORIES.map((category) => ( - + )} > - {t(category.labelKey)} - - )} - > -
- {category.pages.map((page) => ( - activate({ pageId: page.id })} - onPointerEnter={() => preload(page.id)} - onFocus={() => preload(page.id)} - > - {t(page.labelKey)} - {dirtyMarker(page.id)} - - ))} -
-
+
+ {category.pages.map((page) => ( + activate({ pageId: page.id })} + onPointerEnter={() => preload(page.id)} + onFocus={() => preload(page.id)} + > + {t(page.labelKey)} + {dirtyMarker(page.id)} + + ))} +
+
))}
diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx index f5c36cf583..21cc93460b 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx @@ -25,7 +25,8 @@ vi.mock('./settingsRegistry', () => { 'tools.automation': { id: 'tools.automation', categoryId: 'tools', - component: ({ viewId }: { viewId?: string }) =>
, + sections: [{ id: 'hooks' }], + component: ({ sectionId }: { sectionId?: string }) =>
, }, }; return { @@ -56,6 +57,7 @@ describe('SettingsScene canonical page routing', () => { useSettingsStore.setState({ activePageId: 'application.general', activeViewId: null, + activeSectionId: null, navigationRequestId: 0, pageTransitionTarget: null, pageTransitionMotion: 'instant', @@ -76,13 +78,13 @@ describe('SettingsScene canonical page routing', () => { expect(container.querySelector('[data-settings-page="application.general"]')).not.toBeNull(); }); - it('passes an internal view destination without creating a sidebar page', async () => { + it('migrates an old internal view to an inline section without creating a sidebar page', async () => { useSettingsStore.getState().openDestination({ pageId: 'tools.automation', viewId: 'hooks', }); await act(async () => root.render()); - expect(container.querySelector('[data-testid="automation-page"]')?.getAttribute('data-view')).toBe('hooks'); + expect(container.querySelector('[data-testid="automation-page"]')?.getAttribute('data-section')).toBe('hooks'); }); it('switches pages without retaining the outgoing page for instant navigation', async () => { diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 094fb4473c..b862b13950 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -1,6 +1,3 @@ -import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { ConfirmDialog } from '@openbitfun/ui'; -import { useTranslation } from 'react-i18next'; import { NavigationTransitionBoundary } from '@/app/navigation/NavigationTransitionBoundary'; import { cancelPendingSettingsNavigation, @@ -8,14 +5,17 @@ import { saveAndContinueSettingsNavigation, useSettingsDraftSnapshot, } from '@/infrastructure/config/settingsDraftRegistry'; +import { ConfirmDialog } from '@openbitfun/ui'; +import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { getSettingsPageManifest, isSettingsPageReady, preloadSettingsPage, } from './settingsRegistry'; +import './SettingsScene.scss'; import { useSettingsStore } from './settingsStore'; import type { SettingsPageId } from './settingsTypes'; -import './SettingsScene.scss'; function SettingsSceneLoading() { return ( @@ -42,6 +42,7 @@ const SettingsScene: React.FC = ({ isActive = true }) => { const { t } = useTranslation('settings'); const activePageId = useSettingsStore((state) => state.activePageId); const activeViewId = useSettingsStore((state) => state.activeViewId); + const activeSectionId = useSettingsStore((state) => state.activeSectionId); const navigationRequestId = useSettingsStore((state) => state.navigationRequestId); const pageTransitionTarget = useSettingsStore((state) => state.pageTransitionTarget); const pageTransitionMotion = useSettingsStore((state) => state.pageTransitionMotion); @@ -106,6 +107,7 @@ const SettingsScene: React.FC = ({ isActive = true }) => { @@ -119,8 +121,8 @@ const SettingsScene: React.FC = ({ isActive = true }) => { message={pendingNavigation?.failed ? t('changeGuard.saveFailed') : t('changeGuard.message', { - count: pendingNavigation?.resourceLabels.length ?? 0, - })} + count: pendingNavigation?.resourceLabels.length ?? 0, + })} preview={pendingNavigation?.resourceLabels.length ? (
    {pendingNavigation.resourceLabels.map((label, index) => ( diff --git a/src/web-ui/src/app/scenes/settings/appearance.ts b/src/web-ui/src/app/scenes/settings/appearance.ts index 659ceeb9f3..bdfd23d3d2 100644 --- a/src/web-ui/src/app/scenes/settings/appearance.ts +++ b/src/web-ui/src/app/scenes/settings/appearance.ts @@ -14,19 +14,19 @@ export const settingsAppearanceDescriptor: AppearanceSurfaceDescriptor = { 'application.general', 'application.appearance', 'application.pet', - 'application.voice', - 'application.shortcuts', - 'application.terminal', - 'application.editor', + 'application.input', 'ai.models', - 'ai.memory', - 'workspace.session', - 'workspace.worktrees', - 'tools.execution', + 'ai.session-memory', + 'ai.execution', + 'ai.permissions', + 'development.editor', + 'development.terminal', + 'development.workspace', + 'tools.web-search', 'tools.desktop-control', - 'tools.automation', 'tools.mcp', - 'tools.acp', + 'tools.external-agents', + 'tools.automation', 'data.usage', 'data.archived', 'data.diagnostics', diff --git a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss deleted file mode 100644 index 4a38688700..0000000000 --- a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss +++ /dev/null @@ -1,108 +0,0 @@ -.kb-shortcuts { - &__toolbar { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 20px; - } - - /** Design-system `SearchField` — align with settings nav capsule */ - &__search { - flex: 1; - min-width: 0; - } - - &__actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; - } - - &__error { - padding: 8px 12px; - margin-bottom: 16px; - border-radius: 6px; - background: var(--openbitfun-color-status-danger-surface); - color: var(--openbitfun-color-status-danger-content); - font-size: var(--openbitfun-type-label-md-font-size); - } - - &__list { - display: flex; - flex-direction: column; - gap: 2px; - } - - &__item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-radius: 6px; - - &:hover { - background: var(--openbitfun-color-action-neutral-surface-hover); - } - - &--recording { - background: var(--openbitfun-color-action-secondary-background); - } - - &--conflict { - background: var(--openbitfun-color-status-warning-surface); - } - - &--modified .kb-shortcuts__item-name { - font-style: italic; - } - } - - &__item-label { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-width: 0; - } - - &__item-name { - font-size: var(--openbitfun-type-label-md-font-size); - color: var(--openbitfun-color-content-primary); - white-space: nowrap; - overflow: hidden; - } - - &__item-hint { - font-size: var(--openbitfun-type-micro-font-size); - line-height: var(--openbitfun-type-meta-line-height); - color: var(--openbitfun-color-content-muted); - font-weight: var(--openbitfun-type-body-sm-font-weight); - white-space: normal; - opacity: 0.62; - } - - &__item-conflict-hint { - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-status-warning-content); - } - - &__item-key { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - margin-left: 16px; - } - - &__item--merged { - opacity: 0.95; - } - - &__empty { - padding: 32px; - text-align: center; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-body-md-font-size); - } -} diff --git a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.appearance.ts b/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.appearance.ts deleted file mode 100644 index 65cd75c61f..0000000000 --- a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.appearance.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const settingsViewPageAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'settings-view-page', - parts: [ - { id: 'root', propertyProfile: 'layout', visualRole: 'content' }, - { id: 'loading', propertyProfile: 'overlay', visualRole: 'content' }, - { id: 'loadingLine', propertyProfile: 'paint', visualRole: 'decoration' }, - { id: 'loadingBlock', propertyProfile: 'paint', visualRole: 'decoration' }, - ], - facets: [{ - id: 'view', - attribute: 'data-openbitfun-view', - values: ['voice', 'shortcuts', 'editor', 'terminal', 'quick-actions', 'hooks'], - }], -}; diff --git a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.scss b/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.scss deleted file mode 100644 index 0191b39887..0000000000 --- a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.scss +++ /dev/null @@ -1,49 +0,0 @@ -.openbitfun-settings-view-page { - display: flex; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: hidden; - - &__tabs { - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-width: 0; - min-height: 0; - - } - - &__tab-list { - flex: 0 0 auto; - margin: var(--openbitfun-space-3) var(--openbitfun-space-6) 0; - } - - &__tab-content { - flex: 1 1 auto; - width: 100%; - min-height: 0; - overflow: hidden; - } - - &__loading { - display: grid; - gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-6); - } - - &__loading-line, - &__loading-block { - border-radius: var(--openbitfun-radius-sm); - background: var(--openbitfun-color-surface-subtle); - } - - &__loading-line { - width: min(320px, 70%); - height: 12px; - } - - &__loading-block { - height: 96px; - } -} diff --git a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx b/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx deleted file mode 100644 index 64f0c9dd82..0000000000 --- a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { Suspense, useMemo } from 'react'; -import { TabGroup } from '@openbitfun/ui'; -import { useSettingsStore } from '../settingsStore'; -import type { SettingsPageProps, SettingsViewId } from '../settingsTypes'; -import './SettingsViewPage.scss'; - -export interface SettingsViewDefinition { - id: SettingsViewId; - label: React.ReactNode; - content: React.ReactNode; -} - -interface SettingsViewPageProps extends SettingsPageProps { - defaultViewId: SettingsViewId; - views: readonly SettingsViewDefinition[]; -} - -export const SettingsViewPage: React.FC = ({ - defaultViewId, - views, - viewId, -}) => { - const setActiveView = useSettingsStore((state) => state.setActiveView); - const allowedViewIds = useMemo(() => new Set(views.map((view) => view.id)), [views]); - const activeViewId = viewId && allowedViewIds.has(viewId) ? viewId : defaultViewId; - const activeView = views.find((view) => view.id === activeViewId) ?? views[0]; - const tabItems = views.map((view) => ({ - id: `settings-view-${view.id}-tab`, - label: view.label, - panelId: `settings-view-${view.id}-panel`, - value: view.id, - })); - - const handleChange = (nextViewId: string) => { - if (!allowedViewIds.has(nextViewId as SettingsViewId)) return; - const next = nextViewId as SettingsViewId; - setActiveView(next); - }; - - return ( -
    -
    - - {activeView && ( -
    -
    - )}> - {activeView.content} - -
    - )} -
    -
- ); -}; diff --git a/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.presentation.test.ts b/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.presentation.test.ts deleted file mode 100644 index 2c0da20090..0000000000 --- a/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.presentation.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; - -function readSource(relativePath: string): string { - return readFileSync( - fileURLToPath(new URL(relativePath, import.meta.url)), - 'utf8', - ); -} - -describe('AutomationSettingsPage structure', () => { - it('keeps quick actions and Hooks in one continuous page without tabs', () => { - const source = readSource('./AutomationSettingsPage.tsx'); - const quickActionsIndex = source.indexOf(''); - const hooksIndex = source.indexOf(''); - - expect(quickActionsIndex).toBeGreaterThan(-1); - expect(hooksIndex).toBeGreaterThan(quickActionsIndex); - expect(source).not.toContain('SettingsViewPage'); - expect(source).not.toContain(' { - const styles = readSource('./AutomationSettingsPage.scss'); - const hooksRule = styles.match(/&--hooks\s*{([^}]*)}/)?.[1] ?? ''; - - expect(hooksRule).toContain('margin-top'); - expect(hooksRule).not.toContain('border'); - }); -}); diff --git a/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.scss b/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.scss deleted file mode 100644 index bbe0af8ffe..0000000000 --- a/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.scss +++ /dev/null @@ -1,44 +0,0 @@ -.openbitfun-automation-settings-page { - flex: 1 1 auto; - min-width: 0; - min-height: 0; - /* ScrollArea owns overflow. */ - background: var(--openbitfun-color-surface-scene); - - &__section { - min-width: 0; - scroll-margin-top: var(--openbitfun-space-4); - - > .openbitfun-config-page-layout { - height: auto; - overflow: visible; - background: transparent; - } - - &:not(:last-child) > .openbitfun-config-page-layout > .openbitfun-config-page-layout__scroll-end-spacer { - height: 0; - } - - &--hooks { - margin-top: var(--openbitfun-space-4); - } - } - - &__loading { - display: grid; - gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-8); - - > span { - width: min(320px, 70%); - height: 12px; - border-radius: var(--openbitfun-radius-sm); - background: var(--openbitfun-color-surface-subtle); - } - - > .openbitfun-automation-settings-page__loading-block { - width: min(760px, 100%); - height: 96px; - } - } -} diff --git a/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.tsx deleted file mode 100644 index b98a28e05f..0000000000 --- a/src/web-ui/src/app/scenes/settings/pages/AutomationSettingsPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery'; -import { ScrollArea } from '@openbitfun/ui'; -import React, { Suspense, useEffect, useRef } from 'react'; -import type { SettingsPageProps } from '../settingsTypes'; -import './AutomationSettingsPage.scss'; - -const QuickActionsConfig = lazyWithRecovery(() => import('@/infrastructure/config/components/QuickActionsConfig')); -const HooksConfig = lazyWithRecovery(() => import('@/infrastructure/config/components/HooksConfig')); - -function AutomationSettingsLoading() { - return ( - - ); -} - -const AutomationSettingsPage: React.FC = ({ viewId, navigationRequestId }) => { - const quickActionsSectionRef = useRef(null); - const hooksSectionRef = useRef(null); - - useEffect(() => { - const target = viewId === 'hooks' ? hooksSectionRef.current : quickActionsSectionRef.current; - target?.scrollIntoView?.({ block: 'start' }); - }, [navigationRequestId, viewId]); - - return ( - -
- }> - - -
-
- }> - - -
-
- ); -}; - -export default AutomationSettingsPage; diff --git a/src/web-ui/src/app/scenes/settings/pages/EditorSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/EditorSettingsPage.tsx deleted file mode 100644 index ff3d6ee2b6..0000000000 --- a/src/web-ui/src/app/scenes/settings/pages/EditorSettingsPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; -import EditorConfig from '@/infrastructure/config/components/EditorConfig'; -import type { SettingsPageProps } from '../settingsTypes'; - -const EditorSettingsPage: React.FC = () => ; - -export default EditorSettingsPage; diff --git a/src/web-ui/src/app/scenes/settings/pages/ExecutionSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ExecutionSettingsPage.tsx deleted file mode 100644 index 281340326f..0000000000 --- a/src/web-ui/src/app/scenes/settings/pages/ExecutionSettingsPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { - ExecutionSettingsPage as RuntimeExecutionSettingsPage, -} from '@/infrastructure/config/components/RuntimeSettingsPages'; -import type { SettingsPageProps } from '../settingsTypes'; - -const ExecutionSettingsPage: React.FC = () => ( - -); - -export default ExecutionSettingsPage; diff --git a/src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.test.tsx similarity index 97% rename from src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.test.tsx rename to src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.test.tsx index 7a248201d9..2b5b5479b4 100644 --- a/src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.test.tsx @@ -4,7 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChatInputModePreference } from '@/flow_chat/services/ChatInputModePreferenceService'; -import { DefaultHarnessConfig } from './DefaultHarnessConfig'; +import { DefaultHarnessSection } from '@/app/scenes/settings/pages/ai/DefaultHarnessSection'; const mocks = vi.hoisted(() => ({ preference: { @@ -70,7 +70,7 @@ vi.mock('@openbitfun/ui', async original => ({ ), })); -describe('DefaultHarnessConfig', () => { +describe('DefaultHarnessSection', () => { let container: HTMLDivElement; let root: Root; @@ -110,7 +110,7 @@ describe('DefaultHarnessConfig', () => { }); const render = async () => { - await act(async () => root.render()); + await act(async () => root.render()); }; it('shows follow-last memory and reveals a seeded fixed selector when requested', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx similarity index 95% rename from src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.tsx rename to src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx index 9454c17f57..7bfd32400c 100644 --- a/src/web-ui/src/infrastructure/config/components/DefaultHarnessConfig.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx @@ -5,27 +5,27 @@ import { useTranslation } from 'react-i18next'; import { chatInputModePreferenceService, - type ChatInputDefaultModeStrategy, - type ChatInputModePreference, withChatInputDefaultModeStrategy, withFixedChatInputDefaultMode, + type ChatInputDefaultModeStrategy, + type ChatInputModePreference, } from '@/flow_chat/services/ChatInputModePreferenceService'; -import { createLogger } from '@/shared/utils/logger'; -import { notificationService } from '@/shared/notification-system'; import { ConfigLoadingState, ConfigPageRow, ConfigPageSection, ConfigRetryState, -} from './common'; +} from '@/infrastructure/config/components/common'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; -const log = createLogger('DefaultHarnessConfig'); +const log = createLogger('DefaultHarnessSection'); function selectValue(value: string | number | (string | number)[]): string { return String(Array.isArray(value) ? value[0] ?? '' : value); } -export function DefaultHarnessConfig(): React.ReactElement { +export function DefaultHarnessSection(): React.ReactElement { const { t } = useTranslation('settings/runtime'); const { t: tFlowChat } = useTranslation('flow-chat'); const [preference, setPreference] = useState(null); @@ -156,11 +156,11 @@ export function DefaultHarnessConfig(): React.ReactElement { } else { const followLastDescription = preference.lastModeId ? t('defaultHarness.followLastDescription', { - name: labelForMode(preference.lastModeId), - }) + name: labelForMode(preference.lastModeId), + }) : t('defaultHarness.followLastEmptyDescription', { - name: labelForMode('Standard'), - }); + name: labelForMode('Standard'), + }); content = ( <> { + const { t } = useTranslation('settings/runtime'); + + const { t: tTools } = useTranslation('settings/agentic-tools'); + + const { t: tModels } = useTranslation('settings/models'); + + const { buildModelOption } = useModelSelectPresentation(); + + const [subagentDefaultModel, setSubagentDefaultModel] = useState({ kind: 'fixed', model_id: 'fast' }); + + const [configuredModels, setConfiguredModels] = useState([]); + + const [isLoading, setIsLoading] = useState(true); + + const [loadError, setLoadError] = useState(false); + + const hasLoadedPageDataRef = useRef(false); + + const [enableDeferredToolLoading, setEnableDeferredToolLoading] = useState(true); + + const [subagentMaxConcurrency, setSubagentMaxConcurrency] = useState(DEFAULT_SUBAGENT_MAX_CONCURRENCY); + + const [swarmMaxConcurrency, setSwarmMaxConcurrency] = useState(DEFAULT_SWARM_MAX_CONCURRENCY); + + const [executionTimeout, setExecutionTimeout] = useState(''); + + const [userQuestionTimeout, setUserQuestionTimeout] = useState('180'); + + const [subagentBatchExecutionPolicy, setSubagentBatchExecutionPolicy] = + useState(DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY); + + const [toolExecConfigLoading, setToolExecConfigLoading] = useState(false); + + const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false); + + const subagentBatchExecutionPolicyOptions: DescribedSelectOption[] = [ + { + value: 'safe_only', + label: tTools('config.subagentBatchPolicy.safeOnly'), + description: tTools('config.subagentBatchPolicy.safeOnlyDesc'), + }, + { + value: 'force_parallel', + label: tTools('config.subagentBatchPolicy.forceParallel'), + description: tTools('config.subagentBatchPolicy.forceParallelDesc'), + }, + ]; + + const handleDeferredToolLoadingChange = async (checked: boolean) => { + const previous = enableDeferredToolLoading; + setEnableDeferredToolLoading(checked); + setDeferredToolLoadingConfigSaving(true); + try { + await configManager.setConfig('ai.enable_deferred_tool_loading', checked); + notificationService.success(t('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save enable_deferred_tool_loading', error); + notificationService.error( + `${t('messages.saveFailed')}: ` + (error instanceof Error ? error.message : String(error)) + ); + setEnableDeferredToolLoading(previous); + } finally { + setDeferredToolLoadingConfigSaving(false); + } + }; + + const handleSubagentBatchExecutionPolicyChange = async (value: string | number | (string | number)[]) => { + const nextPolicy = normalizeSubagentBatchExecutionPolicy(Array.isArray(value) ? value[0] : value); + const previousPolicy = subagentBatchExecutionPolicy; + setSubagentBatchExecutionPolicy(nextPolicy); + setToolExecConfigLoading(true); + try { + await configManager.setConfig('ai.subagent_batch_execution_policy', nextPolicy); + notificationService.success(tTools('messages.saveSuccess'), { duration: 2000 }); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + } catch (error) { + log.error('Failed to save subagent_batch_execution_policy', error); + notificationService.error( + `${tTools('messages.saveFailed')}: ` + (error instanceof Error ? error.message : String(error)) + ); + setSubagentBatchExecutionPolicy(previousPolicy); + } finally { + setToolExecConfigLoading(false); + } + }; + + const subagentModelValue = subagentDefaultModel.kind === 'inherit' ? 'inherit' : subagentDefaultModel.model_id; + + const subagentModelOptions: ComboboxOption[] = [ + { value: 'inherit', label: tTools('config.subagentModelInherit') }, + { value: 'fast', label: tModels('sessionTitle.model.fast') }, + { value: 'primary', label: tModels('sessionTitle.model.primary') }, + ...configuredModels.filter(model => model.enabled && model.id).map(buildModelOption), + ]; + + const handleSubagentDefaultModelChange = async (value: string | number) => { + const selection: SubagentModelSelection = value === 'inherit' + ? { kind: 'inherit' } + : { kind: 'fixed', model_id: String(value) }; + setToolExecConfigLoading(true); + try { + await configManager.setConfig('ai.agent_model_defaults.subagents.default', selection); + setSubagentDefaultModel(selection); + notificationService.success(tTools('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save default subagent model', error); + notificationService.error( + `${tTools('messages.saveFailed')}: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setToolExecConfigLoading(false); + } + }; + + const handleSwarmMaxConcurrencyChange = async (input: number) => { + if (!Number.isFinite(input)) return; + const value = Math.min(SWARM_MAX_CONCURRENCY_LIMIT, Math.max(1, Math.round(input))); + const previous = swarmMaxConcurrency; + setSwarmMaxConcurrency(value); + setToolExecConfigLoading(true); + try { + await configManager.setConfig('ai.swarm_max_concurrency', value); + notificationService.success(tTools('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save swarm_max_concurrency', error); + setSwarmMaxConcurrency(previous); + notificationService.error( + `${tTools('messages.saveFailed')}: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setToolExecConfigLoading(false); + } + }; + + const handleSubagentMaxConcurrencyChange = async (input: number) => { + if (!Number.isFinite(input)) return; + const value = Math.min(SUBAGENT_MAX_CONCURRENCY_LIMIT, Math.max(1, Math.round(input))); + const previous = subagentMaxConcurrency; + setSubagentMaxConcurrency(value); + setToolExecConfigLoading(true); + try { + await configManager.setConfig('ai.subagent_max_concurrency', value); + notificationService.success(tTools('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save subagent_max_concurrency', error); + setSubagentMaxConcurrency(previous); + notificationService.error( + `${tTools('messages.saveFailed')}: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setToolExecConfigLoading(false); + } + }; + + const handleToolTimeoutChange = async (value: string) => { + const configKey = 'ai.tool_execution_timeout_secs'; + const trimmedValue = value.trim(); + if (trimmedValue !== '') { + const numValue = parseInt(trimmedValue, 10); + if (Number.isNaN(numValue) || numValue < 0) return; + } + const previous = executionTimeout; + setExecutionTimeout(trimmedValue); + setToolExecConfigLoading(true); + const numValue = trimmedValue === '' ? null : parseInt(trimmedValue, 10); + try { + await configManager.setConfig(configKey, numValue); + } catch (error) { + log.error('Failed to save tool timeout config', { error }); + setExecutionTimeout(previous); + notificationService.error(tTools('messages.saveFailed')); + } finally { + setToolExecConfigLoading(false); + } + }; + + const handleUserQuestionTimeoutChange = async (value: string) => { + const trimmed = value.trim(); + if (trimmed !== '' && (!/^\d+$/.test(trimmed) || Number(trimmed) > 3600)) return; + if (toolExecConfigLoading || Number(trimmed) === Number(userQuestionTimeout)) return; + const previous = userQuestionTimeout; + setUserQuestionTimeout(trimmed); + setToolExecConfigLoading(true); + try { + await configManager.setConfig('ai.user_question_timeout_secs', trimmed === '' ? null : Number(trimmed)); + } catch (error) { + log.error('Failed to save user question timeout config', { error }); + setUserQuestionTimeout(previous); + notificationService.error(tTools('messages.saveFailed')); + } finally { + setToolExecConfigLoading(false); + } + }; + + const loadPageData = useCallback(async () => { + const isInitialLoad = !hasLoadedPageDataRef.current; + if (isInitialLoad) { setIsLoading(true); setLoadError(false); } + try { + const [ + loadedSubagentDefaultModel, + loadedModels, + deferredToolLoadingEnabled, + loadedSubagentMaxConcurrency, + loadedSwarmMaxConcurrency, + execTimeout, + loadedSubagentBatchExecutionPolicy, + loadedUserQuestionTimeout, + ] = await Promise.all([ + configManager.getConfig('ai.agent_model_defaults.subagents.default'), + configManager.getConfig('ai.models'), + configManager.getConfig('ai.enable_deferred_tool_loading'), + configManager.getConfig('ai.subagent_max_concurrency'), + configManager.getConfig('ai.swarm_max_concurrency'), + configManager.getConfig('ai.tool_execution_timeout_secs'), + configManager.getConfig('ai.subagent_batch_execution_policy'), + configManager.getOptionalConfig('ai.user_question_timeout_secs'), + ]); + setSubagentDefaultModel(loadedSubagentDefaultModel ?? { kind: 'fixed', model_id: 'fast' }); + setConfiguredModels(loadedModels ?? []); + setEnableDeferredToolLoading(deferredToolLoadingEnabled ?? true); + setSubagentMaxConcurrency(loadedSubagentMaxConcurrency != null + ? loadedSubagentMaxConcurrency + : DEFAULT_SUBAGENT_MAX_CONCURRENCY); + setSwarmMaxConcurrency(loadedSwarmMaxConcurrency != null + ? loadedSwarmMaxConcurrency + : DEFAULT_SWARM_MAX_CONCURRENCY); + setExecutionTimeout(execTimeout != null ? String(execTimeout) : ''); + setUserQuestionTimeout(loadedUserQuestionTimeout === undefined || loadedUserQuestionTimeout === 180 + ? '180' + : loadedUserQuestionTimeout === null ? '0' : String(loadedUserQuestionTimeout)); + setSubagentBatchExecutionPolicy(normalizeSubagentBatchExecutionPolicy(loadedSubagentBatchExecutionPolicy)); + + hasLoadedPageDataRef.current = true; + } catch (error) { + log.error('Failed to load settings page data', { error }); + if (isInitialLoad) setLoadError(true); + } finally { + if (isInitialLoad) setIsLoading(false); + } + }, []); + useEffect(() => { + + void loadPageData(); + }, [loadPageData]); + + if (!subagentModelOptions.some(option => option.value === subagentModelValue)) { + subagentModelOptions.push({ + value: subagentModelValue, + label: tModels('sessionTitle.models.unavailable', { id: subagentModelValue }), + disabled: true, + }); + } + return ( + + {loadError ? ( + void loadPageData()} /> + ) : isLoading ? ( + + ) : ( + <> + + {/* ── Tool execution behavior ────────────────────────────── */} + + +
+ handleToolTimeoutChange(val === 0 ? '' : String(val))} + min={0} + max={3600} + step={5} + unit={tTools('config.seconds')} + size="sm" + variant="compact" + disabled={toolExecConfigLoading} + /> +
+
+ +
+ void handleUserQuestionTimeoutChange(val === 0 ? '0' : String(val))} + min={0} + max={3600} + step={5} + unit={tTools('config.seconds')} + size="sm" + variant="compact" + disabled={toolExecConfigLoading} + /> +
+
+
+ + + + {tTools('config.subagentDefaultModel')} + + } + onClick={() => useSceneStore.getState().openScene('agents')} + /> + + + } + description={tTools('config.subagentDefaultModelDesc')} + align="center" + > +
+ void handleSubagentDefaultModelChange(value)} + /> +
+
+ +
+ setEditingConfig(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('form.configNamePlaceholder')} - size="sm" - /> - - {renderAuthRow()} - {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} - {!authIsSubscription && ( - <> - -
- {currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && ( - opt.url === editingConfig.base_url) ? editingConfig.base_url : ''} - onValueChange={(value) => { - const selectedOption = currentTemplate.baseUrlOptions!.find(opt => opt.url === value); - const newProvider = selectedOption?.format || editingConfig.provider || 'openai'; - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - base_url: value as string, - request_url: resolveRequestUrl(value as string, newProvider, editingConfig.model_name || ''), - provider: newProvider - })); - }} - placeholder={t('form.baseUrl')} - options={currentTemplate.baseUrlOptions.map(opt => ({ label: opt.note || opt.url, value: opt.url, description: `${opt.format.toUpperCase()} · ${opt.url}` }))} - size="sm" - /> - )} - { - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - base_url: e.target.value, - request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') - })); - }} - onFocus={(e) => e.target.select()} - placeholder={currentTemplate?.baseUrl} - size="sm" - /> - {editingConfig.base_url && !automaticOpenCodeRouting && ( -
- {t('form.resolvedUrlLabel')} - - {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai', selectedModelDrafts.length === 1 ? selectedModelDrafts[0].modelName : undefined)} - -
- )} -
-
- {!automaticOpenCodeRouting && ( - - setManualModelInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addManualModelDraft(); - } - }} - placeholder={t('providerSelection.inputModelName')} - size="sm" - /> - -
- {modelFetchHint && ( - - {modelFetchHint} - - )} - {renderSelectedModelRows()} -
- - - ) : ( - <> - {isProviderScopedEditing && ( - <> - - setEditingConfig(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('form.configNamePlaceholder')} - size="sm" - /> - - {renderAuthRow()} - {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} - {!authIsSubscription && ( - <> - -
- + + setEditingConfig(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('form.configNamePlaceholder')} + size="sm" + /> + + {renderAuthRow()} + {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} + {!authIsSubscription && ( + <> + +
+ {currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && ( + { + value={currentTemplate.baseUrlOptions.some(opt => opt.url === editingConfig.base_url) ? editingConfig.base_url : ''} + onValueChange={(value) => { + const selectedOption = currentTemplate.baseUrlOptions!.find(opt => opt.url === value); + const newProvider = selectedOption?.format || editingConfig.provider || 'openai'; resetRemoteModelDiscovery(); setEditingConfig(prev => ({ ...prev, - base_url: e.target.value, - request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') + base_url: value as string, + request_url: resolveRequestUrl(value as string, newProvider, editingConfig.model_name || ''), + provider: newProvider })); }} - onFocus={(e) => e.target.select()} - placeholder={'https://open.bigmodel.cn/api/paas/v4/chat/completions'} + placeholder={t('form.baseUrl')} + options={currentTemplate.baseUrlOptions.map(opt => ({ label: opt.note || opt.url, value: opt.url, description: `${opt.format.toUpperCase()} · ${opt.url}` }))} size="sm" /> - {editingConfig.base_url && !automaticOpenCodeRouting && ( -
- {t('form.resolvedUrlLabel')} - - {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai', selectedModelDrafts.length === 1 ? selectedModelDrafts[0].modelName : undefined)} - -
- )} -
-
- {!automaticOpenCodeRouting && ( + )} + { + resetRemoteModelDiscovery(); + setEditingConfig(prev => ({ + ...prev, + base_url: e.target.value, + request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') + })); + }} + onFocus={(e) => e.target.select()} + placeholder={currentTemplate?.baseUrl} + size="sm" + /> + {editingConfig.base_url && !automaticOpenCodeRouting && ( +
+ {t('form.resolvedUrlLabel')} + + {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai', selectedModelDrafts.length === 1 ? selectedModelDrafts[0].modelName : undefined)} + +
+ )} +
+
+ {!automaticOpenCodeRouting && ( - { + const provider = value as string; + resetRemoteModelDiscovery(); + setEditingConfig(prev => ({ + ...prev, + provider, + request_url: resolveRequestUrl(prev?.base_url || '', provider, prev?.model_name || '') + })); + }} + placeholder={t('form.providerPlaceholder')} + options={requestFormatOptions} + size="sm" + /> - )} - - )} - - )} - - )} - - {!isFromTemplate && ( - <> - -
-
- {editingConfig.id ? ( - + )} + +
+
+ { - syncSelectedModelDrafts([String(value)], editingConfig, true); + const nextModelNames = value.map(item => String(item)); + syncSelectedModelDrafts(nextModelNames, editingConfig); }} - placeholder="glm-5.2" + placeholder={t('providerSelection.selectModel')} options={availableModelOptions} loading={isFetchingRemoteModels} onCreateValue={value => value} size="sm" onOpenChange={handleModelSelectionOpenChange} /> - ) : ( - { - syncSelectedModelDrafts(value.map(item => String(item)), editingConfig, false); + +
+
+ setManualModelInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addManualModelDraft(); + } }} - placeholder="glm-5.2" - options={availableModelOptions} - loading={isFetchingRemoteModels} - onCreateValue={value => value} + placeholder={t('providerSelection.inputModelName')} size="sm" - onOpenChange={handleModelSelectionOpenChange} /> + +
+ {modelFetchHint && ( + + {modelFetchHint} + )} - + {renderSelectedModelRows()}
-
- setManualModelInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addManualModelDraft(); - } - }} - placeholder={t('providerSelection.inputModelName')} - size="sm" - /> - + + + ) : ( + <> + {isProviderScopedEditing && ( + <> + + setEditingConfig(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('form.configNamePlaceholder')} + size="sm" + /> + + {renderAuthRow()} + {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} + {!authIsSubscription && ( + <> + +
+ { + resetRemoteModelDiscovery(); + setEditingConfig(prev => ({ + ...prev, + base_url: e.target.value, + request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') + })); + }} + onFocus={(e) => e.target.select()} + placeholder={'https://open.bigmodel.cn/api/paas/v4/chat/completions'} + size="sm" + /> + {editingConfig.base_url && !automaticOpenCodeRouting && ( +
+ {t('form.resolvedUrlLabel')} + + {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai', selectedModelDrafts.length === 1 ? selectedModelDrafts[0].modelName : undefined)} + +
+ )} +
+
+ {!automaticOpenCodeRouting && ( + + setManualModelInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addManualModelDraft(); + } + }} + placeholder={t('providerSelection.inputModelName')} + size="sm" + /> + +
+ {modelFetchHint && ( + + {modelFetchHint} + + )} + {renderSelectedModelRows()}
- {modelFetchHint && ( - - {modelFetchHint} - - )} - {renderSelectedModelRows()} -
-
- - )} - + + + )} + - {!authIsSubscription && ( - - - setShowAdvancedSettings(e.target.checked)} /> - + {!authIsSubscription && ( + + + setShowAdvancedSettings(e.target.checked)} /> + - {showAdvancedSettings && ( - <> - {(editingConfig.provider === 'openai' || editingConfig.provider === 'anthropic') && ( + {showAdvancedSettings && ( + <> + {(editingConfig.provider === 'openai' || editingConfig.provider === 'anthropic') && ( + + setEditingConfig(prev => ({ ...prev, inline_think_in_text: e.target.checked }))} + /> + + )} + + {t('advancedSettings.skipSslVerify.warning')} + + ) : undefined} align="center" className="openbitfun-model-settings__toggle-row" > setEditingConfig(prev => ({ ...prev, inline_think_in_text: e.target.checked }))} + checked={editingConfig.skip_ssl_verify || false} + onChange={(e) => setEditingConfig(prev => ({ ...prev, skip_ssl_verify: e.target.checked }))} /> - )} - - - {t('advancedSettings.skipSslVerify.warning')} - - ) : undefined} - align="center" - className="openbitfun-model-settings__toggle-row" - > - setEditingConfig(prev => ({ ...prev, skip_ssl_verify: e.target.checked }))} - /> - - - - {t('advancedSettings.customHeaders.label')} - - {t('advancedSettings.customHeaders.hint')} - - {(editingConfig.custom_headers_mode || 'merge') === 'replace' - ? t('advancedSettings.customHeaders.modeReplaceHint') - : t('advancedSettings.customHeaders.modeMergeHint')} + + + {t('advancedSettings.customHeaders.label')} + + {t('advancedSettings.customHeaders.hint')} + + {(editingConfig.custom_headers_mode || 'merge') === 'replace' + ? t('advancedSettings.customHeaders.modeReplaceHint') + : t('advancedSettings.customHeaders.modeMergeHint')} + - - )} - placement="top" - > - - - - - - - - - - - - + + + + + + + + + + + + + - - )} - multiline - className="openbitfun-model-settings__custom-headers-row" - > -
-
- {Object.entries(editingConfig.custom_headers || {}).map(([key, value], index) => ( -
- { const nh = { ...editingConfig.custom_headers }; const ov = nh[key]; delete nh[key]; if (e.target.value) nh[e.target.value] = ov; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} - placeholder={t('advancedSettings.customHeaders.keyPlaceholder')} - className="openbitfun-model-settings__header-key" - size="sm" - /> - { const nh = { ...editingConfig.custom_headers }; nh[key] = e.target.value; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} - placeholder={t('advancedSettings.customHeaders.valuePlaceholder')} - className="openbitfun-model-settings__header-value" - size="sm" - /> - - +
+
+ {Object.entries(editingConfig.custom_headers || {}).map(([key, value], index) => ( +
+ { const nh = { ...editingConfig.custom_headers }; const ov = nh[key]; delete nh[key]; if (e.target.value) nh[e.target.value] = ov; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} + placeholder={t('advancedSettings.customHeaders.keyPlaceholder')} + className="openbitfun-model-settings__header-key" size="sm" - onClick={() => { const nh = { ...editingConfig.custom_headers }; delete nh[key]; setEditingConfig(prev => ({ ...prev, custom_headers: Object.keys(nh).length > 0 ? nh : undefined })); }} - icon={} /> - -
- ))} - + { const nh = { ...editingConfig.custom_headers }; nh[key] = e.target.value; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} + placeholder={t('advancedSettings.customHeaders.valuePlaceholder')} + className="openbitfun-model-settings__header-value" + size="sm" + /> + + { const nh = { ...editingConfig.custom_headers }; delete nh[key]; setEditingConfig(prev => ({ ...prev, custom_headers: Object.keys(nh).length > 0 ? nh : undefined })); }} + icon={} + /> + +
+ ))} + +
-
- - - - {t('advancedSettings.customRequestBody.label')} - - {t('advancedSettings.customRequestBody.hint')} - {getCustomRequestBodyModeHint(editingConfig.provider, editingConfig.custom_request_body_mode)} - - )} - placement="top" - > - - - - - - - - - - - - + + + + + + + + + + + + + - - )} - multiline - className="openbitfun-model-settings__custom-request-body-row" - > -
-