From 936467141bd4e322899b8d9bd7947fa330f296fb Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Fri, 10 Jul 2026 19:53:34 +0800 Subject: [PATCH] feat(settings): refine hierarchy and visual design --- docs/ui/README.md | 12 + .../i18n/locales/en.settingsPanel.layout.ts | 67 +++ .../renderer/i18n/locales/en.settingsPanel.ts | 46 +- .../locales/en.settingsPanel.workspace.ts | 26 + .../locales/zh-CN.settingsPanel.layout.ts | 67 +++ .../i18n/locales/zh-CN.settingsPanel.ts | 44 +- .../locales/zh-CN.settingsPanel.workspace.ts | 24 + src/app/renderer/styles.css | 1 + src/app/renderer/styles/base.css | 35 +- .../styles/settings-panel.buttons.css | 111 +++- src/app/renderer/styles/settings-panel.css | 232 +++++---- .../renderer/styles/settings-panel.groups.css | 145 ++++++ .../renderer/styles/settings-panel.lists.css | 62 ++- .../styles/settings-panel.preview.css | 32 +- .../renderer/styles/settings-panel.search.css | 69 ++- .../styles/settings-panel.sidebar.css | 254 ++++++++-- .../presentation/renderer/SettingsPanel.tsx | 123 +++-- .../settingsPanel/AdvancedSection.tsx | 7 - .../AgentProviderConfigurePanel.tsx | 6 +- .../renderer/settingsPanel/AgentSection.tsx | 324 ++++++------ .../settingsPanel/AppearanceSection.tsx | 193 +++---- .../renderer/settingsPanel/CanvasSection.tsx | 378 +++++++------- .../settingsPanel/CanvasWindowsSection.tsx | 4 +- .../settingsPanel/DiagnosticsSection.tsx | 49 +- .../settingsPanel/EndpointsSection.tsx | 42 +- .../settingsPanel/ExperimentalSection.tsx | 29 +- .../ExperimentalWorkerWebUiSection.tsx | 356 ++++++------- .../renderer/settingsPanel/GeneralSection.tsx | 250 +++++----- .../settingsPanel/IntegrationsSection.tsx | 45 +- .../settingsPanel/NotificationsSection.tsx | 224 +++++---- .../settingsPanel/QuickMenuSection.tsx | 8 +- .../renderer/settingsPanel/SettingsGroup.tsx | 97 ++++ .../settingsPanel/SettingsPanelHeader.tsx | 40 ++ .../settingsPanel/SettingsPanelNavButton.tsx | 5 +- .../settingsPanel/SettingsPanelSidebar.tsx | 414 +++++++++------ .../settingsPanel/ShortcutsSection.tsx | 423 ++++++++-------- .../TaskConfigurationSection.tsx | 157 +++--- .../TerminalDisplayCalibrationRow.tsx | 18 +- .../settingsPanel/TerminalFontFamilyRow.tsx | 2 + .../settingsPanel/TerminalProfileSection.tsx | 113 +++-- .../WorkerConnectionsSection.tsx | 58 +-- .../WorkerRemoteConnectionFields.tsx | 91 ++++ .../renderer/settingsPanel/WorkerSection.tsx | 472 ++++++++---------- .../settingsPanel/WorkspaceSection.tsx | 189 ++++--- .../quickMenu/QuickCommandsSubsection.tsx | 18 +- .../quickMenu/QuickPhrasesSubsection.tsx | 17 +- .../settingsPageRegistry.spec.ts | 112 +++++ .../settingsPanel/settingsPageRegistry.ts | 213 ++++++++ .../settingsSearchIndex.agent.ts | 11 +- .../settingsPanel/settingsSearchIndex.spec.ts | 2 + .../settingsPanel/settingsSearchIndex.ts | 40 +- .../renderer/useSettingsPanelDialog.ts | 196 ++++++++ .../renderer/useSettingsPanelPageState.ts | 24 +- tests/e2e/control-center.spec.ts | 11 +- ...m6.endpoints-mounts.integration.helpers.ts | 21 +- tests/e2e/performance-monitor.mac.spec.ts | 2 +- tests/e2e/performance-monitor.windows.spec.ts | 2 +- tests/e2e/settings.spec.ts | 2 +- tests/e2e/settings.visual.spec.ts | 397 +++++++++++++++ tests/e2e/shortcuts.spec.ts | 2 +- .../settingsPanel.dialogFocus.spec.tsx | 176 +++++++ .../settingsPanel.formAccessibility.spec.tsx | 308 ++++++++++++ .../settingsPanel.groupHierarchy.spec.tsx | 79 +++ .../settingsPanel.searchKeyboard.spec.tsx | 120 +++++ .../contexts/settingsPanel.shell.spec.tsx | 167 +++++++ tests/unit/contexts/settingsPanel.spec.tsx | 13 +- .../contexts/workerSection.hierarchy.spec.tsx | 66 +++ tests/unit/contexts/workspaceSection.spec.tsx | 10 +- 68 files changed, 5191 insertions(+), 2162 deletions(-) create mode 100644 src/app/renderer/i18n/locales/en.settingsPanel.workspace.ts create mode 100644 src/app/renderer/i18n/locales/zh-CN.settingsPanel.workspace.ts create mode 100644 src/app/renderer/styles/settings-panel.groups.css create mode 100644 src/contexts/settings/presentation/renderer/settingsPanel/SettingsGroup.tsx create mode 100644 src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelHeader.tsx create mode 100644 src/contexts/settings/presentation/renderer/settingsPanel/WorkerRemoteConnectionFields.tsx create mode 100644 src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.spec.ts create mode 100644 src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.ts create mode 100644 src/contexts/settings/presentation/renderer/useSettingsPanelDialog.ts create mode 100644 tests/e2e/settings.visual.spec.ts create mode 100644 tests/unit/contexts/settingsPanel.dialogFocus.spec.tsx create mode 100644 tests/unit/contexts/settingsPanel.formAccessibility.spec.tsx create mode 100644 tests/unit/contexts/settingsPanel.groupHierarchy.spec.tsx create mode 100644 tests/unit/contexts/settingsPanel.searchKeyboard.spec.tsx create mode 100644 tests/unit/contexts/settingsPanel.shell.spec.tsx create mode 100644 tests/unit/contexts/workerSection.hierarchy.spec.tsx diff --git a/docs/ui/README.md b/docs/ui/README.md index f862a5145..69cb2496a 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -102,11 +102,23 @@ - 所有文本/边框/输入框必须基于 token;尤其避免 `#fff` / `#ccc` 这类暗色假设。 - provider/card 标题与错误信息必须可读(Light/Dark 都可读)。 +- 页面 registry 是 Settings 信息架构的单一来源:规范页面 id、别名、导航分组、标题/说明键和滚动目标;侧栏、动态页头、搜索结果与窄窗选择器必须共同消费它,禁止各自维护映射。 +- 桌面侧栏按“应用 / 工作区 / 连接 / 高级”分组;项目使用独立、可折叠分组。二级入口可供搜索或深链定位,但不重复占用主导航。 +- 页头必须随当前页面显示对应标题和简短说明,标题与说明来自 registry,不使用泛化的固定标题替代页面上下文。 +- Settings 内容层级固定为 `Page H2 → Group H3 →(必要时)Module H4 → Row`;Group 标题与说明必须位于内容面之外,不能用 `` 冒充结构标题。 +- 每个 Group 最多一个共享的轻量内容面,内部继续使用扁平设置行和细分隔线;Module 只用标题、说明、留白与 hairline 区分,不得再拥有独立背景、圆角或 header surface。Agent、Endpoint 等具备独立身份与状态的重复实体可保留 Entity Card,但不能再套 Group Body Card。 +- 单项页面不为了形式统一强加空洞层级;条件内容不得产生空 Group;重排时必须保留搜索/深链依赖的 `settings-section-*` anchor。 +- 窄窗口使用顶部 page selector 和单列内容,不能把桌面侧栏压缩成不可读的窄栏;页面切换后仍需保持标题、说明和目标内容可达。 +- Settings 容器必须具备 `dialog` 语义、可访问名称和 `aria-modal`。打开后把焦点移入面板,关闭后恢复到触发控件;所有交互必须有清晰的 `:focus-visible`。 +- 搜索支持方向键选择结果、`Enter` 跳转和 `Escape` 退出;查询非空时首次 `Escape` 先清空查询,再次 `Escape` 关闭面板。关闭按钮必须有可访问名称。 实现锚点: - 面板样式:`src/app/renderer/styles/settings-panel.css` +- 分组原语:`src/contexts/settings/presentation/renderer/settingsPanel/SettingsGroup.tsx` - 主题选择:`src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection.tsx` +参考原则:Apple HIG Settings(稳定 pane 与相关设置分组)、Windows App Settings(单列、受约束宽度与 section header)、GNOME HIG Boxed Lists(短静态偏好使用共享列表面,标题位于列表之外)。 + --- ## 6) 测试与验收(UI 变更必须做) diff --git a/src/app/renderer/i18n/locales/en.settingsPanel.layout.ts b/src/app/renderer/i18n/locales/en.settingsPanel.layout.ts index 74f6e4d0d..55cbd6d8c 100644 --- a/src/app/renderer/i18n/locales/en.settingsPanel.layout.ts +++ b/src/app/renderer/i18n/locales/en.settingsPanel.layout.ts @@ -20,6 +20,73 @@ export const enSettingsPanelLayout = { diagnostics: 'Diagnostics', projects: 'Projects', sectionsLabel: 'Settings sections', + pageSelectorLabel: 'Settings page', + expandProjects: 'Expand projects', + collapseProjects: 'Collapse projects', + }, + navGroups: { + application: 'Application', + workspace: 'Workspace', + connections: 'Connections', + advanced: 'Advanced', + projects: 'Projects', + }, + pageDescriptions: { + general: 'Language, updates, and everyday preferences.', + appearance: 'Theme, interface text, and terminal typography.', + notifications: 'Manage system alerts and standby context.', + canvasWindows: 'Adjust canvas navigation and window behavior.', + agent: 'Choose and configure AI assistants.', + tasksShortcuts: 'Set task defaults, quick actions, and shortcuts.', + worker: 'Configure Worker runtime, access, and remote endpoints.', + integrations: 'Connect OpenCove with external services.', + advanced: 'Manage experimental features and diagnostics.', + }, + groups: { + general: { + languageRegion: 'Language & Region', + softwareUpdates: 'Software Updates', + }, + appearance: { + interface: 'Interface', + terminal: 'Terminal', + }, + notifications: { + system: 'Notification Delivery', + standby: 'Standby Banner', + }, + canvasWindows: { + canvasInput: 'Canvas Input', + nodeFocus: 'Node Focus', + spaceArchiving: 'Space Archiving', + windowTerminal: 'Windows & Terminal', + }, + agent: { + providers: 'Agent Providers', + permissions: 'Permissions', + }, + tasksShortcuts: { + taskTitles: 'Task Titles', + taskTags: 'Task Tags', + quickActions: 'Quick Actions', + keyboard: 'Keyboard Shortcuts', + }, + worker: { + runtime: 'Worker Runtime', + remote: 'Remote Connections', + webAccess: 'Browser Access', + }, + integrations: { + github: 'GitHub', + }, + advanced: { + websiteWindows: 'Website Windows', + diagnostics: 'Performance Diagnostics', + }, + workspace: { + worktrees: 'Worktrees', + environment: 'Runtime Environment', + }, }, appearance: { title: 'Display & Fonts', diff --git a/src/app/renderer/i18n/locales/en.settingsPanel.ts b/src/app/renderer/i18n/locales/en.settingsPanel.ts index e443852f3..36e0d9a3e 100644 --- a/src/app/renderer/i18n/locales/en.settingsPanel.ts +++ b/src/app/renderer/i18n/locales/en.settingsPanel.ts @@ -3,6 +3,7 @@ import { enSettingsPanelEndpoints } from './en.settingsPanel.endpoints' import { enSettingsPanelAgentExecutable } from './en.settingsPanel.agentExecutable' import { enSettingsPanelDiagnostics } from './en.settingsPanel.diagnostics' import { enSettingsPanelLayout } from './en.settingsPanel.layout' +import { enSettingsPanelWorkspace } from './en.settingsPanel.workspace' import { enWorkspaceCanvasNavigationShortcutCommands } from './en.settingsPanel.shortcuts.workspaceCanvasNavigation' export const enSettingsPanel = { @@ -13,6 +14,9 @@ export const enSettingsPanel = { noResults: 'No matching settings', }, nav: enSettingsPanelLayout.nav, + navGroups: enSettingsPanelLayout.navGroups, + pageDescriptions: enSettingsPanelLayout.pageDescriptions, + groups: enSettingsPanelLayout.groups, general: { title: 'General', languageLabel: 'Display Language', @@ -97,6 +101,8 @@ export const enSettingsPanel = { help: 'Add or override environment variables when starting an agent.', add: 'Add Variable', enabled: 'Enabled', + keyLabel: 'variable name', + valueLabel: 'variable value', keyPlaceholder: 'KEY', valuePlaceholder: 'value', }, @@ -221,6 +227,7 @@ export const enSettingsPanel = { 'Shows remote worker and remote location UI. When disabled, OpenCove keeps the local-only workflow by default.', workerWebUi: { title: 'Worker Web UI', + securityTitle: 'Access Security', help: 'Open the experimental browser UI served by the Local Worker.', enabledLabel: 'Enable Web UI', enabledHelp: @@ -418,13 +425,13 @@ export const enSettingsPanel = { 'OpenCove uses the Local Worker on this device. Most of the time, you only need to start or restart it below.', packagedModeLabel: 'Current Mode', packagedModeValue: 'Local Worker', - modeLabel: 'Worker Mode', + modeLabel: 'Current Mode', mode: { standalone: 'Standalone (No Worker)', local: 'Local Worker', remote: 'Remote Worker', }, - applyLabel: 'Apply', + applyLabel: 'Mode Changes', applyHelp: 'Changes require a restart to take effect.', applyRestart: 'Apply & Restart', }, @@ -438,25 +445,25 @@ export const enSettingsPanel = { cli: { title: 'CLI', help: 'Install the `opencove` command in your PATH.', - statusLabel: 'Status', + statusLabel: 'CLI Status', status: { installed: 'Installed ({{path}})', needsRepair: 'Installed, but the launcher needs repair ({{path}})', notInstalled: 'Not installed', }, - actionsLabel: 'Actions', + actionsLabel: 'CLI Actions', install: 'Install CLI', uninstall: 'Uninstall CLI', }, local: { - title: 'Local Worker Status', + title: 'Local Worker', help: 'Loopback-only (127.0.0.1) + token. Use SSH tunnels for remote access. Worker Web UI is in Worker & Connections.', - statusLabel: 'Status', + statusLabel: 'Worker Status', status: { running: 'Running', stopped: 'Stopped', }, - actionsLabel: 'Actions', + actionsLabel: 'Worker Controls', start: 'Start', stop: 'Stop', baseUrlLabel: 'Base URL', @@ -468,28 +475,5 @@ export const enSettingsPanel = { }, }, endpoints: enSettingsPanelEndpoints, - workspace: { - title: 'Workspace Worktree', - selectProjectFirst: 'Select a project first', - selectProjectFirstHelp: 'Choose a project in the sidebar to configure its worktree root.', - workspacePathLabel: 'Workspace Path', - workspacePathHelp: 'Project root for {{name}}.', - worktreeRootLabel: 'Worktree Root', - worktreeRootHelp: 'Relative path is based on project root.', - worktreeRootPlaceholder: '.opencove/worktrees', - resolvedPathLabel: 'Resolved Path', - resolvedPathHelp: 'Where new worktrees will be created.', - pullRequestBaseBranchesLabel: 'Pull Request Base Branches', - pullRequestBaseBranchesHelp: - 'Suggestions shown when creating PRs on the canvas. The repository default branch is always included.', - pullRequestBaseBranchesEmpty: 'No base branches configured.', - pullRequestBaseBranchesPlaceholder: 'e.g. develop', - environmentVariablesTitle: 'Environment Variables', - environmentVariablesHelp: - 'Custom environment variables injected into terminals and agents in this workspace.', - searchResultDescription: 'Project worktree and environment settings.', - environmentVariablesEmpty: 'No environment variables configured.', - environmentVariablesKeyPlaceholder: 'e.g. NODE_ENV', - environmentVariablesValuePlaceholder: 'e.g. development', - }, + workspace: enSettingsPanelWorkspace, } diff --git a/src/app/renderer/i18n/locales/en.settingsPanel.workspace.ts b/src/app/renderer/i18n/locales/en.settingsPanel.workspace.ts new file mode 100644 index 000000000..8b9d124e8 --- /dev/null +++ b/src/app/renderer/i18n/locales/en.settingsPanel.workspace.ts @@ -0,0 +1,26 @@ +export const enSettingsPanelWorkspace = { + title: 'Workspace Worktree', + selectProjectFirst: 'Select a project first', + selectProjectFirstHelp: 'Choose a project in the sidebar to configure its worktree root.', + workspacePathLabel: 'Workspace Path', + workspacePathHelp: 'Project root for {{name}}.', + worktreeRootLabel: 'Worktree Root', + worktreeRootHelp: 'Relative path is based on project root.', + worktreeRootPlaceholder: '.opencove/worktrees', + resolvedPathLabel: 'Resolved Path', + resolvedPathHelp: 'Where new worktrees will be created.', + pullRequestBaseBranchesLabel: 'Pull Request Base Branches', + pullRequestBaseBranchesHelp: + 'Suggestions shown when creating PRs on the canvas. The repository default branch is always included.', + pullRequestBaseBranchesEmpty: 'No base branches configured.', + pullRequestBaseBranchesPlaceholder: 'e.g. develop', + environmentVariablesTitle: 'Environment Variables', + environmentVariablesHelp: + 'Custom environment variables injected into terminals and agents in this workspace.', + searchResultDescription: 'Project worktree and environment settings.', + environmentVariablesEmpty: 'No environment variables configured.', + environmentVariablesKeyLabel: 'Environment variable name', + environmentVariablesValueLabel: 'Environment variable value', + environmentVariablesKeyPlaceholder: 'e.g. NODE_ENV', + environmentVariablesValuePlaceholder: 'e.g. development', +} diff --git a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.layout.ts b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.layout.ts index 75b955f58..6c9450341 100644 --- a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.layout.ts +++ b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.layout.ts @@ -20,6 +20,73 @@ export const zhCNSettingsPanelLayout = { diagnostics: '诊断', projects: '项目', sectionsLabel: '设置分区', + pageSelectorLabel: '设置页面', + expandProjects: '展开项目', + collapseProjects: '收起项目', + }, + navGroups: { + application: '应用', + workspace: '工作区', + connections: '连接', + advanced: '高级', + projects: '项目', + }, + pageDescriptions: { + general: '设置语言、更新和常用偏好。', + appearance: '调整主题、界面文字与终端字体。', + notifications: '管理系统通知与待命信息。', + canvasWindows: '调整画布操作与窗口行为。', + agent: '选择并配置 AI 助手。', + tasksShortcuts: '设置任务默认项、快捷操作与快捷键。', + worker: '配置 Worker 运行方式、访问与远程端点。', + integrations: '连接 OpenCove 与外部服务。', + advanced: '管理实验性功能与诊断选项。', + }, + groups: { + general: { + languageRegion: '语言与地区', + softwareUpdates: '软件更新', + }, + appearance: { + interface: '界面', + terminal: '终端', + }, + notifications: { + system: '通知方式', + standby: '待命横幅', + }, + canvasWindows: { + canvasInput: '画布输入', + nodeFocus: '节点定位', + spaceArchiving: 'Space 归档', + windowTerminal: '窗口与终端', + }, + agent: { + providers: 'Agent 提供方', + permissions: '权限', + }, + tasksShortcuts: { + taskTitles: '任务标题', + taskTags: '任务标签', + quickActions: '快捷操作', + keyboard: '键盘快捷键', + }, + worker: { + runtime: 'Worker 运行', + remote: '远程连接', + webAccess: '浏览器访问', + }, + integrations: { + github: 'GitHub', + }, + advanced: { + websiteWindows: '网页窗口', + diagnostics: '性能诊断', + }, + workspace: { + worktrees: 'Worktree', + environment: '运行环境', + }, }, appearance: { title: '显示与字体', diff --git a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts index ee37b1904..ba36fa566 100644 --- a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts +++ b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts @@ -2,6 +2,7 @@ import { zhCNTerminalDisplayCalibration } from './zh-CN.terminalDisplayCalibrati import { zhCNSettingsPanelDiagnostics } from './zh-CN.settingsPanel.diagnostics' import { zhCNSettingsPanelEndpoints } from './zh-CN.settingsPanel.endpoints' import { zhCNSettingsPanelLayout } from './zh-CN.settingsPanel.layout' +import { zhCNSettingsPanelWorkspace } from './zh-CN.settingsPanel.workspace' import { zhCNWorkspaceCanvasNavigationShortcutCommands } from './zh-CN.settingsPanel.shortcuts.workspaceCanvasNavigation' export const zhCNSettingsPanel = { @@ -12,6 +13,9 @@ export const zhCNSettingsPanel = { noResults: '未找到匹配的设置', }, nav: zhCNSettingsPanelLayout.nav, + navGroups: zhCNSettingsPanelLayout.navGroups, + pageDescriptions: zhCNSettingsPanelLayout.pageDescriptions, + groups: zhCNSettingsPanelLayout.groups, general: { title: '通用', languageLabel: '界面语言', @@ -95,6 +99,8 @@ export const zhCNSettingsPanel = { help: '启动 Agent 时新增或覆写环境变量。', add: '添加变量', enabled: '启用', + keyLabel: '变量名', + valueLabel: '变量值', keyPlaceholder: 'KEY', valuePlaceholder: 'value', }, @@ -227,6 +233,7 @@ export const zhCNSettingsPanel = { '开启后将显示远程 Worker 与远程位置相关的界面入口。关闭时默认保持本地工作流。', workerWebUi: { title: 'Worker Web UI', + securityTitle: '访问安全', help: '通过本机 Worker 提供的浏览器 UI(实验性)。', enabledLabel: '启用 Web UI', enabledHelp: '开启本机 Worker 同源托管的 Web UI 与 Debug Shell。修改后会重启本机 Worker。', @@ -413,13 +420,13 @@ export const zhCNSettingsPanel = { packagedHelp: 'OpenCove 会使用这台电脑上的本机 Worker。通常你只需要在下面启动或重启它。', packagedModeLabel: '当前模式', packagedModeValue: '本机 Worker', - modeLabel: 'Worker 模式', + modeLabel: '当前模式', mode: { standalone: 'Standalone(无 Worker)', local: '本机 Worker', remote: '远程 Worker', }, - applyLabel: '应用', + applyLabel: '模式变更', applyHelp: '变更需要重启后生效。', applyRestart: '应用并重启', }, @@ -433,25 +440,25 @@ export const zhCNSettingsPanel = { cli: { title: 'CLI', help: '将 `opencove` 命令安装到 PATH。', - statusLabel: '状态', + statusLabel: 'CLI 状态', status: { installed: '已安装({{path}})', needsRepair: '已安装,但命令入口需要修复({{path}})', notInstalled: '未安装', }, - actionsLabel: '操作', + actionsLabel: 'CLI 操作', install: '安装 CLI', uninstall: '卸载 CLI', }, local: { - title: '本机 Worker 状态', + title: '本机 Worker', help: '默认仅监听 127.0.0.1,并要求 token。远程访问建议使用 SSH tunnel。Worker Web UI 位于「Worker 与连接」。', - statusLabel: '状态', + statusLabel: 'Worker 状态', status: { running: '运行中', stopped: '已停止', }, - actionsLabel: '操作', + actionsLabel: 'Worker 控制', start: '启动', stop: '停止', baseUrlLabel: 'Base URL', @@ -463,26 +470,5 @@ export const zhCNSettingsPanel = { }, }, endpoints: zhCNSettingsPanelEndpoints, - workspace: { - title: '工作区 Worktree', - selectProjectFirst: '请先选择一个项目', - selectProjectFirstHelp: '在侧边栏选择项目后,再配置其 worktree 根目录。', - workspacePathLabel: '工作区路径', - workspacePathHelp: '{{name}} 的项目根目录。', - worktreeRootLabel: 'Worktree 根目录', - worktreeRootHelp: '相对路径以项目根目录为基准。', - worktreeRootPlaceholder: '.opencove/worktrees', - resolvedPathLabel: '解析后的路径', - resolvedPathHelp: '新 worktree 将会创建在这里。', - pullRequestBaseBranchesLabel: 'PR 目标分支候选', - pullRequestBaseBranchesHelp: '用于画布创建 PR 时的目标分支建议。默认分支会自动出现。', - pullRequestBaseBranchesEmpty: '暂无候选分支。', - pullRequestBaseBranchesPlaceholder: '例如 develop', - environmentVariablesTitle: '环境变量', - environmentVariablesHelp: '自定义环境变量将注入到该工作区下启动的所有终端和 Agent 中。', - searchResultDescription: '项目 worktree 与环境设置。', - environmentVariablesEmpty: '暂无环境变量。', - environmentVariablesKeyPlaceholder: '例如 NODE_ENV', - environmentVariablesValuePlaceholder: '例如 development', - }, + workspace: zhCNSettingsPanelWorkspace, } diff --git a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.workspace.ts b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.workspace.ts new file mode 100644 index 000000000..4050ce634 --- /dev/null +++ b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.workspace.ts @@ -0,0 +1,24 @@ +export const zhCNSettingsPanelWorkspace = { + title: '工作区 Worktree', + selectProjectFirst: '请先选择一个项目', + selectProjectFirstHelp: '在侧边栏选择项目后,再配置其 worktree 根目录。', + workspacePathLabel: '工作区路径', + workspacePathHelp: '{{name}} 的项目根目录。', + worktreeRootLabel: 'Worktree 根目录', + worktreeRootHelp: '相对路径以项目根目录为基准。', + worktreeRootPlaceholder: '.opencove/worktrees', + resolvedPathLabel: '解析后的路径', + resolvedPathHelp: '新 worktree 将会创建在这里。', + pullRequestBaseBranchesLabel: 'PR 目标分支候选', + pullRequestBaseBranchesHelp: '用于画布创建 PR 时的目标分支建议。默认分支会自动出现。', + pullRequestBaseBranchesEmpty: '暂无候选分支。', + pullRequestBaseBranchesPlaceholder: '例如 develop', + environmentVariablesTitle: '环境变量', + environmentVariablesHelp: '自定义环境变量将注入到该工作区下启动的所有终端和 Agent 中。', + searchResultDescription: '项目 worktree 与环境设置。', + environmentVariablesEmpty: '暂无环境变量。', + environmentVariablesKeyLabel: '环境变量名', + environmentVariablesValueLabel: '环境变量值', + environmentVariablesKeyPlaceholder: '例如 NODE_ENV', + environmentVariablesValuePlaceholder: '例如 development', +} diff --git a/src/app/renderer/styles.css b/src/app/renderer/styles.css index cb42eb3c0..407e7d2b8 100644 --- a/src/app/renderer/styles.css +++ b/src/app/renderer/styles.css @@ -40,6 +40,7 @@ @import './styles/settings-panel.lists.css'; @import './styles/settings-panel.buttons.css'; @import './styles/settings-panel.diagnostics.css'; +@import './styles/settings-panel.groups.css'; @import './styles/cove-window.css'; @import './styles/remote-ui.css'; @import './styles/remote-ui.settings.css'; diff --git a/src/app/renderer/styles/base.css b/src/app/renderer/styles/base.css index 6960cd34e..5dfab36ee 100644 --- a/src/app/renderer/styles/base.css +++ b/src/app/renderer/styles/base.css @@ -107,10 +107,32 @@ --cove-canvas-minimap-node-default: rgba(130, 156, 255, 0.78); --cove-canvas-minimap-node-stroke: rgba(0, 0, 0, 0.18); - --cove-settings-panel-background: - radial-gradient(circle at top right, rgba(94, 156, 255, 0.1), transparent 30%), - linear-gradient(180deg, #111318 0%, #0c0f14 100%); - --cove-settings-sidebar-background: rgba(8, 10, 14, 0.92); + --cove-settings-panel-background: color-mix( + in srgb, + var(--cove-window-surface) 92%, + var(--cove-surface-strong) + ); + --cove-settings-sidebar-background: color-mix( + in srgb, + var(--cove-window-surface) 86%, + var(--cove-surface) + ); + --cove-settings-header-background: color-mix( + in srgb, + var(--cove-settings-panel-background) 94%, + transparent + ); + --cove-settings-row-divider: color-mix(in srgb, var(--cove-border-subtle) 82%, transparent); + --cove-settings-group-surface: color-mix(in srgb, var(--cove-field) 48%, transparent); + --cove-settings-group-outline: color-mix(in srgb, var(--cove-border-subtle) 52%, transparent); + --cove-settings-active-surface: color-mix(in srgb, var(--cove-accent) 13%, transparent); + --cove-settings-active-surface-strong: color-mix(in srgb, var(--cove-accent) 19%, transparent); + --cove-settings-primary-background: color-mix(in srgb, var(--cove-accent) 78%, black); + --cove-settings-primary-background-hover: color-mix(in srgb, var(--cove-accent) 88%, black); + --cove-settings-on-accent: rgba(255, 255, 255, 0.96); + --cove-settings-focus-ring: color-mix(in srgb, var(--cove-accent) 58%, transparent); + --cove-settings-control-shadow: 0 8px 20px + color-mix(in srgb, var(--cove-shadow-color-elevated) 54%, transparent); } :root[data-cove-theme='light'] { @@ -213,11 +235,6 @@ --cove-canvas-minimap-node-image: rgba(255, 206, 120, 0.66); --cove-canvas-minimap-node-default: rgba(94, 156, 255, 0.55); --cove-canvas-minimap-node-stroke: rgba(17, 24, 39, 0.14); - - --cove-settings-panel-background: - radial-gradient(circle at top right, rgba(94, 156, 255, 0.14), transparent 32%), - linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(243, 246, 252, 0.94) 100%); - --cove-settings-sidebar-background: rgba(255, 255, 255, 0.78); } [data-cove-label-color='gray'] { diff --git a/src/app/renderer/styles/settings-panel.buttons.css b/src/app/renderer/styles/settings-panel.buttons.css index 3d78b31a6..eb4c34f8b 100644 --- a/src/app/renderer/styles/settings-panel.buttons.css +++ b/src/app/renderer/styles/settings-panel.buttons.css @@ -1,18 +1,23 @@ /* --- Settings Buttons --- */ .settings-panel button.primary { - background: linear-gradient(180deg, rgba(84, 126, 235, 0.92), rgba(60, 99, 201, 0.92)); - color: #eff6ff; - border: none; - padding: 8px 16px; - border-radius: 10px; + min-height: 34px; + padding: 7px 14px; + border: 1px solid color-mix(in srgb, var(--cove-accent) 68%, transparent); + border-radius: 9px; + background: var(--cove-settings-primary-background); + color: var(--cove-settings-on-accent); font-weight: 600; font-size: calc(13px * var(--cove-ui-font-scale)); cursor: pointer; - transition: all 0.2s; + transition: + background 150ms ease, + border-color 150ms ease, + transform 120ms ease; } .settings-panel button.primary:hover { - background: linear-gradient(180deg, rgba(103, 143, 245, 0.95), rgba(73, 113, 216, 0.95)); + border-color: var(--cove-accent); + background: var(--cove-settings-primary-background-hover); } .settings-panel button.primary:active { @@ -21,22 +26,106 @@ .settings-panel button.primary:disabled { background: var(--cove-border-subtle); + border-color: transparent; color: var(--cove-text-faint); cursor: not-allowed; } .settings-panel button.secondary { - background: transparent; + min-height: 32px; + padding: 6px 11px; border: 1px solid var(--cove-border); + border-radius: 9px; + background: transparent; color: var(--cove-text); - padding: 6px 12px; - border-radius: 10px; font-size: calc(12px * var(--cove-ui-font-scale)); cursor: pointer; - transition: all 0.2s; + transition: + color 150ms ease, + background 150ms ease, + border-color 150ms ease, + transform 120ms ease; } .settings-panel button.secondary:hover { background: var(--cove-surface-hover); border-color: var(--cove-border); } + +.settings-panel button.secondary:active:not(:disabled) { + transform: scale(0.98); +} + +.settings-panel button.primary:focus-visible, +.settings-panel button.secondary:focus-visible { + outline: 2px solid var(--cove-settings-focus-ring); + outline-offset: 2px; +} + +.settings-panel button.secondary:disabled { + color: var(--cove-text-faint); + cursor: not-allowed; + opacity: 0.55; +} + +/* --- Toggle Switch --- */ +.cove-toggle { + position: relative; + display: inline-flex; + width: 36px; + height: 20px; + flex: 0 0 auto; + cursor: pointer; +} + +.cove-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.cove-toggle__slider { + position: absolute; + inset: 0; + border: 1px solid var(--cove-border); + border-radius: 999px; + background: var(--cove-field); + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease; + pointer-events: none; +} + +.cove-toggle__slider::before { + position: absolute; + content: ''; + width: 14px; + height: 14px; + top: 2px; + left: 2px; + border-radius: 50%; + background: var(--cove-text-muted); + transition: + transform 160ms ease, + background 160ms ease; +} + +.cove-toggle input:checked + .cove-toggle__slider { + border-color: var(--cove-accent); + background: var(--cove-accent); +} + +.cove-toggle input:checked + .cove-toggle__slider::before { + transform: translateX(16px); + background: var(--cove-settings-on-accent); +} + +.cove-toggle input:focus-visible + .cove-toggle__slider { + box-shadow: 0 0 0 3px var(--cove-settings-focus-ring); +} + +.cove-toggle input:disabled + .cove-toggle__slider { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/src/app/renderer/styles/settings-panel.css b/src/app/renderer/styles/settings-panel.css index 6472c7002..a48b86fe8 100644 --- a/src/app/renderer/styles/settings-panel.css +++ b/src/app/renderer/styles/settings-panel.css @@ -2,9 +2,11 @@ position: fixed; inset: 0; background: var(--cove-backdrop); - backdrop-filter: blur(16px); + backdrop-filter: blur(14px); display: grid; place-items: center; + padding: 20px; + overflow: hidden; z-index: 100; transition: background 180ms ease, @@ -12,15 +14,17 @@ } .settings-panel { - width: min(980px, calc(100vw - 48px)); - height: min(760px, calc(100vh - 48px)); + width: min(1040px, calc(100vw - 40px)); + height: min(780px, calc(100vh - 40px)); + min-width: 0; + min-height: 0; background: var(--cove-settings-panel-background); border: 1px solid var(--cove-border-subtle); - border-radius: 18px; + border-radius: 20px; display: grid; - grid-template-columns: 208px minmax(0, 1fr); + grid-template-columns: 232px minmax(0, 1fr); overflow: hidden; - box-shadow: 0 40px 100px var(--cove-shadow-color-panel); + box-shadow: 0 32px 96px var(--cove-shadow-color-panel); transition: background 180ms ease, backdrop-filter 180ms ease, @@ -32,39 +36,80 @@ .settings-panel__content-wrapper { display: flex; flex-direction: column; + min-width: 0; min-height: 0; } .settings-panel__header { - padding: 26px 32px 20px; + min-height: 92px; + padding: 24px 34px 20px; display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; + gap: 24px; + background: var(--cove-settings-header-background); border-bottom: 1px solid var(--cove-border-subtle); } +.settings-panel__header-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 5px; +} + .settings-panel__header h2 { - font-size: calc(16px * var(--cove-ui-font-scale)); - font-weight: 600; - letter-spacing: -0.01em; + margin: 0; + overflow-wrap: anywhere; + font-size: calc(20px * var(--cove-ui-font-scale)); + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.025em; color: var(--cove-text); } +.settings-panel__header-description { + max-width: 560px; + overflow-wrap: anywhere; + color: var(--cove-text-muted); + font-size: calc(12px * var(--cove-ui-font-scale)); + line-height: 1.45; +} + .settings-panel__close { all: unset; + display: inline-grid; + width: 32px; + height: 32px; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; color: var(--cove-text-faint); cursor: pointer; font-size: calc(20px * var(--cove-ui-font-scale)); - transition: color 0.2s; + line-height: 1; + transition: + color 160ms ease, + background 160ms ease; } .settings-panel__close:hover { color: var(--cove-text); + background: var(--cove-surface-hover); +} + +.settings-panel__close:focus-visible { + outline: 2px solid var(--cove-settings-focus-ring); + outline-offset: 1px; } .settings-panel__content { - padding: 0 32px 32px; + min-width: 0; + min-height: 0; + padding: 0 34px 34px; overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; display: flex; flex-direction: column; gap: 0; @@ -73,9 +118,11 @@ .settings-panel__section { display: flex; flex-direction: column; - gap: 18px; - padding: 28px 0; - border-top: 1px solid var(--cove-border-subtle); + gap: 0; + min-width: 0; + padding: 26px 0 10px; + border-top: 1px solid var(--cove-settings-row-divider); + scroll-margin-top: 20px; } .settings-panel__content > .settings-panel__section:first-child { @@ -84,17 +131,19 @@ /* --- Typography --- */ .settings-panel__section-title { - font-size: calc(12px * var(--cove-ui-font-scale)); + margin: 0 0 8px; + font-size: calc(13px * var(--cove-ui-font-scale)); font-weight: 600; - letter-spacing: 0; - color: var(--cove-accent); - margin: 0; + line-height: 1.35; + letter-spacing: -0.005em; + color: var(--cove-text); } .settings-panel__section-help { max-width: 620px; + margin: -2px 0 10px; font-size: calc(12px * var(--cove-ui-font-scale)); - line-height: 1.45; + line-height: 1.5; color: var(--cove-text-muted); } @@ -102,12 +151,24 @@ .settings-panel__row { display: grid; grid-template-columns: minmax(0, 1fr); - align-items: start; + align-items: center; gap: 10px; + min-width: 0; +} + +.settings-panel__section > .settings-panel__row, +.settings-panel__subsection > .settings-panel__row { + padding: 16px 0; + border-top: 1px solid var(--cove-settings-row-divider); +} + +.settings-panel__section > .settings-panel__row:first-child { + border-top: none; } .settings-panel__row-label { display: flex; + min-width: 0; flex-direction: column; gap: 4px; } @@ -121,14 +182,15 @@ .settings-panel__row-label span { font-size: calc(12px * var(--cove-ui-font-scale)); color: var(--cove-text-muted); - line-height: 1.4; + line-height: 1.45; } /* --- Controls --- */ .settings-panel__control { display: flex; + min-width: 0; justify-content: flex-start; - align-items: flex-start; + align-items: center; width: min(100%, 360px); } @@ -253,14 +315,19 @@ .settings-panel__subsection { display: flex; flex-direction: column; - gap: 14px; - padding-top: 4px; + gap: 0; + min-width: 0; + margin-top: 8px; + padding-top: 20px; + border-top: 1px solid var(--cove-settings-row-divider); + scroll-margin-top: 20px; } .settings-panel__subsection-header { display: flex; flex-direction: column; gap: 4px; + margin-bottom: 10px; } .settings-panel__subsection-header strong { @@ -277,6 +344,7 @@ .settings-panel__input-row { display: flex; + min-width: 0; gap: 8px; width: min(100%, 420px); } @@ -285,56 +353,11 @@ flex: 1; } -/* --- Toggle Switch --- */ -.cove-toggle { - position: relative; - display: inline-block; - width: 32px; - height: 18px; - flex: 0 0 auto; -} - -.cove-toggle input { - opacity: 0; - width: 0; - height: 0; -} - -.cove-toggle__slider { - position: absolute; - inset: 0; - background-color: var(--cove-border); - transition: 0.2s; - border-radius: 20px; - pointer-events: none; -} - -.cove-toggle__slider:before { - position: absolute; - content: ''; - height: 14px; - width: 14px; - left: 2px; - bottom: 2px; - background-color: var(--cove-text-muted); - transition: 0.2s; - border-radius: 50%; -} - -.cove-toggle input:checked + .cove-toggle__slider { - background-color: var(--cove-accent); -} - -.cove-toggle input:checked + .cove-toggle__slider:before { - transform: translateX(14px); - background-color: #fff; -} - /* --- Vertical Section Layout --- */ .settings-panel__section--vertical { display: flex; flex-direction: column; - gap: 16px; + gap: 0; } .settings-panel__section--vertical .settings-panel__row { @@ -342,8 +365,6 @@ flex-direction: column; align-items: flex-start; gap: 8px; - border-bottom: none; - padding: 0; } /* Override for toggle rows that should stay horizontal */ @@ -396,11 +417,9 @@ width: 18px; height: 18px; border-radius: 999px; - background: color-mix(in srgb, var(--cove-accent) 92%, #fff); - border: 1px solid color-mix(in srgb, var(--cove-accent) 60%, transparent); - box-shadow: - 0 8px 18px rgba(0, 0, 0, 0.22), - 0 0 0 2px color-mix(in srgb, var(--cove-surface) 75%, transparent); + background: var(--cove-accent); + border: 1px solid color-mix(in srgb, var(--cove-accent) 70%, var(--cove-settings-on-accent)); + box-shadow: var(--cove-settings-control-shadow); margin-top: -6px; } @@ -410,7 +429,7 @@ .settings-panel input[type='range']:focus-visible::-webkit-slider-thumb { box-shadow: - 0 8px 18px rgba(0, 0, 0, 0.22), + var(--cove-settings-control-shadow), 0 0 0 3px color-mix(in srgb, var(--cove-accent) 35%, transparent); } @@ -431,44 +450,39 @@ width: 100%; } -@media (min-width: 900px) { - .settings-panel__row { - grid-template-columns: minmax(0, 220px) minmax(220px, 360px); - justify-content: space-between; - gap: 24px; - } +.settings-panel [data-settings-search-target='true'] { + border-radius: 10px; + animation: settings-panel-search-target 1.6s ease-out; } -@media (max-width: 760px) { - .settings-panel { - width: min(100vw - 24px, 980px); - height: min(100vh - 24px, 760px); - grid-template-columns: 1fr; +@keyframes settings-panel-search-target { + 0%, + 45% { + background: var(--cove-settings-active-surface-strong); + box-shadow: inset 0 0 0 1px var(--cove-settings-focus-ring); } - .settings-panel__sidebar { - border-right: none; - border-bottom: 1px solid var(--cove-border-subtle); - padding: 20px 12px 14px; - overflow-x: auto; - } - - .settings-panel__nav-group-label { - margin-top: 12px; + 100% { + background: transparent; + box-shadow: inset 0 0 0 1px transparent; } +} - .settings-panel__content { - padding: 0 20px 24px; +@media (min-width: 900px) { + .settings-panel__row { + grid-template-columns: minmax(0, 1fr) minmax(240px, 360px); + justify-content: space-between; + gap: 36px; } - .settings-panel__header { - padding: 20px 20px 16px; + .settings-panel__control { + justify-self: end; } +} - .settings-panel__control, - .settings-panel__path-chip, - .settings-panel__input-row { - width: 100%; - max-width: none; +@media (prefers-reduced-motion: reduce) { + .settings-panel [data-settings-search-target='true'] { + animation: none; + background: var(--cove-settings-active-surface); } } diff --git a/src/app/renderer/styles/settings-panel.groups.css b/src/app/renderer/styles/settings-panel.groups.css new file mode 100644 index 000000000..ad45ffe07 --- /dev/null +++ b/src/app/renderer/styles/settings-panel.groups.css @@ -0,0 +1,145 @@ +.settings-panel__group { + display: flex; + min-width: 0; + flex-direction: column; + padding-top: 26px; + scroll-margin-top: 20px; +} + +.settings-panel__group + .settings-panel__group { + padding-top: 28px; +} + +.settings-panel__group-header { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + margin-bottom: 9px; + padding-inline: 2px; +} + +.settings-panel__group-title { + margin: 0; + color: var(--cove-text); + font-size: calc(14px * var(--cove-ui-font-scale)); + font-weight: 620; + line-height: 1.35; + letter-spacing: -0.008em; +} + +.settings-panel__group-description { + max-width: 640px; + margin: 0; + color: var(--cove-text-muted); + font-size: calc(12px * var(--cove-ui-font-scale)); + line-height: 1.45; +} + +.settings-panel__group-body { + min-width: 0; + overflow: hidden; + padding-inline: 16px; + border-radius: 12px; + background: var(--cove-settings-group-surface); + box-shadow: inset 0 0 0 1px var(--cove-settings-group-outline); +} + +.settings-panel__group-body > .settings-panel__row, +.settings-panel__module-content > .settings-panel__row { + padding: 14px 0; + border-top: 1px solid var(--cove-settings-row-divider); +} + +.settings-panel__group-body > .settings-panel__row:first-child, +.settings-panel__module-content > .settings-panel__row:first-child { + border-top: none; +} + +.settings-panel__module { + min-width: 0; + padding: 15px 0 0; +} + +.settings-panel__module + .settings-panel__module { + margin-top: 1px; + border-top: 1px solid var(--cove-settings-row-divider); + padding-top: 16px; +} + +.settings-panel__group-body > .settings-panel__row + .settings-panel__module { + border-top: 1px solid var(--cove-settings-row-divider); + padding-top: 16px; +} + +.settings-panel__group > .settings-panel__module { + padding-top: 4px; +} + +.settings-panel__group > .settings-panel__module + .settings-panel__module { + margin-top: 20px; + padding-top: 20px; +} + +.settings-panel__module-header { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + padding-bottom: 5px; +} + +.settings-panel__module-title { + margin: 0; + color: var(--cove-text-muted); + font-size: calc(12px * var(--cove-ui-font-scale)); + font-weight: 620; + line-height: 1.4; + letter-spacing: -0.002em; +} + +.settings-panel__module-description { + max-width: 640px; + margin: 0; + color: var(--cove-text-muted); + font-size: calc(12px * var(--cove-ui-font-scale)); + line-height: 1.45; +} + +.settings-panel__module-content { + min-width: 0; +} + +.settings-panel__control--actions { + flex-wrap: wrap; + gap: 8px; +} + +.settings-panel__group .settings-panel__value { + overflow-wrap: anywhere; +} + +.settings-panel__group > .settings-panel__group-body + :not(.settings-panel__group-body) { + margin-top: 16px; +} + +@media (max-width: 760px) { + .settings-panel__group, + .settings-panel__group + .settings-panel__group { + padding-top: 22px; + } + + .settings-panel__group-header { + margin-bottom: 8px; + } + + .settings-panel__group-body { + padding-inline: 14px; + border-radius: 11px; + } + + .settings-panel__group-body > .settings-panel__row, + .settings-panel__module-content > .settings-panel__row { + padding: 13px 0; + } +} diff --git a/src/app/renderer/styles/settings-panel.lists.css b/src/app/renderer/styles/settings-panel.lists.css index bc18eb53f..232b86ed4 100644 --- a/src/app/renderer/styles/settings-panel.lists.css +++ b/src/app/renderer/styles/settings-panel.lists.css @@ -21,6 +21,66 @@ background: var(--cove-surface-hover); } +.settings-task-tags { + flex-flow: row wrap; + gap: 8px; +} + +.settings-task-tags .settings-list-item { + width: auto; + min-height: 34px; + gap: 5px; + padding: 4px 5px 4px 10px; + border-radius: 999px; +} + +.settings-task-tags .settings-list-item:hover { + background: var(--cove-field); +} + +.settings-panel button.settings-task-tags__remove { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--cove-text-muted); + cursor: pointer; + transition: + color 150ms ease, + background 150ms ease, + transform 120ms ease; +} + +.settings-panel button.settings-task-tags__remove:hover:not(:disabled) { + background: var(--cove-surface-hover); + color: var(--cove-text); +} + +.settings-panel button.settings-task-tags__remove:active:not(:disabled) { + transform: scale(0.92); +} + +.settings-panel button.settings-task-tags__remove:focus-visible { + outline: 2px solid var(--cove-settings-focus-ring); + outline-offset: 1px; +} + +.settings-panel button.settings-task-tags__remove:disabled { + color: var(--cove-text-faint); + cursor: not-allowed; + opacity: 0.45; +} + +.settings-task-tags__input { + width: min(100%, 420px); + margin-top: 10px; +} + .settings-list-item__left { display: flex; align-items: center; @@ -144,7 +204,7 @@ } .settings-agent-default-choice input:focus-visible + .settings-agent-default-choice__visual { - outline: 2px solid rgba(94, 156, 255, 0.85); + outline: 2px solid var(--cove-settings-focus-ring); outline-offset: 2px; } diff --git a/src/app/renderer/styles/settings-panel.preview.css b/src/app/renderer/styles/settings-panel.preview.css index d4040ccf4..d057faa27 100644 --- a/src/app/renderer/styles/settings-panel.preview.css +++ b/src/app/renderer/styles/settings-panel.preview.css @@ -24,41 +24,57 @@ pointer-events: none; } -.settings-panel--preview #settings-section-canvas .settings-panel__section-title { +.settings-panel--preview + .settings-panel__content + > .settings-panel__group:not(#settings-section-canvas-node-focus) { + visibility: hidden; + pointer-events: none; +} + +.settings-panel--preview #settings-section-canvas-node-focus .settings-panel__group-header { visibility: hidden; } .settings-panel--preview - #settings-section-canvas + #settings-section-canvas-node-focus .settings-panel__row:not(.settings-panel__row--focus-target-zoom) { visibility: hidden; pointer-events: none; } .settings-panel--preview - #settings-section-canvas + #settings-section-canvas-node-focus .settings-panel__row:not(.settings-panel__row--focus-target-zoom) * { transition: none; } -.settings-panel--preview #settings-section-canvas .settings-panel__row--focus-target-zoom { +.settings-panel--preview #settings-section-canvas-node-focus .settings-panel__group-body { + overflow: visible; + background: transparent; + box-shadow: none; +} + +.settings-panel--preview + #settings-section-canvas-node-focus + .settings-panel__row--focus-target-zoom { + border-top-color: transparent; pointer-events: auto; } .settings-panel--preview - #settings-section-canvas + #settings-section-canvas-node-focus .settings-panel__row--focus-target-zoom input[type='range'] { pointer-events: auto; } .settings-panel--preview - #settings-section-canvas + #settings-section-canvas-node-focus .settings-panel__row--focus-target-zoom .settings-panel__row-label, .settings-panel--preview - #settings-section-canvas + #settings-section-canvas-node-focus .settings-panel__row--focus-target-zoom .settings-panel__range-value { visibility: hidden; @@ -70,7 +86,7 @@ } .settings-panel--preview - #settings-section-canvas + #settings-section-canvas-node-focus .settings-panel__row--focus-target-zoom input[type='range'] { --cove-range-track-bg: color-mix(in srgb, var(--cove-border) 88%, transparent); diff --git a/src/app/renderer/styles/settings-panel.search.css b/src/app/renderer/styles/settings-panel.search.css index 94fe44356..a5a06be89 100644 --- a/src/app/renderer/styles/settings-panel.search.css +++ b/src/app/renderer/styles/settings-panel.search.css @@ -1,5 +1,5 @@ .settings-panel__search { - padding: 0 2px 12px; + padding: 0 0 16px; } .settings-panel__search-input-shell { @@ -7,10 +7,18 @@ } .settings-panel__search-input { + min-height: 36px; padding-left: 34px; + border-color: var(--cove-border-subtle); + background: color-mix(in srgb, var(--cove-field) 72%, transparent); font-size: calc(12px * var(--cove-ui-font-scale)); } +.settings-panel__search-input:focus-visible { + border-color: var(--cove-accent); + box-shadow: 0 0 0 2px var(--cove-settings-focus-ring); +} + .settings-panel__search-icon { position: absolute; top: 50%; @@ -18,7 +26,7 @@ transform: translateY(-50%); color: var(--cove-text-faint); pointer-events: none; - transition: color 0.18s ease; + transition: color 150ms ease; } .settings-panel__search-input-shell:focus-within .settings-panel__search-icon { @@ -27,32 +35,49 @@ .settings-panel__search-results { display: flex; + max-height: 312px; flex-direction: column; - gap: 4px; - padding-bottom: 10px; - margin-bottom: 8px; - border-bottom: 1px solid var(--cove-border-subtle); + gap: 3px; + margin: -6px 0 12px; + padding: 0 0 12px; + overflow-y: auto; + border-bottom: 1px solid var(--cove-settings-row-divider); } .settings-panel__search-result { all: unset; display: flex; + box-sizing: border-box; + min-width: 0; flex-direction: column; gap: 2px; padding: 8px 10px; - border-radius: 10px; + border-radius: 9px; cursor: pointer; - transition: all 0.2s; + transition: + color 150ms ease, + background 150ms ease; +} + +.settings-panel__search-result:hover, +.settings-panel__search-result--active, +.settings-panel__search-result[aria-selected='true'], +.settings-panel__search-result[data-active='true'] { + background: var(--cove-settings-active-surface); } -.settings-panel__search-result:hover { - background: var(--cove-surface-hover); +.settings-panel__search-result:focus-visible { + outline: 2px solid var(--cove-settings-focus-ring); + outline-offset: -1px; } .settings-panel__search-result-title { + overflow: hidden; color: var(--cove-text); font-size: calc(12px * var(--cove-ui-font-scale)); - font-weight: 500; + font-weight: 550; + text-overflow: ellipsis; + white-space: nowrap; } .settings-panel__search-result-page, @@ -63,4 +88,26 @@ .settings-panel__search-empty { padding: 8px 10px; + line-height: 1.45; +} + +@media (max-width: 760px) { + .settings-panel__search { + padding-bottom: 0; + } + + .settings-panel__search-results { + position: absolute; + z-index: 4; + top: 56px; + right: 16px; + left: 16px; + max-height: min(240px, calc(100vh - 168px)); + margin: 0; + padding: 6px; + border: 1px solid var(--cove-border); + border-radius: 12px; + background: var(--cove-settings-sidebar-background); + box-shadow: 0 18px 44px var(--cove-shadow-color-elevated); + } } diff --git a/src/app/renderer/styles/settings-panel.sidebar.css b/src/app/renderer/styles/settings-panel.sidebar.css index ff6568029..4dc0a3789 100644 --- a/src/app/renderer/styles/settings-panel.sidebar.css +++ b/src/app/renderer/styles/settings-panel.sidebar.css @@ -1,28 +1,62 @@ .settings-panel__sidebar { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 0; + padding: 22px 14px 16px; + overflow-y: auto; + scrollbar-gutter: stable; background: var(--cove-settings-sidebar-background); border-right: 1px solid var(--cove-border-subtle); - padding: 28px 12px; + transition: background 180ms ease; +} + +.settings-panel__nav-sections { display: flex; flex-direction: column; - gap: 6px; - overflow-y: auto; - min-height: 0; - transition: - background 180ms ease, - backdrop-filter 180ms ease; + gap: 18px; +} + +.settings-panel__nav-section, +.settings-panel__nav-group, +.settings-panel__projects-list { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.settings-panel__nav-section-label, +.settings-panel__nav-group-label { + margin: 0 0 5px; + padding: 0 10px; + color: var(--cove-text-faint); + font-size: calc(11px * var(--cove-ui-font-scale)); + font-weight: 600; + line-height: 1.4; + letter-spacing: 0.025em; } .settings-panel__nav-button { all: unset; - padding: 8px 14px; - border-radius: 8px; - font-size: calc(13px * var(--cove-ui-font-scale)); + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 36px; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: 9px; color: var(--cove-text-muted); cursor: pointer; + font-size: calc(13px * var(--cove-ui-font-scale)); + line-height: 1.2; transition: - background 0.18s ease, - color 0.18s ease; - position: relative; + color 150ms ease, + background 150ms ease; } .settings-panel__nav-button:hover { @@ -30,26 +64,31 @@ background: var(--cove-surface-hover); } -.settings-panel__nav-button--active { - color: var(--cove-text); - background: color-mix(in srgb, var(--cove-accent) 14%, transparent); - font-weight: 500; +.settings-panel__nav-button:focus-visible { + outline: 2px solid var(--cove-settings-focus-ring); + outline-offset: 1px; } -.settings-panel__nav-button--parent-active { - background: transparent; - color: var(--cove-text-muted); +.settings-panel__nav-button--active { + color: var(--cove-text); + background: var(--cove-settings-active-surface); + font-weight: 550; } .settings-panel__nav-button--active::before { content: ''; position: absolute; + top: 9px; + bottom: 9px; left: 0; - top: 8px; - bottom: 8px; width: 2px; - background: var(--cove-accent); border-radius: 0 2px 2px 0; + background: var(--cove-accent); +} + +.settings-panel__nav-button--parent-active { + color: var(--cove-text-muted); + background: transparent; } .settings-panel__nav-button--parent-active::before { @@ -57,29 +96,170 @@ } .settings-panel__nav-button--secondary { - margin-left: 10px; - padding-top: 6px; - padding-bottom: 6px; + min-height: 30px; + padding-left: 32px; + color: var(--cove-text-faint); font-size: calc(12px * var(--cove-ui-font-scale)); +} + +.settings-panel__nav-button-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; color: var(--cove-text-faint); + transition: color 150ms ease; } -.settings-panel__nav-button--secondary::before { - left: -6px; +.settings-panel__nav-button:hover .settings-panel__nav-button-icon, +.settings-panel__nav-button--active .settings-panel__nav-button-icon { + color: var(--cove-accent); } -.settings-panel__nav-group-label { - margin-top: 20px; - padding: 0 14px; - font-size: calc(11px * var(--cove-ui-font-scale)); - font-weight: 600; - color: var(--cove-text-faint); - text-transform: uppercase; - letter-spacing: 0.05em; +.settings-panel__nav-button-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.settings-panel__nav-group { +.settings-panel__projects { display: flex; flex-direction: column; gap: 4px; + margin-top: 0; + padding-top: 14px; + border-top: 1px solid var(--cove-settings-row-divider); +} + +.settings-panel__projects-toggle { + all: unset; + display: flex; + box-sizing: border-box; + min-height: 30px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + border-radius: 8px; + color: var(--cove-text-faint); + cursor: pointer; + font-size: calc(11px * var(--cove-ui-font-scale)); + font-weight: 600; + letter-spacing: 0.025em; +} + +.settings-panel__projects-toggle:hover { + color: var(--cove-text-muted); + background: var(--cove-surface-hover); +} + +.settings-panel__projects-toggle:focus-visible { + outline: 2px solid var(--cove-settings-focus-ring); + outline-offset: 1px; +} + +.settings-panel__projects-toggle svg { + width: 14px; + height: 14px; + transition: transform 150ms ease; +} + +.settings-panel__projects-toggle[aria-expanded='false'] svg { + transform: rotate(-90deg); +} + +.settings-panel__mobile-nav { + display: none; + min-width: 0; +} + +.settings-panel__mobile-nav > button, +.settings-panel__mobile-nav [data-testid='settings-panel-page-selector-trigger'] { + width: 100%; +} + +@media (max-width: 760px) { + .settings-backdrop { + padding: 8px; + } + + .settings-panel { + width: calc(100vw - 16px); + height: calc(100vh - 16px); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + border-radius: 16px; + } + + .settings-panel__sidebar { + position: relative; + z-index: 2; + gap: 10px; + padding: 14px 16px 13px; + overflow: visible; + scrollbar-gutter: auto; + border-right: none; + border-bottom: 1px solid var(--cove-border-subtle); + } + + .settings-panel__nav-sections, + .settings-panel__projects, + .settings-panel__sidebar > .settings-panel__nav-button, + .settings-panel__sidebar > .settings-panel__nav-group, + .settings-panel__sidebar > .settings-panel__nav-group-label { + display: none; + } + + .settings-panel__mobile-nav { + display: block; + } + + .settings-panel__content-wrapper { + min-width: 0; + min-height: 0; + } + + .settings-panel__header { + min-height: 78px; + padding: 17px 20px 15px; + } + + .settings-panel__header h2 { + font-size: calc(18px * var(--cove-ui-font-scale)); + } + + .settings-panel__header-description { + max-width: none; + } + + .settings-panel__content { + padding: 0 20px 24px; + } + + .settings-panel__section { + padding-top: 22px; + } + + .settings-panel__control, + .settings-panel__path-chip, + .settings-panel__input-row { + width: 100%; + max-width: none; + } + + .settings-panel__input-row { + flex-wrap: wrap; + } +} + +@media (max-width: 520px) { + .settings-backdrop { + padding: 0; + } + + .settings-panel { + width: 100vw; + height: 100vh; + border-radius: 0; + } } diff --git a/src/contexts/settings/presentation/renderer/SettingsPanel.tsx b/src/contexts/settings/presentation/renderer/SettingsPanel.tsx index 64472c836..507b11a78 100644 --- a/src/contexts/settings/presentation/renderer/SettingsPanel.tsx +++ b/src/contexts/settings/presentation/renderer/SettingsPanel.tsx @@ -13,6 +13,7 @@ import { GeneralSection } from './settingsPanel/GeneralSection' import { AppearanceSection } from './settingsPanel/AppearanceSection' import { IntegrationsSection } from './settingsPanel/IntegrationsSection' import { NotificationsSection } from './settingsPanel/NotificationsSection' +import { SettingsPanelHeader } from './settingsPanel/SettingsPanelHeader' import { SettingsPanelSidebar } from './settingsPanel/SettingsPanelSidebar' import { TasksAndShortcutsSection } from './settingsPanel/TasksAndShortcutsSection' import { AgentSettingsPage } from './settingsPanel/AgentSettingsPage' @@ -21,11 +22,14 @@ import { WorkspaceSection } from './settingsPanel/WorkspaceSection' import type { SettingsSearchResult } from './settingsPanel/settingsSearchIndex' import { createInitialInputState, + getFolderName, isWorkspacePageId, type SettingsPanelProps, } from './SettingsPanel.shared' import { useSettingsPanelPageState } from './useSettingsPanelPageState' import { createSettingsPanelUpdaters } from './useSettingsPanelUpdaters' +import { useSettingsPanelDialog } from './useSettingsPanelDialog' +import { resolveSettingsPage } from './settingsPanel/settingsPageRegistry' export function SettingsPanel({ initialPageId, @@ -46,17 +50,24 @@ export function SettingsPanel({ }: SettingsPanelProps): React.JSX.Element { const { t } = useTranslation() const { terminalProfiles, detectedDefaultTerminalProfileId } = useTerminalProfiles() + const panelRef = useRef(null) const contentRef = useRef(null) + const highlightedSearchTargetRef = useRef(null) + const searchHighlightTimeoutRef = useRef(null) const [addModelInputByProvider, setAddModelInputByProvider] = useState< Record >(() => createInitialInputState(AGENT_PROVIDERS)) const [addTaskTagInput, setAddTaskTagInput] = useState('') - const { activePageId, setActivePageId, activeWorkspace } = useSettingsPanelPageState({ - openPageId, - workspaces, - contentRef, - onFocusNodeTargetZoomPreviewChange, - }) + const { activePageId, canonicalPageId, setActivePageId, activeWorkspace } = + useSettingsPanelPageState({ + openPageId, + workspaces, + contentRef, + onFocusNodeTargetZoomPreviewChange, + }) + const resolvedActivePage = useMemo(() => resolveSettingsPage(activePageId), [activePageId]) + + useSettingsPanelDialog(panelRef) useEffect(() => { if (initialPageId) { @@ -211,30 +222,6 @@ export function SettingsPanel({ setActivePageId('worker') }, [activePageId, setActivePageId, settings.experimentalRemoteWorkersEnabled]) - const renderedPageId = (() => { - if (activePageId === 'canvas') { - return 'canvas-windows' - } - - if ( - activePageId === 'shortcuts' || - activePageId === 'quick-menu' || - activePageId === 'task-configuration' - ) { - return 'tasks-shortcuts' - } - - if (activePageId === 'experimental' || activePageId === 'diagnostics') { - return 'advanced' - } - - if (activePageId === 'endpoints') { - return 'worker' - } - - return activePageId - })() - const selectSearchResult = (result: SettingsSearchResult): void => { setActivePageId(result.pageId) @@ -246,17 +233,68 @@ export function SettingsPanel({ } target.scrollIntoView({ block: 'start' }) + highlightedSearchTargetRef.current?.removeAttribute('data-settings-search-target') + target.setAttribute('data-settings-search-target', 'true') + highlightedSearchTargetRef.current = target + + if (searchHighlightTimeoutRef.current !== null) { + window.clearTimeout(searchHighlightTimeoutRef.current) + } + searchHighlightTimeoutRef.current = window.setTimeout(() => { + target.removeAttribute('data-settings-search-target') + if (highlightedSearchTargetRef.current === target) { + highlightedSearchTargetRef.current = null + } + searchHighlightTimeoutRef.current = null + }, 1600) }) } + useEffect(() => { + return () => { + if (searchHighlightTimeoutRef.current !== null) { + window.clearTimeout(searchHighlightTimeoutRef.current) + } + highlightedSearchTargetRef.current?.removeAttribute('data-settings-search-target') + } + }, []) + + const pageTitle = activeWorkspace + ? activeWorkspace.name.trim() || getFolderName(activeWorkspace.path) + : t(resolvedActivePage.navLabelKey ?? 'settingsPanel.title') + const pageDescription = activeWorkspace + ? activeWorkspace.path + : resolvedActivePage.descriptionKey + ? t(resolvedActivePage.descriptionKey) + : '' + return (
e.stopPropagation()} + onKeyDown={event => { + const target = event.target + if ( + event.key !== 'Escape' || + event.defaultPrevented || + (target instanceof Element && target.closest('.cove-window')) + ) { + return + } + event.preventDefault() + onClose() + }} >
-
-

{t('settingsPanel.title')}

- -
+
- {renderedPageId === 'general' ? ( + {canonicalPageId === 'general' ? ( ) : null} - {renderedPageId === 'appearance' ? ( + {canonicalPageId === 'appearance' ? ( ) : null} - {renderedPageId === 'worker' ? ( + {canonicalPageId === 'worker' ? ( ) : null} - {renderedPageId === 'agent' ? ( + {canonicalPageId === 'agent' ? ( ) : null} - {renderedPageId === 'notifications' ? ( + {canonicalPageId === 'notifications' ? ( ) : null} - {renderedPageId === 'integrations' ? ( + {canonicalPageId === 'integrations' ? ( ) : null} - {renderedPageId === 'canvas-windows' ? ( + {canonicalPageId === 'canvas-windows' ? ( ) : null} - {renderedPageId === 'advanced' ? ( + {canonicalPageId === 'advanced' ? ( ) : null} - {renderedPageId === 'tasks-shortcuts' ? ( + {canonicalPageId === 'tasks-shortcuts' ? ( void onChangePerformanceMonitorHeaderButtonEnabled: (enabled: boolean) => void }): React.JSX.Element { - const { t } = useTranslation() - return ( <> -
-

{t('settingsPanel.nav.advanced')}

- {t('settingsPanel.experimental.help')} -
onToggleCustomModelEnabled(provider, event.target.checked)} /> @@ -182,6 +183,7 @@ export function AgentProviderConfigurePanel(props: { type="text" data-testid={`settings-custom-model-add-input-${provider}`} className="cove-field" + aria-label={`${AGENT_PROVIDER_LABEL[provider]} ${t('settingsPanel.models.addModelPlaceholder')}`} value={addModelInputValue} placeholder={t('settingsPanel.models.addModelPlaceholder')} onChange={event => onChangeAddModelInput(provider, event.target.value)} @@ -228,7 +230,7 @@ export function AgentProviderConfigurePanel(props: { type="checkbox" data-testid={`settings-agent-env-enabled-${provider}-${row.id}`} checked={row.enabled} - aria-label={t('settingsPanel.agentEnv.enabled')} + aria-label={`${AGENT_PROVIDER_LABEL[provider]} ${row.key.trim() || index + 1} ${t('settingsPanel.agentEnv.enabled')}`} onChange={event => { updateProviderRows( rows.map(existing => @@ -245,6 +247,7 @@ export function AgentProviderConfigurePanel(props: { { @@ -260,6 +263,7 @@ export function AgentProviderConfigurePanel(props: { { diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/AgentSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/AgentSection.tsx index 5a8e6c2a9..208eed3c2 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/AgentSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/AgentSection.tsx @@ -10,6 +10,7 @@ import { } from '@contexts/settings/domain/agentSettings' import type { AgentProviderAvailability } from '@shared/contracts/dto' import { AgentProviderConfigurePanel } from './AgentProviderConfigurePanel' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' interface ProviderModelCatalogEntry { models: string[] @@ -90,171 +91,180 @@ export function AgentSection(props: { } return ( -
-

{t('settingsPanel.agent.title')}

+ <> + +
+
+ {agentProviderOrder.map((provider, index) => { + const availability = availabilityByProvider[provider] + const isInstallingProvider = installingProvider === provider + const diagnostics = availability?.diagnostics?.join(' ') ?? '' + const installError = installErrorByProvider[provider] ?? '' + const isUnavailable = availability?.status === 'unavailable' + const isMisconfigured = availability?.status === 'misconfigured' + const isBusy = isRefreshingAvailability || Boolean(installingProvider) + const installLabel = resolveInstallActionLabel(availability, t, isInstallingProvider) + const actionStatus = isInstallingProvider + ? 'installing' + : (availability?.status ?? 'loading') + const modelSummary = resolveModelSummary(settings, provider, t) + const envSummary = resolveEnvSummary(settings, provider, t) + const isConfiguring = configuringProvider === provider -
-
- {t('settingsPanel.agent.agentListLabel')} - {t('settingsPanel.agent.agentProviderOrderHelp')} -
- -
- {agentProviderOrder.map((provider, index) => { - const availability = availabilityByProvider[provider] - const isInstallingProvider = installingProvider === provider - const diagnostics = availability?.diagnostics?.join(' ') ?? '' - const installError = installErrorByProvider[provider] ?? '' - const isUnavailable = availability?.status === 'unavailable' - const isMisconfigured = availability?.status === 'misconfigured' - const isBusy = isRefreshingAvailability || Boolean(installingProvider) - const installLabel = resolveInstallActionLabel(availability, t, isInstallingProvider) - const actionStatus = isInstallingProvider - ? 'installing' - : (availability?.status ?? 'loading') - const modelSummary = resolveModelSummary(settings, provider, t) - const envSummary = resolveEnvSummary(settings, provider, t) - const isConfiguring = configuringProvider === provider - - return ( -
-
-
- - -
-
- - {installError.length > 0 ? ( -
- {installError} -
- ) : null} - {isMisconfigured && diagnostics.length > 0 ? ( -
- {diagnostics} -
- ) : null} - {isConfiguring ? ( - setConfiguringProvider(null)} - /> - ) : null} -
- ) - })} + ) + })} +
-
+ -
-
- {t('settingsPanel.agent.fullAccessLabel')} - {t('settingsPanel.agent.fullAccessHelp')} -
-
- -
-
-
+ + +
+
+ {t('settingsPanel.agent.fullAccessLabel')} + {t('settingsPanel.agent.fullAccessHelp')} +
+
+ +
+
+
+
+ ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/AppearanceSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/AppearanceSection.tsx index b3a17c5af..53195b610 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/AppearanceSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/AppearanceSection.tsx @@ -13,6 +13,7 @@ import { } from '@contexts/settings/domain/agentSettings' import { TerminalDisplayCalibrationRow } from './TerminalDisplayCalibrationRow' import { TerminalFontFamilyRow } from './TerminalFontFamilyRow' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function AppearanceSection({ uiTheme, @@ -48,101 +49,111 @@ export function AppearanceSection({ const { t } = useTranslation() return ( -
-

{t('settingsPanel.appearance.title')}

- {t('settingsPanel.appearance.help')} - -
-
- {t('settingsPanel.general.uiThemeLabel')} - {t('settingsPanel.general.uiThemeHelp')} -
-
- ({ - value: theme, - label: getUiThemeLabel(t, theme), - }))} - onChange={nextValue => onChangeUiTheme(nextValue as UiTheme)} - /> -
-
- -
-
- {t('settingsPanel.general.interfaceFontSize')} - {t('settingsPanel.appearance.interfaceFontSizeHelp')} -
-
- onChangeUiFontSize(Number(event.target.value))} - /> - - {t('common.pixelUnit')} - -
-
- -
-
- {t('settingsPanel.appearance.terminalTitle')} - {t('settingsPanel.appearance.terminalHelp')} -
+ <> + + +
+
+ {t('settingsPanel.general.uiThemeLabel')} + {t('settingsPanel.general.uiThemeHelp')} +
+
+ ({ + value: theme, + label: getUiThemeLabel(t, theme), + }))} + onChange={nextValue => onChangeUiTheme(nextValue as UiTheme)} + /> +
+
-
-
- {t('settingsPanel.general.terminalFontSize')} - {t('settingsPanel.appearance.terminalFontSizeHelp')} +
+
+ {t('settingsPanel.general.interfaceFontSize')} + {t('settingsPanel.appearance.interfaceFontSizeHelp')} +
+
+ onChangeUiFontSize(Number(event.target.value))} + /> + + {t('common.pixelUnit')} + +
-
- onChangeTerminalFontSize(Number(event.target.value))} - /> - - {t('common.pixelUnit')} - + + + + + +
+
+ {t('settingsPanel.general.terminalFontSize')} + {t('settingsPanel.appearance.terminalFontSizeHelp')} +
+
+ onChangeTerminalFontSize(Number(event.target.value))} + /> + + {t('common.pixelUnit')} + +
-
- + - -
-
+ +
+
+ ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/CanvasSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/CanvasSection.tsx index 1f8722b61..00ec19584 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/CanvasSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/CanvasSection.tsx @@ -11,22 +11,19 @@ import { type CanvasWheelBehavior, type CanvasWheelZoomModifier, type FocusNodeTargetZoom, - STANDARD_WINDOW_SIZE_BUCKETS, - type StandardWindowSizeBucket, } from '@contexts/settings/domain/agentSettings' import { getCanvasInputModeLabel, getCanvasWheelBehaviorLabel, getCanvasWheelZoomModifierLabel, - getStandardWindowSizeBucketLabel, } from '@app/renderer/i18n/labels' import { CoveSelect } from '@app/renderer/components/CoveSelect' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function CanvasSection(props: { canvasInputMode: CanvasInputMode canvasWheelBehavior: CanvasWheelBehavior canvasWheelZoomModifier: CanvasWheelZoomModifier - standardWindowSizeBucket: StandardWindowSizeBucket focusNodeOnClick: boolean focusNodeTargetZoom: FocusNodeTargetZoom focusNodeUseVisibleCanvasCenter: boolean @@ -35,7 +32,6 @@ export function CanvasSection(props: { onChangeCanvasInputMode: (mode: CanvasInputMode) => void onChangeCanvasWheelBehavior: (behavior: CanvasWheelBehavior) => void onChangeCanvasWheelZoomModifier: (modifier: CanvasWheelZoomModifier) => void - onChangeStandardWindowSizeBucket: (bucket: StandardWindowSizeBucket) => void onChangeFocusNodeOnClick: (enabled: boolean) => void onChangeFocusNodeTargetZoom: (zoom: FocusNodeTargetZoom) => void onChangeFocusNodeUseVisibleCanvasCenter: (enabled: boolean) => void @@ -48,7 +44,6 @@ export function CanvasSection(props: { canvasInputMode, canvasWheelBehavior, canvasWheelZoomModifier, - standardWindowSizeBucket, focusNodeOnClick, focusNodeTargetZoom, focusNodeUseVisibleCanvasCenter, @@ -57,7 +52,6 @@ export function CanvasSection(props: { onChangeCanvasInputMode, onChangeCanvasWheelBehavior, onChangeCanvasWheelZoomModifier, - onChangeStandardWindowSizeBucket, onChangeFocusNodeOnClick, onChangeFocusNodeTargetZoom, onChangeFocusNodeUseVisibleCanvasCenter, @@ -91,206 +85,212 @@ export function CanvasSection(props: { '--settings-panel-range-neutral-ratio': neutralTargetZoomRatio, } return ( -
-

{t('settingsPanel.canvas.title')}

- -
-
- {t('settingsPanel.canvas.inputModeLabel')} - {t('settingsPanel.canvas.inputModeHelp')} -
-
- ({ - value: mode, - label: getCanvasInputModeLabel(t, mode), - }))} - onChange={nextValue => onChangeCanvasInputMode(nextValue as CanvasInputMode)} - /> -
-
- -
-
- {t('settingsPanel.canvas.wheelBehaviorLabel')} - {t('settingsPanel.canvas.wheelBehaviorHelp')} -
-
- ({ - value: behavior, - label: getCanvasWheelBehaviorLabel(t, behavior), - }))} - onChange={nextValue => onChangeCanvasWheelBehavior(nextValue as CanvasWheelBehavior)} - /> -
-
- - {canvasWheelBehavior === 'pan' ? ( -
-
- {t('settingsPanel.canvas.wheelZoomModifierLabel')} - - {t('settingsPanel.canvas.wheelZoomModifierHelp', { - modifier: wheelZoomModifierHelpLabel, - })} - -
-
- - modifier === 'ctrl' ? isMac : true, - ).map(modifier => ({ - value: modifier, - label: getCanvasWheelZoomModifierLabel(t, modifier, platform), - }))} - onChange={nextValue => - onChangeCanvasWheelZoomModifier(nextValue as CanvasWheelZoomModifier) - } - /> + <> + + +
+
+ {t('settingsPanel.canvas.inputModeLabel')} + {t('settingsPanel.canvas.inputModeHelp')} +
+
+ ({ + value: mode, + label: getCanvasInputModeLabel(t, mode), + }))} + onChange={nextValue => onChangeCanvasInputMode(nextValue as CanvasInputMode)} + /> +
-
- ) : null} -
-
- {t('settingsPanel.canvas.standardWindowSizeLabel')} - {t('settingsPanel.canvas.standardWindowSizeHelp')} -
-
- ({ - value: bucket, - label: getStandardWindowSizeBucketLabel(t, bucket), - }))} - onChange={nextValue => - onChangeStandardWindowSizeBucket(nextValue as StandardWindowSizeBucket) - } - /> -
-
+
+
+ {t('settingsPanel.canvas.wheelBehaviorLabel')} + {t('settingsPanel.canvas.wheelBehaviorHelp')} +
+
+ ({ + value: behavior, + label: getCanvasWheelBehaviorLabel(t, behavior), + }))} + onChange={nextValue => + onChangeCanvasWheelBehavior(nextValue as CanvasWheelBehavior) + } + /> +
+
-
-
- {t('settingsPanel.canvas.focusOnClickLabel')} - {t('settingsPanel.canvas.focusOnClickHelp')} -
-
- -
-
+ {canvasWheelBehavior === 'pan' ? ( +
+
+ {t('settingsPanel.canvas.wheelZoomModifierLabel')} + + {t('settingsPanel.canvas.wheelZoomModifierHelp', { + modifier: wheelZoomModifierHelpLabel, + })} + +
+
+ + modifier === 'ctrl' ? isMac : true, + ).map(modifier => ({ + value: modifier, + label: getCanvasWheelZoomModifierLabel(t, modifier, platform), + }))} + onChange={nextValue => + onChangeCanvasWheelZoomModifier(nextValue as CanvasWheelZoomModifier) + } + /> +
+
+ ) : null} + + -
-
- {t('settingsPanel.canvas.focusVisibleCenterLabel')} - {t('settingsPanel.canvas.focusVisibleCenterHelp')} -
-
- -
-
+ + +
+
+ {t('settingsPanel.canvas.focusOnClickLabel')} + {t('settingsPanel.canvas.focusOnClickHelp')} +
+
+ +
+
-
-
- {t('settingsPanel.canvas.focusTargetZoomLabel')} - {t('settingsPanel.canvas.focusTargetZoomHelp')} -
-
-
- onFocusNodeTargetZoomPreviewChange(true)} - onPointerUp={() => onFocusNodeTargetZoomPreviewChange(false)} - onPointerCancel={() => onFocusNodeTargetZoomPreviewChange(false)} - onBlur={() => onFocusNodeTargetZoomPreviewChange(false)} - onChange={event => onChangeFocusNodeTargetZoom(Number(event.target.value))} - /> +
+
+ {t('settingsPanel.canvas.focusVisibleCenterLabel')} + {t('settingsPanel.canvas.focusVisibleCenterHelp')} +
+
+ +
-
-
-
-
- {t('settingsPanel.canvas.archiveSpaceDefaultsTitle')} - {t('settingsPanel.canvas.archiveSpaceDefaultsHelp')} -
+
+
+ {t('settingsPanel.canvas.focusTargetZoomLabel')} + {t('settingsPanel.canvas.focusTargetZoomHelp')} +
+
+
+ onFocusNodeTargetZoomPreviewChange(true)} + onPointerUp={() => onFocusNodeTargetZoomPreviewChange(false)} + onPointerCancel={() => onFocusNodeTargetZoomPreviewChange(false)} + onBlur={() => onFocusNodeTargetZoomPreviewChange(false)} + onChange={event => onChangeFocusNodeTargetZoom(Number(event.target.value))} + /> +
+
+
+ + -
+ +
-
- {t('settingsPanel.canvas.archiveSpaceDeleteWorktreeDefaultLabel')} +
+ {t('settingsPanel.canvas.archiveSpaceDeleteWorktreeDefaultLabel')} +
+
+
-
-
- {t('settingsPanel.canvas.archiveSpaceDeleteBranchDefaultLabel')} +
+ {t('settingsPanel.canvas.archiveSpaceDeleteBranchDefaultLabel')} +
+
+
-
-
-
-
+ + + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/CanvasWindowsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/CanvasWindowsSection.tsx index 7fc0f1a8b..2a75be85f 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/CanvasWindowsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/CanvasWindowsSection.tsx @@ -65,7 +65,6 @@ export function CanvasWindowsSection({ canvasInputMode={canvasInputMode} canvasWheelBehavior={canvasWheelBehavior} canvasWheelZoomModifier={canvasWheelZoomModifier} - standardWindowSizeBucket={standardWindowSizeBucket} focusNodeOnClick={focusNodeOnClick} focusNodeTargetZoom={focusNodeTargetZoom} focusNodeUseVisibleCanvasCenter={focusNodeUseVisibleCanvasCenter} @@ -74,7 +73,6 @@ export function CanvasWindowsSection({ onChangeCanvasInputMode={onChangeCanvasInputMode} onChangeCanvasWheelBehavior={onChangeCanvasWheelBehavior} onChangeCanvasWheelZoomModifier={onChangeCanvasWheelZoomModifier} - onChangeStandardWindowSizeBucket={onChangeStandardWindowSizeBucket} onChangeFocusNodeOnClick={onChangeFocusNodeOnClick} onChangeFocusNodeTargetZoom={onChangeFocusNodeTargetZoom} onChangeFocusNodeUseVisibleCanvasCenter={onChangeFocusNodeUseVisibleCanvasCenter} @@ -83,9 +81,11 @@ export function CanvasWindowsSection({ onFocusNodeTargetZoomPreviewChange={onFocusNodeTargetZoomPreviewChange} /> diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/DiagnosticsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/DiagnosticsSection.tsx index 2e7fcc951..bda6b82b3 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/DiagnosticsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/DiagnosticsSection.tsx @@ -18,6 +18,7 @@ import { useRendererMemoryTrend, } from '@app/renderer/performanceDiagnostics/rendererDiagnosticsSampling' import type { PerformanceDiagnosticsProcessSummary } from '@shared/contracts/dto' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function DiagnosticsSection({ headerButtonEnabled, @@ -77,29 +78,31 @@ export function DiagnosticsSection({ } return ( -
-
-

{t('settingsPanel.diagnostics.title')}

- {t('settingsPanel.diagnostics.help')} -
- -
-
- {t('settingsPanel.diagnostics.headerMonitor.label')} - {t('settingsPanel.diagnostics.headerMonitor.help')} -
-
- + + +
+
+ {t('settingsPanel.diagnostics.headerMonitor.label')} + {t('settingsPanel.diagnostics.headerMonitor.help')} +
+
+ +
-
+
+ ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/EndpointsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/EndpointsSection.tsx index 9e6e18021..ed443d04c 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/EndpointsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/EndpointsSection.tsx @@ -7,6 +7,7 @@ import { getEndpointActionExecution } from '@app/renderer/shell/utils/endpointOv import { notifyTopologyChanged } from '@app/renderer/shell/utils/topologyEvents' import { EndpointsRegisterDialog } from './EndpointsRegisterDialog' import { toErrorMessage } from './workerSectionUtils' +import { SettingsGroup, SettingsGroupBody, SettingsModule } from './SettingsGroup' type RegisterMode = 'managed' | 'manual' @@ -197,30 +198,27 @@ export function EndpointsSection(): React.JSX.Element { } return ( -
-

{t('settingsPanel.endpoints.title')}

- + {error ? ( -
-
- {t('common.error')} -
-
- - {error} - + +
+
+ {t('common.error')} +
+
+ + {error} + +
-
+ ) : null} -
-
-

- {t('settingsPanel.endpoints.list.title')} -

- {t('settingsPanel.endpoints.list.help')} -
- +
@@ -306,7 +304,7 @@ export function EndpointsSection(): React.JSX.Element { })}
)} -
+
-
+ ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalSection.tsx index 76652f2f5..4449e504d 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalSection.tsx @@ -7,6 +7,7 @@ import { type BrowserSearchEngineId, } from '@contexts/settings/domain/browserSettings' import type { BrowserMode } from '@shared/contracts/dto' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function ExperimentalSection({ websiteWindowPolicy, @@ -67,17 +68,12 @@ export function ExperimentalSection({ }, [keepAliveHostDraft, updateWebsiteWindowPolicy, websiteWindowPolicy.keepAliveHosts]) return ( -
-

{t('settingsPanel.experimental.title')}

- -
-
-

- {t('settingsPanel.experimental.websiteWindowsTitle')} -

- {t('settingsPanel.experimental.websiteWindowsHelp')} -
- + +
{t('settingsPanel.experimental.websiteWindowEnabledLabel')} @@ -89,6 +85,7 @@ export function ExperimentalSection({ type="checkbox" data-testid="settings-experimental-website-window-enabled" checked={websiteWindowPolicy.enabled} + aria-label={t('settingsPanel.experimental.websiteWindowEnabledLabel')} onChange={event => updateWebsiteWindowPolicy({ enabled: event.target.checked, @@ -109,6 +106,7 @@ export function ExperimentalSection({ ({ @@ -160,6 +159,7 @@ export function ExperimentalSection({ data-testid="settings-experimental-website-window-paste" checked={websiteWindowPasteEnabled} disabled={!websiteWindowPolicy.enabled} + aria-label={t('settingsPanel.experimental.websiteWindowPasteLabel')} onChange={event => onChangeWebsiteWindowPasteEnabled(event.target.checked)} /> @@ -179,6 +179,7 @@ export function ExperimentalSection({ className="cove-field" style={{ width: '80px' }} type="number" + aria-label={t('settingsPanel.experimental.websiteWindowMaxActiveLabel')} min={1} max={6} value={websiteWindowPolicy.maxActiveCount} @@ -206,6 +207,7 @@ export function ExperimentalSection({ className="cove-field" style={{ width: '80px' }} type="number" + aria-label={t('settingsPanel.experimental.websiteWindowDiscardAfterLabel')} min={1} max={240} value={websiteWindowPolicy.discardAfterMinutes} @@ -255,6 +257,7 @@ export function ExperimentalSection({
-
-
+ + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalWorkerWebUiSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalWorkerWebUiSection.tsx index 1cf882681..bd82c5eaf 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalWorkerWebUiSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalWorkerWebUiSection.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from '@app/renderer/i18n' import type { HomeWorkerConfigDto, WorkerStatusResult } from '@shared/contracts/dto' import { toErrorMessage } from './workerSectionUtils' +import { SettingsGroup, SettingsGroupBody, SettingsModule } from './SettingsGroup' export function ExperimentalWorkerWebUiSection(): React.JSX.Element { const { t } = useTranslation() @@ -257,178 +258,191 @@ export function ExperimentalWorkerWebUiSection(): React.JSX.Element { ) return ( -
-
-

- {t('settingsPanel.experimental.workerWebUi.title')} -

- {t('settingsPanel.experimental.workerWebUi.help')} -
- - {workerWebUiError ? ( -
-
- {t('common.error')} + + + {workerWebUiError ? ( +
+
+ {t('common.error')} +
+
+ + {workerWebUiError} + +
-
- - {workerWebUiError} - + ) : null} + + +
+
+ {t('settingsPanel.experimental.workerWebUi.enabledLabel')} + {t('settingsPanel.experimental.workerWebUi.enabledHelp')} +
+
+ +
-
- ) : null} - -
-
- {t('settingsPanel.experimental.workerWebUi.enabledLabel')} - {t('settingsPanel.experimental.workerWebUi.enabledHelp')} -
-
- -
-
- -
-
- {t('settingsPanel.experimental.workerWebUi.portLabel')} - {t('settingsPanel.experimental.workerWebUi.portHelp')} -
-
- setWebUiPortDraft(event.target.value)} - data-testid="settings-experimental-worker-web-ui-port" - /> - -
-
- -
-
- {t('settingsPanel.experimental.workerWebUi.statusLabel')} - {t('settingsPanel.experimental.workerWebUi.statusHelp')} -
-
- - {workerWebUiStatusLabel} - - -
-
- -
-
- {t('settingsPanel.experimental.workerWebUi.actionsLabel')} -
-
- -
-
- -
-
- {t('settingsPanel.experimental.workerWebUi.lanLabel')} - {t('settingsPanel.experimental.workerWebUi.lanHelp')} -
-
- -
-
- -
-
- {t('settingsPanel.experimental.workerWebUi.passwordLabel')} - - {workerConfig?.webUi.passwordSet - ? t('settingsPanel.experimental.workerWebUi.passwordHelpSet') - : t('settingsPanel.experimental.workerWebUi.passwordHelpUnset')} - -
-
- setWebUiPasswordDraft(event.target.value)} - data-testid="settings-experimental-worker-web-ui-password" - /> - - -
-
-
+ +
+
+ {t('settingsPanel.experimental.workerWebUi.portLabel')} + {t('settingsPanel.experimental.workerWebUi.portHelp')} +
+
+ setWebUiPortDraft(event.target.value)} + data-testid="settings-experimental-worker-web-ui-port" + /> + +
+
+ +
+
+ {t('settingsPanel.experimental.workerWebUi.statusLabel')} + {t('settingsPanel.experimental.workerWebUi.statusHelp')} +
+
+ + {workerWebUiStatusLabel} + + +
+
+ +
+
+ {t('settingsPanel.experimental.workerWebUi.actionsLabel')} +
+
+ +
+
+ + + +
+
+ {t('settingsPanel.experimental.workerWebUi.lanLabel')} + {t('settingsPanel.experimental.workerWebUi.lanHelp')} +
+
+ +
+
+ +
+
+ {t('settingsPanel.experimental.workerWebUi.passwordLabel')} + + {workerConfig?.webUi.passwordSet + ? t('settingsPanel.experimental.workerWebUi.passwordHelpSet') + : t('settingsPanel.experimental.workerWebUi.passwordHelpUnset')} + +
+
+ setWebUiPasswordDraft(event.target.value)} + data-testid="settings-experimental-worker-web-ui-password" + /> + + +
+
+
+ + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection.tsx index 9ee7bbb0e..8623a84a1 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection.tsx @@ -9,6 +9,7 @@ import { import { CoveSelect } from '@app/renderer/components/CoveSelect' import type { AppUpdateChannel, AppUpdatePolicy, AppUpdateState } from '@shared/contracts/dto' import { APP_UPDATE_CHANNELS, APP_UPDATE_POLICIES } from '@shared/contracts/dto' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' function getUpdateStatusText( t: ReturnType['t'], @@ -80,140 +81,147 @@ export function GeneralSection(props: { } = props return ( -
-

{t('settingsPanel.general.title')}

- -
-
- {t('settingsPanel.general.languageLabel')} - {t('settingsPanel.general.languageHelp')} -
-
- ({ - value: option, - label: getUiLanguageLabel(option), - }))} - onChange={nextValue => { - onChangeLanguage(nextValue as UiLanguage) - }} - /> -
-
- -
-
-

- {t('settingsPanel.general.updates.title')} -

- {t('settingsPanel.general.updates.help')} -
- -
-
- {t('settingsPanel.general.updates.currentVersionLabel')} + <> + + +
+
+ {t('settingsPanel.general.languageLabel')} + {t('settingsPanel.general.languageHelp')} +
+
+ ({ + value: option, + label: getUiLanguageLabel(option), + }))} + onChange={nextValue => { + onChangeLanguage(nextValue as UiLanguage) + }} + /> +
-
- {updateState?.currentVersion ?? '—'} -
-
+ + -
-
- {t('settingsPanel.general.updates.policyLabel')} - {t('settingsPanel.general.updates.policyHelp')} -
-
- policy !== 'auto') - : APP_UPDATE_POLICIES - ).map(policy => ({ - value: policy, - label: getAppUpdatePolicyLabel(t, policy), - }))} - onChange={nextValue => onChangeUpdatePolicy(nextValue as AppUpdatePolicy)} - /> + + +
+
+ {t('settingsPanel.general.updates.currentVersionLabel')} +
+
+ {updateState?.currentVersion ?? '—'} +
-
-
-
- {t('settingsPanel.general.updates.channelLabel')} - {t('settingsPanel.general.updates.channelHelp')} -
-
- ({ - value: channel, - label: getAppUpdateChannelLabel(t, channel), - }))} - onChange={nextValue => onChangeUpdateChannel(nextValue as AppUpdateChannel)} - /> +
+
+ {t('settingsPanel.general.updates.policyLabel')} + {t('settingsPanel.general.updates.policyHelp')} +
+
+ policy !== 'auto') + : APP_UPDATE_POLICIES + ).map(policy => ({ + value: policy, + label: getAppUpdatePolicyLabel(t, policy), + }))} + onChange={nextValue => onChangeUpdatePolicy(nextValue as AppUpdatePolicy)} + /> +
-
-
-
- {t('settingsPanel.general.updates.statusLabel')} +
+
+ {t('settingsPanel.general.updates.channelLabel')} + {t('settingsPanel.general.updates.channelHelp')} +
+
+ ({ + value: channel, + label: getAppUpdateChannelLabel(t, channel), + }))} + onChange={nextValue => onChangeUpdateChannel(nextValue as AppUpdateChannel)} + /> +
-
- - {getUpdateStatusText(t, updateState)} - -
-
-
-
- {t('settingsPanel.general.updates.actionsLabel')} +
+
+ {t('settingsPanel.general.updates.statusLabel')} +
+
+ + {getUpdateStatusText(t, updateState)} + +
-
- - {updateState?.status === 'available' ? ( - - ) : null} - {updateState?.status === 'downloaded' ? ( + +
+
+ {t('settingsPanel.general.updates.actionsLabel')} +
+
- ) : null} + {updateState?.status === 'available' ? ( + + ) : null} + {updateState?.status === 'downloaded' ? ( + + ) : null} +
-
-
-
+ + + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/IntegrationsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/IntegrationsSection.tsx index 79036ad51..53327cc0c 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/IntegrationsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/IntegrationsSection.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useTranslation } from '@app/renderer/i18n' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function IntegrationsSection(props: { githubPullRequestsEnabled: boolean @@ -9,26 +10,30 @@ export function IntegrationsSection(props: { const { t } = useTranslation() return ( -
-

{t('settingsPanel.integrations.title')}

- -
-
- {t('settingsPanel.integrations.githubPullRequestsLabel')} - {t('settingsPanel.integrations.githubPullRequestsHelp')} -
-
- + + +
+
+ {t('settingsPanel.integrations.githubPullRequestsLabel')} + {t('settingsPanel.integrations.githubPullRequestsHelp')} +
+
+ +
-
-
+ + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/NotificationsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/NotificationsSection.tsx index 5ea363965..ab1e1d62c 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/NotificationsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/NotificationsSection.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useTranslation } from '@app/renderer/i18n' +import { SettingsGroup, SettingsGroupBody, SettingsModule } from './SettingsGroup' export function NotificationsSection(props: { systemNotificationsEnabled: boolean @@ -34,113 +35,138 @@ export function NotificationsSection(props: { } = props return ( -
-

{t('settingsPanel.notifications.title')}

- -
-
- {t('settingsPanel.notifications.systemNotifications.enabledLabel')} - {t('settingsPanel.notifications.systemNotifications.enabledHelp')} -
-
- -
-
- -
-
- {t('settingsPanel.notifications.agentStandbyBanner.enabledLabel')} - {t('settingsPanel.notifications.agentStandbyBanner.enabledHelp')} -
-
- -
-
- -
-
- {t('settingsPanel.notifications.agentStandbyBanner.contextTitle')} - {t('settingsPanel.notifications.agentStandbyBanner.contextHelp')} -
- -
-
-
- {t('settingsPanel.notifications.agentStandbyBanner.showTask')} + <> + + +
+
+ {t('settingsPanel.notifications.systemNotifications.enabledLabel')} + {t('settingsPanel.notifications.systemNotifications.enabledHelp')} +
+
+
-
+
+
-
-
- {t('settingsPanel.notifications.agentStandbyBanner.showSpace')} + + +
+
+ {t('settingsPanel.notifications.agentStandbyBanner.enabledLabel')} + {t('settingsPanel.notifications.agentStandbyBanner.enabledHelp')} +
+
+
-
-
-
- {t('settingsPanel.notifications.agentStandbyBanner.showBranch')} + +
+
+ {t('settingsPanel.notifications.agentStandbyBanner.showTask')} +
+
+ +
- -
-
-
- {t('settingsPanel.notifications.agentStandbyBanner.showPullRequest')} +
+
+ {t('settingsPanel.notifications.agentStandbyBanner.showSpace')} +
+
+ +
- -
-
-
-
+ +
+
+ {t('settingsPanel.notifications.agentStandbyBanner.showBranch')} +
+
+ +
+
+ +
+
+ + {t('settingsPanel.notifications.agentStandbyBanner.showPullRequest')} + +
+
+ +
+
+ + + + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/QuickMenuSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/QuickMenuSection.tsx index 4a6c84937..3f9a20fa2 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/QuickMenuSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/QuickMenuSection.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '@app/renderer/i18n' import type { QuickCommand, QuickPhrase } from '@contexts/settings/domain/agentSettings' import { QuickCommandsSubsection } from './quickMenu/QuickCommandsSubsection' import { QuickPhrasesSubsection } from './quickMenu/QuickPhrasesSubsection' +import { SettingsGroup } from './SettingsGroup' export function QuickMenuSection({ quickCommands, @@ -18,11 +19,10 @@ export function QuickMenuSection({ const { t } = useTranslation() return ( -
-

{t('settingsPanel.quickMenu.title')}

-
+ ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/SettingsGroup.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsGroup.tsx new file mode 100644 index 000000000..4f047d420 --- /dev/null +++ b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsGroup.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react' + +function joinClassName(baseClassName: string, className?: string): string { + return className ? `${baseClassName} ${className}` : baseClassName +} + +export function SettingsGroup({ + id, + title, + description, + className, + children, +}: { + id: string + title: string + description?: ReactNode + className?: string + children: ReactNode +}): React.JSX.Element { + const titleId = `${id}-title` + const descriptionId = description ? `${id}-description` : undefined + + return ( +
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+ ) +} + +export function SettingsGroupBody({ + id, + className, + children, +}: { + id?: string + className?: string + children: ReactNode +}): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +export function SettingsModule({ + id, + title, + description, + children, +}: { + id: string + title: string + description?: ReactNode + children: ReactNode +}): React.JSX.Element { + const titleId = `${id}-title` + const descriptionId = description ? `${id}-description` : undefined + + return ( +
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+
{children}
+
+ ) +} diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelHeader.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelHeader.tsx new file mode 100644 index 000000000..15ed0d83d --- /dev/null +++ b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelHeader.tsx @@ -0,0 +1,40 @@ +import type { JSX } from 'react' +import { X } from 'lucide-react' +import { useTranslation } from '@app/renderer/i18n' + +export function SettingsPanelHeader({ + title, + description, + onClose, +}: { + title: string + description: string + onClose: () => void +}): JSX.Element { + const { t } = useTranslation() + + return ( +
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ +
+ ) +} diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelNavButton.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelNavButton.tsx index f3ec85aa7..7c5d8ba8b 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelNavButton.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelNavButton.tsx @@ -3,6 +3,7 @@ import React from 'react' export function SettingsPanelNavButton({ isActive, isCurrent = isActive, + icon, label, testId, tone = 'default', @@ -10,6 +11,7 @@ export function SettingsPanelNavButton({ }: { isActive: boolean isCurrent?: boolean + icon?: React.ReactNode label: string testId?: string tone?: 'default' | 'secondary' @@ -24,7 +26,8 @@ export function SettingsPanelNavButton({ onClick={onClick} className={`settings-panel__nav-button${tone === 'secondary' ? ' settings-panel__nav-button--secondary' : ''}${isActive ? ' settings-panel__nav-button--active' : ''}${isActive && !isCurrent ? ' settings-panel__nav-button--parent-active' : ''}`} > - {label} + {icon ? {icon} : null} + {label} ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelSidebar.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelSidebar.tsx index ffdb96cfb..229d67916 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelSidebar.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelSidebar.tsx @@ -1,15 +1,54 @@ -import { useMemo, useState, type JSX } from 'react' -import { Search } from 'lucide-react' +import { useEffect, useMemo, useRef, useState, type JSX, type KeyboardEvent } from 'react' +import { + Bell, + Bot, + ChevronDown, + FolderKanban, + LayoutDashboard, + ListChecks, + Palette, + Plug, + Search, + Server, + Settings2, + Wrench, + type LucideIcon, +} from 'lucide-react' +import { CoveSelect } from '@app/renderer/components/CoveSelect' import { useTranslation } from '@app/renderer/i18n' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' -import { getFolderName, getWorkspacePageId, type SettingsPageId } from '../SettingsPanel.shared' +import { + getFolderName, + getWorkspacePageId, + isWorkspacePageId, + type SettingsPageId, +} from '../SettingsPanel.shared' import { SettingsPanelNavButton } from './SettingsPanelNavButton' +import { + CANONICAL_SETTINGS_PAGE_DEFINITIONS, + SETTINGS_PRIMARY_NAV_GROUPS, + resolveSettingsPage, + type SettingsPageIconId, +} from './settingsPageRegistry' import { createSettingsSearchEntries, searchSettingsEntries, type SettingsSearchResult, } from './settingsSearchIndex' +const SETTINGS_PAGE_ICONS: Record = { + settings: Settings2, + palette: Palette, + bell: Bell, + 'layout-dashboard': LayoutDashboard, + bot: Bot, + 'list-checks': ListChecks, + server: Server, + plug: Plug, + wrench: Wrench, + folder: FolderKanban, +} + export function SettingsPanelSidebar({ activePageId, workspaces, @@ -25,6 +64,10 @@ export function SettingsPanelSidebar({ }): JSX.Element { const { t } = useTranslation() const [searchQuery, setSearchQuery] = useState('') + const [activeSearchResultId, setActiveSearchResultId] = useState(null) + const [areProjectsExpanded, setAreProjectsExpanded] = useState(true) + const searchResultRefs = useRef>(new Map()) + const searchInteractionModeRef = useRef<'keyboard' | 'pointer'>('keyboard') const searchEntries = useMemo( () => createSettingsSearchEntries({ t, workspaces, endpointsEnabled }), [endpointsEnabled, t, workspaces], @@ -33,36 +76,98 @@ export function SettingsPanelSidebar({ () => searchSettingsEntries(searchEntries, searchQuery), [searchEntries, searchQuery], ) - const hasSearchQuery = searchQuery.trim().length > 0 - const visibleSearchResults = searchResults.slice(0, 8) - const hasSecondaryActivePage = - activePageId === 'endpoints' || - activePageId === 'diagnostics' || - activePageId === 'shortcuts' || - activePageId === 'quick-menu' - const effectiveActivePageId = (() => { - if ( - activePageId === 'quick-menu' || - activePageId === 'shortcuts' || - activePageId === 'task-configuration' - ) { - return 'tasks-shortcuts' + const visibleSearchResults = useMemo(() => searchResults.slice(0, 8), [searchResults]) + const activeSearchResult = useMemo(() => { + if (!activeSearchResultId) { + return null + } + + return ( + visibleSearchResults.find(result => result.id === activeSearchResultId) ?? + visibleSearchResults[0] ?? + null + ) + }, [activeSearchResultId, visibleSearchResults]) + const resolvedActivePage = resolveSettingsPage(activePageId) + const mobilePageValue = isWorkspacePageId(activePageId) + ? activePageId + : resolvedActivePage.canonicalPageId + + useEffect(() => { + if (isWorkspacePageId(activePageId)) { + setAreProjectsExpanded(true) } + }, [activePageId]) - if (activePageId === 'experimental' || activePageId === 'diagnostics') { - return 'advanced' + useEffect(() => { + if (!activeSearchResultId) { + return } - if (activePageId === 'endpoints') { - return 'worker' + const hasActiveResult = visibleSearchResults.some(result => result.id === activeSearchResultId) + if (!hasActiveResult) { + setActiveSearchResultId(visibleSearchResults[0]?.id ?? null) } + }, [activeSearchResultId, visibleSearchResults]) - if (activePageId === 'canvas') { - return 'canvas-windows' + useEffect(() => { + if (searchInteractionModeRef.current !== 'keyboard' || !activeSearchResult) { + return } - return activePageId - })() + searchResultRefs.current.get(activeSearchResult.id)?.scrollIntoView({ block: 'nearest' }) + }, [activeSearchResult]) + + const selectSearchResult = (result: SettingsSearchResult): void => { + onSelectSearchResult(result) + setSearchQuery('') + setActiveSearchResultId(null) + } + + const handleSearchKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && searchQuery.length > 0) { + event.preventDefault() + event.stopPropagation() + setSearchQuery('') + setActiveSearchResultId(null) + return + } + + if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && visibleSearchResults.length > 0) { + event.preventDefault() + searchInteractionModeRef.current = 'keyboard' + const direction = event.key === 'ArrowDown' ? 1 : -1 + const currentIndex = activeSearchResult + ? visibleSearchResults.findIndex(result => result.id === activeSearchResult.id) + : -1 + const nextIndex = + currentIndex < 0 + ? direction === 1 + ? 0 + : visibleSearchResults.length - 1 + : (currentIndex + direction + visibleSearchResults.length) % visibleSearchResults.length + setActiveSearchResultId(visibleSearchResults[nextIndex]?.id ?? null) + return + } + + if (event.key === 'Enter' && visibleSearchResults.length > 0) { + event.preventDefault() + selectSearchResult(activeSearchResult ?? visibleSearchResults[0]) + } + } + + const mobilePageOptions = [ + ...SETTINGS_PRIMARY_NAV_GROUPS.flatMap(group => + group.pageIds.map(pageId => { + const definition = CANONICAL_SETTINGS_PAGE_DEFINITIONS[pageId] + return { value: pageId, label: t(definition.navLabelKey) } + }), + ), + ...workspaces.map(workspace => ({ + value: getWorkspacePageId(workspace.id), + label: workspace.name.trim().length > 0 ? workspace.name : getFolderName(workspace.path), + })), + ] return (
- {hasSearchQuery ? ( -
- {visibleSearchResults.length > 0 ? ( - visibleSearchResults.map(result => ( - - )) - ) : ( -
- {t('settingsPanel.search.noResults')} -
- )} -
- ) : null} - - onSelectPage('general')} - /> - onSelectPage('appearance')} - /> - onSelectPage('canvas-windows')} - /> - onSelectPage('agent')} - /> - onSelectPage('tasks-shortcuts')} - /> - onSelectPage('shortcuts')} - /> - onSelectPage('quick-menu')} - /> - onSelectPage('notifications')} - /> - onSelectPage('worker')} - /> - {endpointsEnabled ? ( - onSelectPage('endpoints')} +
+ onSelectPage(nextPageId as SettingsPageId)} /> - ) : null} - onSelectPage('integrations')} - /> - onSelectPage('advanced')} - /> - onSelectPage('diagnostics')} - /> - -
{t('settingsPanel.nav.projects')}
-
- {workspaces.map(workspace => ( - 0 ? workspace.name : getFolderName(workspace.path) - } - onClick={() => onSelectPage(getWorkspacePageId(workspace.id))} - /> +
+ +
+ {SETTINGS_PRIMARY_NAV_GROUPS.map(group => ( +
+
+ {t(`settingsPanel.navGroups.${group.id}`)} +
+ {group.pageIds.map(pageId => { + const definition = CANONICAL_SETTINGS_PAGE_DEFINITIONS[pageId] + const Icon = SETTINGS_PAGE_ICONS[definition.iconId] + return ( +
))} + + {workspaces.length > 0 ? ( +
+ + {areProjectsExpanded ? ( +
+ {workspaces.map(workspace => ( +
+ ) : null} +
+ ) : null}
) diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/ShortcutsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/ShortcutsSection.tsx index 300e49703..f8477b9c9 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/ShortcutsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/ShortcutsSection.tsx @@ -20,6 +20,7 @@ import { isSpatialNavigationCommandId, } from './shortcuts/ShortcutKeycaps' import { getCommandHelpKey, getCommandTitleKey } from './shortcuts/shortcutCommandKeys' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' const TERMINAL_FOCUS_SCOPE_LABEL_BY_LOCALE: Record = { en: 'terminal', @@ -222,214 +223,232 @@ export function ShortcutsSection({ ) return ( -
-

{t('settingsPanel.shortcuts.title')}

- -
-
- {t('settingsPanel.shortcuts.disableWhenTerminalFocusedLabel')} - - {t('settingsPanel.shortcuts.disableWhenTerminalFocusedHelp', { - terminal: localeTerminalLabel, - })} - -
-
- -
-
- -
-
-

{t('settingsPanel.shortcuts.bindings')}

- {t('settingsPanel.shortcuts.bindingsHelp')} + + +
+
+ {t('settingsPanel.shortcuts.disableWhenTerminalFocusedLabel')} + + {t('settingsPanel.shortcuts.disableWhenTerminalFocusedHelp', { + terminal: localeTerminalLabel, + })} + +
+
+ +
- {commandGroups.map(group => ( -
-
-

{group.title}

- {group.help} -
- - {group.commandIds.map(commandId => { - const chord = effectiveBindings[commandId] - const hasBinding = chord !== null +
+
+

+ {t('settingsPanel.shortcuts.bindings')} +

+ {t('settingsPanel.shortcuts.bindingsHelp')} +
- return ( -
-
- {t(getCommandTitleKey(commandId))} - {t(getCommandHelpKey(commandId))} -
-
- {chord ? ( - - ) : ( - - {t('settingsPanel.shortcuts.unassigned')} - - )} - - - + {chord ? ( + + ) : ( + + {t('settingsPanel.shortcuts.unassigned')} + + )} + + + +
-
- ) - })} - - {group.id === 'workspaceCanvas' ? ( - <> - {spatialNavigationSummary} - {showSpatialNavigationBindings ? ( -
- {[ - ...SPATIAL_NAVIGATION_NODE_COMMAND_IDS, - ...SPATIAL_NAVIGATION_SPACE_COMMAND_IDS, - ].map(commandId => { - const chord = effectiveBindings[commandId] - const hasBinding = chord !== null - - return ( -
-
- {t(getCommandTitleKey(commandId))} - {t(getCommandHelpKey(commandId))} -
-
- {chord ? ( - - ) : ( - - {t('settingsPanel.shortcuts.unassigned')} - - )} - - - + {chord ? ( + + ) : ( + + {t('settingsPanel.shortcuts.unassigned')} + + )} + + + +
-
- ) - })} -
- ) : null} - - ) : null} -
- ))} -
-
+ ) + })} +
+ ) : null} + + ) : null} +
+ ))} +
+ + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/TaskConfigurationSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/TaskConfigurationSection.tsx index 14e8b2371..bfaf36d5c 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/TaskConfigurationSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/TaskConfigurationSection.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { X } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' import { AGENT_PROVIDER_LABEL, @@ -8,6 +9,7 @@ import { type TaskTitleProvider, } from '@contexts/settings/domain/agentSettings' import { CoveSelect } from '@app/renderer/components/CoveSelect' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function TaskConfigurationSection(props: { showTaskTitleGeneration: boolean @@ -39,102 +41,111 @@ export function TaskConfigurationSection(props: { } = props return ( -
-

{t('settingsPanel.tasks.title')}

- + <> {props.showTaskTitleGeneration ? ( - <> -
-
- {t('settingsPanel.tasks.titleProviderLabel')} - {t('settingsPanel.tasks.titleProviderHelp')} -
-
- ({ - value: provider, - label: AGENT_PROVIDER_LABEL[provider], - })), - ]} - onChange={nextValue => { - onChangeTaskTitleProvider(nextValue as TaskTitleProvider) - }} - /> + + +
+
+ {t('settingsPanel.tasks.titleProviderLabel')} + {t('settingsPanel.tasks.titleProviderHelp')} +
+
+ ({ + value: provider, + label: AGENT_PROVIDER_LABEL[provider], + })), + ]} + onChange={nextValue => { + onChangeTaskTitleProvider(nextValue as TaskTitleProvider) + }} + /> +
-
-
-
- {t('settingsPanel.tasks.titleModelLabel')} - {t('settingsPanel.tasks.titleModelHelp')} -
-
- { - onChangeTaskTitleModel(event.target.value) - }} - /> +
+
+ {t('settingsPanel.tasks.titleModelLabel')} + {t('settingsPanel.tasks.titleModelHelp')} +
+
+ { + onChangeTaskTitleModel(event.target.value) + }} + /> +
-
-
-
- {t('settingsPanel.tasks.effectiveProviderLabel')} - {t('settingsPanel.tasks.effectiveProviderHelp')} +
+
+ {t('settingsPanel.tasks.effectiveProviderLabel')} + {t('settingsPanel.tasks.effectiveProviderHelp')} +
+
+ + {AGENT_PROVIDER_LABEL[effectiveTaskTitleProvider]} + +
-
- - {AGENT_PROVIDER_LABEL[effectiveTaskTitleProvider]} - -
-
- + + ) : null} -
-
- {t('settingsPanel.tasks.tagsLabel')} - {t('settingsPanel.tasks.tagsHelp')} -
- -
+ +
{tags.map(tag => (
{tag}
))}
-
+
-
-
+ + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/TerminalDisplayCalibrationRow.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/TerminalDisplayCalibrationRow.tsx index edd55fd51..c4c9e9019 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/TerminalDisplayCalibrationRow.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/TerminalDisplayCalibrationRow.tsx @@ -21,6 +21,7 @@ import { TERMINAL_DISPLAY_MEASUREMENT_HEIGHT, TERMINAL_DISPLAY_MEASUREMENT_WIDTH, } from '../terminalDisplayMeasurement' +import { SettingsModule } from './SettingsGroup' export function TerminalDisplayCalibrationRow({ terminalFontSize, @@ -180,14 +181,11 @@ export function TerminalDisplayCalibrationRow({ : t('settingsPanel.general.terminalDisplayCalibration.clientDefault') return ( -
-
-

- {t('settingsPanel.general.terminalDisplayCalibration.title')} -

- {t('settingsPanel.general.terminalDisplayCalibration.help')} -
- +
@@ -201,6 +199,7 @@ export function TerminalDisplayCalibrationRow({ type="checkbox" data-testid="settings-terminal-display-auto-reference" checked={terminalDisplayAutoReferenceEnabled} + aria-label={t('settingsPanel.general.terminalDisplayCalibration.autoReferenceLabel')} onChange={event => onChangeTerminalDisplayAutoReferenceEnabled(event.target.checked)} /> @@ -219,6 +218,7 @@ export function TerminalDisplayCalibrationRow({ type="checkbox" data-testid="settings-terminal-display-compensation" checked={terminalDisplayCalibrationCompensationEnabled} + aria-label={t('settingsPanel.general.terminalDisplayCalibration.compensationLabel')} onChange={event => onChangeTerminalDisplayCalibrationCompensationEnabled(event.target.checked) } @@ -321,6 +321,6 @@ export function TerminalDisplayCalibrationRow({ pointerEvents: 'none', }} /> -
+ ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/TerminalFontFamilyRow.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/TerminalFontFamilyRow.tsx index d7b965f88..4fed6dab3 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/TerminalFontFamilyRow.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/TerminalFontFamilyRow.tsx @@ -79,6 +79,7 @@ export function TerminalFontFamilyRow({ onClick={() => (isOpen ? close() : open())} id="settings-terminal-font-family" data-testid="settings-terminal-font-family" + aria-label={t('settingsPanel.general.terminalFontFamily')} > {isLoading ? t('settingsPanel.general.terminalFontFamilyLoading') : displayValue} @@ -117,6 +118,7 @@ export function TerminalFontFamilyRow({ className="cove-field" style={{ width: '100%', boxSizing: 'border-box' }} placeholder={t('settingsPanel.general.terminalFontFamilySearch')} + aria-label={t('settingsPanel.general.terminalFontFamilySearch')} value={query} onChange={e => setQuery(e.target.value)} /> diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/TerminalProfileSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/TerminalProfileSection.tsx index 8da66355d..a701fa8de 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/TerminalProfileSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/TerminalProfileSection.tsx @@ -1,19 +1,29 @@ import React from 'react' import { CoveSelect } from '@app/renderer/components/CoveSelect' import { useTranslation } from '@app/renderer/i18n' +import { getStandardWindowSizeBucketLabel } from '@app/renderer/i18n/labels' +import { + STANDARD_WINDOW_SIZE_BUCKETS, + type StandardWindowSizeBucket, +} from '@contexts/settings/domain/agentSettings' import type { TerminalProfile } from '@shared/contracts/dto' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function TerminalProfileSection({ + standardWindowSizeBucket, defaultTerminalProfileId, terminalProfiles, detectedDefaultTerminalProfileId, + onChangeStandardWindowSizeBucket, onChangeDefaultTerminalProfileId, }: { + standardWindowSizeBucket: StandardWindowSizeBucket defaultTerminalProfileId: string | null terminalProfiles: TerminalProfile[] detectedDefaultTerminalProfileId: string | null + onChangeStandardWindowSizeBucket: (bucket: StandardWindowSizeBucket) => void onChangeDefaultTerminalProfileId: (profileId: string | null) => void -}): React.JSX.Element | null { +}): React.JSX.Element { const { t } = useTranslation() const selectedProfileId = terminalProfiles.some( profile => profile.id === defaultTerminalProfileId, @@ -24,47 +34,70 @@ export function TerminalProfileSection({ terminalProfiles.find(profile => profile.id === detectedDefaultTerminalProfileId)?.label ?? t('settingsPanel.terminal.profileAuto') - if (terminalProfiles.length === 0) { - return null - } - return ( -
-

{t('settingsPanel.terminal.title')}

- {t('settingsPanel.terminal.help')} - -
-
- {t('settingsPanel.terminal.profileLabel')} - - {t('settingsPanel.terminal.profileHelp', { - defaultProfile: defaultProfileLabel, - })} - + + +
+
+ {t('settingsPanel.canvas.standardWindowSizeLabel')} + {t('settingsPanel.canvas.standardWindowSizeHelp')} +
+
+ ({ + value: bucket, + label: getStandardWindowSizeBucketLabel(t, bucket), + }))} + onChange={nextValue => + onChangeStandardWindowSizeBucket(nextValue as StandardWindowSizeBucket) + } + /> +
-
- 0 ? ( +
+
+ {t('settingsPanel.terminal.profileLabel')} + + {t('settingsPanel.terminal.profileHelp', { defaultProfile: defaultProfileLabel, - }), - }, - ...terminalProfiles.map(profile => ({ - value: profile.id, - label: profile.label, - })), - ]} - onChange={nextValue => - onChangeDefaultTerminalProfileId(nextValue.trim().length > 0 ? nextValue : null) - } - /> -
-
-
+ })} + +
+
+ ({ + value: profile.id, + label: profile.label, + })), + ]} + onChange={nextValue => + onChangeDefaultTerminalProfileId(nextValue.trim().length > 0 ? nextValue : null) + } + /> +
+
+ ) : null} + + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/WorkerConnectionsSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/WorkerConnectionsSection.tsx index 2c1add4cd..a1e18a3f7 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/WorkerConnectionsSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/WorkerConnectionsSection.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '@app/renderer/i18n' import { EndpointsSection } from './EndpointsSection' import { ExperimentalWorkerWebUiSection } from './ExperimentalWorkerWebUiSection' import { WorkerSection } from './WorkerSection' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' export function WorkerConnectionsSection({ remoteWorkersEnabled, @@ -17,24 +18,13 @@ export function WorkerConnectionsSection({ <> -
-

- {t('settingsPanel.workerConnections.title')} -

- - {t('settingsPanel.workerConnections.help')} - - -
-
- {t('settingsPanel.experimental.remoteWorkersTitle')} - {t('settingsPanel.experimental.remoteWorkersHelp')} -
- -
+ + +
{t('settingsPanel.experimental.remoteWorkersEnabledLabel')} {t('settingsPanel.experimental.remoteWorkersEnabledHelp')} @@ -45,32 +35,34 @@ export function WorkerConnectionsSection({ type="checkbox" data-testid="settings-experimental-remote-workers-enabled" checked={remoteWorkersEnabled} + aria-label={t('settingsPanel.experimental.remoteWorkersEnabledLabel')} onChange={event => onChangeRemoteWorkersEnabled(event.target.checked)} />
-
-
+ + {remoteWorkersEnabled ? ( ) : ( -
-

{t('settingsPanel.endpoints.title')}

-
-
- {t('settingsPanel.endpoints.list.title')} - {t('settingsPanel.workerConnections.endpointsDisabledHelp')} + + +
+
+ {t('settingsPanel.endpoints.list.title')} + {t('settingsPanel.workerConnections.endpointsDisabledHelp')} +
+
+ + {t('settingsPanel.workerConnections.endpointsDisabledValue')} + +
-
- - {t('settingsPanel.workerConnections.endpointsDisabledValue')} - -
-
-
+ + )} diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/WorkerRemoteConnectionFields.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/WorkerRemoteConnectionFields.tsx new file mode 100644 index 000000000..7d93ee37e --- /dev/null +++ b/src/contexts/settings/presentation/renderer/settingsPanel/WorkerRemoteConnectionFields.tsx @@ -0,0 +1,91 @@ +import type { JSX } from 'react' +import { useTranslation } from '@app/renderer/i18n' + +export function WorkerRemoteConnectionFields({ + hostname, + port, + token, + revealToken, + onHostnameChange, + onPortChange, + onTokenChange, + onToggleRevealToken, +}: { + hostname: string + port: string + token: string + revealToken: boolean + onHostnameChange: (hostname: string) => void + onPortChange: (port: string) => void + onTokenChange: (token: string) => void + onToggleRevealToken: () => void +}): JSX.Element { + const { t } = useTranslation() + + return ( + <> +
+
+ {t('settingsPanel.worker.remote.hostnameLabel')} +
+
+ onHostnameChange(event.target.value)} + data-testid="settings-worker-remote-hostname" + /> +
+
+ +
+
+ {t('settingsPanel.worker.remote.portLabel')} +
+
+ onPortChange(event.target.value)} + data-testid="settings-worker-remote-port" + /> +
+
+ +
+
+ {t('settingsPanel.worker.remote.tokenLabel')} +
+
+ onTokenChange(event.target.value)} + data-testid="settings-worker-remote-token" + /> + +
+
+ + ) +} diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection.tsx index 15d5db49f..d18ab70b6 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react' import { useTranslation } from '@app/renderer/i18n' import type { CliPathStatusResult, HomeWorkerMode, WorkerStatusResult } from '@shared/contracts/dto' import { CoveSelect } from '@app/renderer/components/CoveSelect' +import { WorkerRemoteConnectionFields } from './WorkerRemoteConnectionFields' +import { SettingsGroup, SettingsGroupBody, SettingsModule } from './SettingsGroup' import { formatToken, toBaseUrl, toErrorMessage } from './workerSectionUtils' export function WorkerSection({ @@ -198,300 +200,248 @@ export function WorkerSection({ } return ( -
-
-

{t('settingsPanel.worker.title')}

-
- - {error ? ( -
-
- {t('common.error')} -
-
- - {error} - -
-
- ) : null} - -
-
-

{t('settingsPanel.worker.home.title')}

- - {supportsHomeWorkerModeSelection - ? t('settingsPanel.worker.home.help') - : t('settingsPanel.worker.home.packagedHelp')} - -
- -
-
- - {supportsHomeWorkerModeSelection - ? t('settingsPanel.worker.home.modeLabel') - : t('settingsPanel.worker.home.packagedModeLabel')} - -
-
- {supportsHomeWorkerModeSelection ? ( - setDraftMode(nextValue as HomeWorkerMode)} - /> - ) : ( - - {t('settingsPanel.worker.home.packagedModeValue')} + + + {error ? ( +
+
+ {t('common.error')} +
+
+ + {error} - )} +
-
+ ) : null} - {supportsHomeWorkerModeSelection && draftMode === 'remote' ? ( - <> -
-
- {t('settingsPanel.worker.remote.hostnameLabel')} -
-
- setRemoteHostname(e.target.value)} - data-testid="settings-worker-remote-hostname" - /> -
+ +
+
+ + {supportsHomeWorkerModeSelection + ? t('settingsPanel.worker.home.modeLabel') + : t('settingsPanel.worker.home.packagedModeLabel')} +
- -
-
- {t('settingsPanel.worker.remote.portLabel')} -
-
- setRemotePort(e.target.value)} - data-testid="settings-worker-remote-port" +
+ {supportsHomeWorkerModeSelection ? ( + setDraftMode(nextValue as HomeWorkerMode)} /> -
+ ) : ( + + {t('settingsPanel.worker.home.packagedModeValue')} + + )}
+
+ {supportsHomeWorkerModeSelection && draftMode === 'remote' ? ( + setRevealRemoteToken(value => !value)} + /> + ) : null} + + {supportsHomeWorkerModeSelection ? (
- {t('settingsPanel.worker.remote.tokenLabel')} + {t('settingsPanel.worker.home.applyLabel')} + {t('settingsPanel.worker.home.applyHelp')}
-
- setRemoteToken(e.target.value)} - data-testid="settings-worker-remote-token" - /> +
- - ) : null} + ) : null} + + + +
+
+ {t('settingsPanel.worker.cli.statusLabel')} +
+
+ + {cliStatus?.installed + ? cliStatus.healthy + ? t('settingsPanel.worker.cli.status.installed', { + path: cliStatus.path ?? '—', + }) + : t('settingsPanel.worker.cli.status.needsRepair', { + path: cliStatus.path ?? '—', + }) + : t('settingsPanel.worker.cli.status.notInstalled')} + +
+
- {supportsHomeWorkerModeSelection ? (
- {t('settingsPanel.worker.home.applyLabel')} - {t('settingsPanel.worker.home.applyHelp')} + {t('settingsPanel.worker.cli.actionsLabel')}
-
+
+
- ) : null} -
- -
-
-

{t('settingsPanel.worker.cli.title')}

- {t('settingsPanel.worker.cli.help')} -
+ -
-
- {t('settingsPanel.worker.cli.statusLabel')} -
-
- - {cliStatus?.installed - ? cliStatus.healthy - ? t('settingsPanel.worker.cli.status.installed', { - path: cliStatus.path ?? '—', - }) - : t('settingsPanel.worker.cli.status.needsRepair', { - path: cliStatus.path ?? '—', - }) - : t('settingsPanel.worker.cli.status.notInstalled')} - -
-
- -
-
- {t('settingsPanel.worker.cli.actionsLabel')} -
-
- - -
-
-
- -
-
-

{t('settingsPanel.worker.local.title')}

- {t('settingsPanel.worker.local.help')} -
- -
-
- {t('settingsPanel.worker.local.statusLabel')} -
-
- - {localStatus?.status === 'running' - ? t('settingsPanel.worker.local.status.running') - : t('settingsPanel.worker.local.status.stopped')} - + +
+
+ {t('settingsPanel.worker.local.statusLabel')} +
+
+ + {localStatus?.status === 'running' + ? t('settingsPanel.worker.local.status.running') + : t('settingsPanel.worker.local.status.stopped')} + +
-
-
-
- {t('settingsPanel.worker.local.actionsLabel')} -
-
- - +
+
+ {t('settingsPanel.worker.local.actionsLabel')} +
+
+ + +
-
- {localConnection ? ( - <> -
-
- {t('settingsPanel.worker.local.baseUrlLabel')} + {localConnection ? ( + <> +
+
+ {t('settingsPanel.worker.local.baseUrlLabel')} +
+
+ + {toBaseUrl(localConnection)} + + +
-
- - {toBaseUrl(localConnection)} - - -
-
-
-
- {t('settingsPanel.worker.local.tokenLabel')} +
+
+ {t('settingsPanel.worker.local.tokenLabel')} +
+
+ + {formatToken(localConnection.token, revealLocalToken)} + + + +
-
- - {formatToken(localConnection.token, revealLocalToken)} - - - -
-
- - ) : null} -
-
+ + ) : null} + + + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/WorkspaceSection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/WorkspaceSection.tsx index 7522071de..eb181b5db 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/WorkspaceSection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/WorkspaceSection.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from 'react' import { useTranslation } from '@app/renderer/i18n' +import { SettingsGroup, SettingsGroupBody } from './SettingsGroup' function resolveWorktreesRoot(workspacePath: string, worktreesRoot: string): string { const trimmed = worktreesRoot.trim() @@ -93,119 +94,116 @@ export function WorkspaceSection({ return ( <> -
-

{t('settingsPanel.workspace.title')}

- - {!hasWorkspace ? ( -
-
- {t('settingsPanel.workspace.selectProjectFirst')} - {t('settingsPanel.workspace.selectProjectFirstHelp')} -
-
- ) : ( - <> + + + {!hasWorkspace ? (
- {t('settingsPanel.workspace.workspacePathLabel')} - - {t('settingsPanel.workspace.workspacePathHelp', { name: resolvedWorkspaceName })} - -
-
- - {getFolderName(workspacePath)} - -
-
- -
-
- {t('settingsPanel.workspace.worktreeRootLabel')} - {t('settingsPanel.workspace.worktreeRootHelp')} -
-
- onChangeWorktreesRoot(event.target.value)} - /> - + {t('settingsPanel.workspace.selectProjectFirst')} + {t('settingsPanel.workspace.selectProjectFirstHelp')}
- -
-
- {t('settingsPanel.workspace.resolvedPathLabel')} - {t('settingsPanel.workspace.resolvedPathHelp')} -
-
- - {getTrailingPathSegments(resolvedRoot, 2)} - + ) : ( + <> +
+
+ {t('settingsPanel.workspace.workspacePathLabel')} + + {t('settingsPanel.workspace.workspacePathHelp', { + name: resolvedWorkspaceName, + })} + +
+
+ + {getFolderName(workspacePath)} + +
-
- - )} -
- {hasWorkspace ? ( -
-

- {t('settingsPanel.workspace.environmentVariablesTitle')} -

- -
-
- {t('settingsPanel.workspace.environmentVariablesHelp')} -
- -
- {envEntries.map(([key, value]) => ( -
- - {key} = {value} - +
+
+ {t('settingsPanel.workspace.worktreeRootLabel')} + {t('settingsPanel.workspace.worktreeRootHelp')} +
+
+ onChangeWorktreesRoot(event.target.value)} + />
- ))} - {envEntries.length === 0 ? ( +
+ +
+
+ {t('settingsPanel.workspace.resolvedPathLabel')} + {t('settingsPanel.workspace.resolvedPathHelp')} +
+
+ + {getTrailingPathSegments(resolvedRoot, 2)} + +
+
+ + )} + + + + {hasWorkspace ? ( + +
+ {envEntries.map(([key, value]) => ( +
- {t('settingsPanel.workspace.environmentVariablesEmpty')} + {key} = {value} - ) : null} -
- + +
+ ))} + {envEntries.length === 0 ? ( + + {t('settingsPanel.workspace.environmentVariablesEmpty')} + + ) : null}
setEnvKeyInput(event.target.value)} @@ -215,6 +213,7 @@ export function WorkspaceSection({ type="text" data-testid="settings-env-var-value-input" className="cove-field" + aria-label={t('settingsPanel.workspace.environmentVariablesValueLabel')} value={envValueInput} placeholder={t('settingsPanel.workspace.environmentVariablesValuePlaceholder')} onChange={event => setEnvValueInput(event.target.value)} @@ -231,7 +230,7 @@ export function WorkspaceSection({
-
+ ) : null} ) diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickCommandsSubsection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickCommandsSubsection.tsx index 880f28a77..051354248 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickCommandsSubsection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickCommandsSubsection.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, Globe, Pin, Power, Terminal } from 'lucide-reac import { useTranslation } from '@app/renderer/i18n' import type { QuickCommand } from '@contexts/settings/domain/agentSettings' import { CoveSelect } from '@app/renderer/components/CoveSelect' +import { SettingsModule } from '../SettingsGroup' import { moveItem } from './moveItem' type QuickCommandDraft = { @@ -159,12 +160,11 @@ export function QuickCommandsSubsection({ : 'settings-quick-command-pinned' return ( -
-
- {t('settingsPanel.quickMenu.commands.title')} - {t('settingsPanel.quickMenu.commands.help')} -
- +
{quickCommands.map((command, index) => (
@@ -196,7 +196,7 @@ export function QuickCommandsSubsection({ type="checkbox" data-testid={`settings-quick-command-enabled-${command.id}`} checked={command.enabled} - aria-label={t('settingsPanel.quickMenu.commands.enabled')} + aria-label={`${command.title} ${t('settingsPanel.quickMenu.commands.enabled')}`} onChange={event => { onChangeQuickCommands( quickCommands.map(existing => @@ -222,7 +222,7 @@ export function QuickCommandsSubsection({ type="checkbox" data-testid={`settings-quick-command-pinned-${command.id}`} checked={command.pinned} - aria-label={t('settingsPanel.quickMenu.commands.pinned')} + aria-label={`${command.title} ${t('settingsPanel.quickMenu.commands.pinned')}`} onChange={event => { onChangeQuickCommands( quickCommands.map(existing => @@ -493,6 +493,6 @@ export function QuickCommandsSubsection({
) : null} - + ) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickPhrasesSubsection.tsx b/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickPhrasesSubsection.tsx index af5b33a23..3bc6b6da1 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickPhrasesSubsection.tsx +++ b/src/contexts/settings/presentation/renderer/settingsPanel/quickMenu/QuickPhrasesSubsection.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react' import { ChevronDown, ChevronUp, Power } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' import type { QuickPhrase } from '@contexts/settings/domain/agentSettings' +import { SettingsModule } from '../SettingsGroup' import { moveItem } from './moveItem' type QuickPhraseDraft = { @@ -103,12 +104,11 @@ export function QuickPhrasesSubsection({ ) return ( -
-
- {t('settingsPanel.quickMenu.phrases.title')} - {t('settingsPanel.quickMenu.phrases.help')} -
- +
{quickPhrases.map((phrase, index) => (
@@ -128,7 +128,7 @@ export function QuickPhrasesSubsection({ type="checkbox" data-testid={`settings-quick-phrase-enabled-${phrase.id}`} checked={phrase.enabled} - aria-label={t('settingsPanel.quickMenu.phrases.enabled')} + aria-label={`${phrase.title} ${t('settingsPanel.quickMenu.phrases.enabled')}`} onChange={event => { onChangeQuickPhrases( quickPhrases.map(existing => @@ -271,6 +271,7 @@ export function QuickPhrasesSubsection({ { setPhraseEditor(previous => previous @@ -314,6 +315,6 @@ export function QuickPhrasesSubsection({
) : null} -
+
) } diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.spec.ts b/src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.spec.ts new file mode 100644 index 000000000..428119fa9 --- /dev/null +++ b/src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { SettingsPageId } from '../SettingsPanel.shared' +import { + SETTINGS_PAGE_REGISTRY, + SETTINGS_PRIMARY_NAV_GROUPS, + resolveSettingsPage, +} from './settingsPageRegistry' + +type StaticSettingsPageId = Exclude + +const EXPECTED_CANONICAL_PAGE_IDS = { + general: 'general', + appearance: 'appearance', + 'canvas-windows': 'canvas-windows', + worker: 'worker', + endpoints: 'worker', + agent: 'agent', + 'tasks-shortcuts': 'tasks-shortcuts', + 'quick-menu': 'tasks-shortcuts', + notifications: 'notifications', + canvas: 'canvas-windows', + advanced: 'advanced', + experimental: 'advanced', + shortcuts: 'tasks-shortcuts', + 'task-configuration': 'tasks-shortcuts', + integrations: 'integrations', + diagnostics: 'advanced', +} as const satisfies Record + +const STATIC_PAGE_IDS = Object.keys(EXPECTED_CANONICAL_PAGE_IDS) as StaticSettingsPageId[] + +const SECONDARY_ALIASES = [ + 'canvas', + 'shortcuts', + 'quick-menu', + 'task-configuration', + 'endpoints', + 'experimental', + 'diagnostics', +] as const satisfies readonly StaticSettingsPageId[] + +describe('settingsPageRegistry', () => { + it('resolves every static page id to a canonical page', () => { + const registeredPageIds = Object.keys(SETTINGS_PAGE_REGISTRY) + expect(registeredPageIds).toHaveLength(STATIC_PAGE_IDS.length) + expect(registeredPageIds).toEqual(expect.arrayContaining(STATIC_PAGE_IDS)) + + for (const pageId of STATIC_PAGE_IDS) { + const resolvedPage = resolveSettingsPage(pageId) + + expect(resolvedPage).toBeDefined() + expect(resolvedPage.canonicalPageId).toBe(EXPECTED_CANONICAL_PAGE_IDS[pageId]) + } + }) + + it('normalizes secondary page aliases', () => { + expect( + Object.fromEntries( + SECONDARY_ALIASES.map(pageId => [pageId, resolveSettingsPage(pageId).canonicalPageId]), + ), + ).toEqual({ + canvas: 'canvas-windows', + shortcuts: 'tasks-shortcuts', + 'quick-menu': 'tasks-shortcuts', + 'task-configuration': 'tasks-shortcuts', + endpoints: 'worker', + experimental: 'advanced', + diagnostics: 'advanced', + }) + }) + + it('defines primary navigation in four groups without secondary aliases', () => { + expect(SETTINGS_PRIMARY_NAV_GROUPS).toEqual([ + { + id: 'application', + pageIds: ['general', 'appearance', 'notifications'], + }, + { + id: 'workspace', + pageIds: ['canvas-windows', 'agent', 'tasks-shortcuts'], + }, + { + id: 'connections', + pageIds: ['worker', 'integrations'], + }, + { + id: 'advanced', + pageIds: ['advanced'], + }, + ]) + + const primaryPageIds = SETTINGS_PRIMARY_NAV_GROUPS.flatMap(group => group.pageIds) + expect(primaryPageIds).not.toEqual(expect.arrayContaining([...SECONDARY_ALIASES])) + }) + + it('keeps legacy scroll targets on their registry entries', () => { + expect( + Object.fromEntries( + STATIC_PAGE_IDS.flatMap(pageId => { + const { scrollTargetId } = resolveSettingsPage(pageId) + return scrollTargetId ? [[pageId, scrollTargetId]] : [] + }), + ), + ).toEqual({ + endpoints: 'settings-section-endpoints', + 'quick-menu': 'settings-section-quick-commands', + shortcuts: 'settings-section-shortcuts', + 'task-configuration': 'settings-section-task-configuration', + diagnostics: 'settings-section-diagnostics', + }) + }) +}) diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.ts b/src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.ts new file mode 100644 index 000000000..f5dbbea64 --- /dev/null +++ b/src/contexts/settings/presentation/renderer/settingsPanel/settingsPageRegistry.ts @@ -0,0 +1,213 @@ +import { + isWorkspacePageId, + type SettingsPageId, +} from '@contexts/settings/presentation/renderer/SettingsPanel.shared' + +export type StaticSettingsPageId = Exclude + +export type CanonicalSettingsPageId = + | 'general' + | 'appearance' + | 'notifications' + | 'canvas-windows' + | 'agent' + | 'tasks-shortcuts' + | 'worker' + | 'integrations' + | 'advanced' + +export type SettingsNavGroupId = 'application' | 'workspace' | 'connections' | 'advanced' + +export type SettingsPageIconId = + | 'settings' + | 'palette' + | 'bell' + | 'layout-dashboard' + | 'bot' + | 'list-checks' + | 'server' + | 'plug' + | 'wrench' + | 'folder' + +export interface SettingsPageRegistryEntry { + canonicalPageId: CanonicalSettingsPageId + scrollTargetId?: string +} + +export interface CanonicalSettingsPageDefinition { + canonicalPageId: CanonicalSettingsPageId + groupId: SettingsNavGroupId + navLabelKey: string + descriptionKey?: string + iconId: SettingsPageIconId + testId: string +} + +export const CANONICAL_SETTINGS_PAGE_DEFINITIONS: Readonly< + Record +> = { + general: { + canonicalPageId: 'general', + groupId: 'application', + navLabelKey: 'settingsPanel.nav.general', + descriptionKey: 'settingsPanel.pageDescriptions.general', + iconId: 'settings', + testId: 'settings-section-nav-general', + }, + appearance: { + canonicalPageId: 'appearance', + groupId: 'application', + navLabelKey: 'settingsPanel.nav.appearance', + descriptionKey: 'settingsPanel.pageDescriptions.appearance', + iconId: 'palette', + testId: 'settings-section-nav-appearance', + }, + notifications: { + canonicalPageId: 'notifications', + groupId: 'application', + navLabelKey: 'settingsPanel.nav.notifications', + descriptionKey: 'settingsPanel.pageDescriptions.notifications', + iconId: 'bell', + testId: 'settings-section-nav-notifications', + }, + 'canvas-windows': { + canonicalPageId: 'canvas-windows', + groupId: 'workspace', + navLabelKey: 'settingsPanel.nav.canvasWindows', + descriptionKey: 'settingsPanel.pageDescriptions.canvasWindows', + iconId: 'layout-dashboard', + testId: 'settings-section-nav-canvas', + }, + agent: { + canonicalPageId: 'agent', + groupId: 'workspace', + navLabelKey: 'settingsPanel.nav.agent', + descriptionKey: 'settingsPanel.pageDescriptions.agent', + iconId: 'bot', + testId: 'settings-section-nav-agent', + }, + 'tasks-shortcuts': { + canonicalPageId: 'tasks-shortcuts', + groupId: 'workspace', + navLabelKey: 'settingsPanel.nav.tasksShortcuts', + descriptionKey: 'settingsPanel.pageDescriptions.tasksShortcuts', + iconId: 'list-checks', + testId: 'settings-section-nav-task-configuration', + }, + worker: { + canonicalPageId: 'worker', + groupId: 'connections', + navLabelKey: 'settingsPanel.nav.workerConnections', + descriptionKey: 'settingsPanel.pageDescriptions.worker', + iconId: 'server', + testId: 'settings-section-nav-worker', + }, + integrations: { + canonicalPageId: 'integrations', + groupId: 'connections', + navLabelKey: 'settingsPanel.nav.integrations', + descriptionKey: 'settingsPanel.pageDescriptions.integrations', + iconId: 'plug', + testId: 'settings-section-nav-integrations', + }, + advanced: { + canonicalPageId: 'advanced', + groupId: 'advanced', + navLabelKey: 'settingsPanel.nav.advanced', + descriptionKey: 'settingsPanel.pageDescriptions.advanced', + iconId: 'wrench', + testId: 'settings-section-nav-experimental', + }, +} + +export const SETTINGS_PAGE_REGISTRY: Readonly< + Record +> = { + general: { canonicalPageId: 'general' }, + appearance: { canonicalPageId: 'appearance' }, + 'canvas-windows': { canonicalPageId: 'canvas-windows' }, + worker: { canonicalPageId: 'worker' }, + endpoints: { + canonicalPageId: 'worker', + scrollTargetId: 'settings-section-endpoints', + }, + agent: { canonicalPageId: 'agent' }, + 'tasks-shortcuts': { canonicalPageId: 'tasks-shortcuts' }, + 'quick-menu': { + canonicalPageId: 'tasks-shortcuts', + scrollTargetId: 'settings-section-quick-commands', + }, + notifications: { canonicalPageId: 'notifications' }, + canvas: { canonicalPageId: 'canvas-windows' }, + advanced: { canonicalPageId: 'advanced' }, + experimental: { canonicalPageId: 'advanced' }, + shortcuts: { + canonicalPageId: 'tasks-shortcuts', + scrollTargetId: 'settings-section-shortcuts', + }, + 'task-configuration': { + canonicalPageId: 'tasks-shortcuts', + scrollTargetId: 'settings-section-task-configuration', + }, + integrations: { canonicalPageId: 'integrations' }, + diagnostics: { + canonicalPageId: 'advanced', + scrollTargetId: 'settings-section-diagnostics', + }, +} + +export interface SettingsPrimaryNavGroup { + id: SettingsNavGroupId + pageIds: readonly CanonicalSettingsPageId[] +} + +export const SETTINGS_PRIMARY_NAV_GROUPS: readonly SettingsPrimaryNavGroup[] = [ + { + id: 'application', + pageIds: ['general', 'appearance', 'notifications'], + }, + { + id: 'workspace', + pageIds: ['canvas-windows', 'agent', 'tasks-shortcuts'], + }, + { + id: 'connections', + pageIds: ['worker', 'integrations'], + }, + { + id: 'advanced', + pageIds: ['advanced'], + }, +] + +export interface ResolvedSettingsPage { + requestedPageId: SettingsPageId + canonicalPageId: SettingsPageId + groupId?: SettingsNavGroupId + navLabelKey?: string + descriptionKey?: string + iconId: SettingsPageIconId + testId?: string + scrollTargetId?: string +} + +export function resolveSettingsPage(pageId: SettingsPageId): ResolvedSettingsPage { + if (isWorkspacePageId(pageId)) { + return { + requestedPageId: pageId, + canonicalPageId: pageId, + navLabelKey: 'settingsPanel.nav.projects', + iconId: 'folder', + } + } + + const registryEntry = SETTINGS_PAGE_REGISTRY[pageId] + const canonicalDefinition = CANONICAL_SETTINGS_PAGE_DEFINITIONS[registryEntry.canonicalPageId] + + return { + requestedPageId: pageId, + ...canonicalDefinition, + scrollTargetId: registryEntry.scrollTargetId, + } +} diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.agent.ts b/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.agent.ts index a695cd8e1..a6b839dcf 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.agent.ts +++ b/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.agent.ts @@ -1,9 +1,8 @@ -import type { SettingsPageId } from '../SettingsPanel.shared' +import type { StaticSettingsPageId } from './settingsPageRegistry' export interface AgentSettingsSearchEntryDefinition { id: string - pageId: SettingsPageId - pageLabelKey: string + pageId: StaticSettingsPageId titleKey: string descriptionKey?: string anchorId: string @@ -15,7 +14,6 @@ export const AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS: AgentSettingsSearchEntryDe { id: 'agent.default', pageId: 'agent', - pageLabelKey: 'settingsPanel.nav.agent', titleKey: 'settingsPanel.agent.defaultAgentLabel', descriptionKey: 'settingsPanel.agent.defaultAgentHelp', anchorId: 'settings-agent-list', @@ -24,7 +22,6 @@ export const AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS: AgentSettingsSearchEntryDe { id: 'agent.order', pageId: 'agent', - pageLabelKey: 'settingsPanel.nav.agent', titleKey: 'settingsPanel.agent.agentProviderOrderLabel', descriptionKey: 'settingsPanel.agent.agentProviderOrderHelp', anchorId: 'settings-agent-list', @@ -33,7 +30,6 @@ export const AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS: AgentSettingsSearchEntryDe { id: 'agent.full-access', pageId: 'agent', - pageLabelKey: 'settingsPanel.nav.agent', titleKey: 'settingsPanel.agent.fullAccessLabel', descriptionKey: 'settingsPanel.agent.fullAccessHelp', anchorId: 'settings-agent-full-access', @@ -42,7 +38,6 @@ export const AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS: AgentSettingsSearchEntryDe { id: 'agent.models', pageId: 'agent', - pageLabelKey: 'settingsPanel.nav.agent', titleKey: 'settingsPanel.models.title', descriptionKey: 'settingsPanel.agent.agentProviderOrderHelp', anchorId: 'settings-agent-list', @@ -55,7 +50,6 @@ export const AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS: AgentSettingsSearchEntryDe { id: 'agent.env', pageId: 'agent', - pageLabelKey: 'settingsPanel.nav.agent', titleKey: 'settingsPanel.agentEnv.title', descriptionKey: 'settingsPanel.agentEnv.help', anchorId: 'settings-agent-list', @@ -64,7 +58,6 @@ export const AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS: AgentSettingsSearchEntryDe { id: 'agent.executable', pageId: 'agent', - pageLabelKey: 'settingsPanel.nav.agent', titleKey: 'settingsPanel.agent.agentListLabel', descriptionKey: 'settingsPanel.agentExecutable.help', anchorId: 'settings-agent-list', diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.spec.ts b/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.spec.ts index c144400be..f5fa49fe7 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.spec.ts +++ b/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.spec.ts @@ -49,6 +49,7 @@ describe('settingsSearchIndex', () => { expect(results[0]).toMatchObject({ id: 'general.theme', pageId: 'appearance', + pageLabel: '显示与字体', anchorId: 'settings-ui-theme', }) }) @@ -92,6 +93,7 @@ describe('settingsSearchIndex', () => { expect(results[0]).toMatchObject({ id: 'diagnostics.performance', pageId: 'advanced', + pageLabel: 'Advanced', anchorId: 'settings-section-diagnostics', }) }) diff --git a/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.ts b/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.ts index 4b10f2543..7d4be111d 100644 --- a/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.ts +++ b/src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex.ts @@ -1,6 +1,7 @@ import type { TranslateFn } from '@app/renderer/i18n' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' import { getFolderName, getWorkspacePageId, type SettingsPageId } from '../SettingsPanel.shared' +import { resolveSettingsPage, type StaticSettingsPageId } from './settingsPageRegistry' import { AGENT_SETTINGS_SEARCH_ENTRY_DEFINITIONS, type AgentSettingsSearchEntryDefinition, @@ -22,8 +23,7 @@ export interface SettingsSearchResult extends SettingsSearchEntry { interface CoreSettingsSearchEntryDefinition { id: string - pageId: SettingsPageId - pageLabelKey: string + pageId: StaticSettingsPageId titleKey: string descriptionKey?: string anchorId: string @@ -37,7 +37,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.language', pageId: 'general', - pageLabelKey: 'settingsPanel.nav.general', titleKey: 'settingsPanel.general.languageLabel', descriptionKey: 'settingsPanel.general.languageHelp', anchorId: 'settings-language', @@ -46,7 +45,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.theme', pageId: 'appearance', - pageLabelKey: 'settingsPanel.nav.appearance', titleKey: 'settingsPanel.general.uiThemeLabel', descriptionKey: 'settingsPanel.general.uiThemeHelp', anchorId: 'settings-ui-theme', @@ -55,7 +53,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.ui-font-size', pageId: 'appearance', - pageLabelKey: 'settingsPanel.nav.appearance', titleKey: 'settingsPanel.general.interfaceFontSize', anchorId: 'settings-ui-font-size', keywords: ['font', 'size', 'interface', '字体', '界面'], @@ -63,7 +60,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.terminal-font-size', pageId: 'appearance', - pageLabelKey: 'settingsPanel.nav.appearance', titleKey: 'settingsPanel.general.terminalFontSize', anchorId: 'settings-terminal-font-size', keywords: ['font', 'size', 'terminal', '终端', '字体'], @@ -71,7 +67,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.terminal-font-family', pageId: 'appearance', - pageLabelKey: 'settingsPanel.nav.appearance', titleKey: 'settingsPanel.general.terminalFontFamily', anchorId: 'settings-terminal-font-family', keywords: ['font', 'family', 'terminal', 'shell', '终端', '字体'], @@ -79,7 +74,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.terminal-display-calibration', pageId: 'appearance', - pageLabelKey: 'settingsPanel.nav.appearance', titleKey: 'settingsPanel.general.terminalDisplayCalibration.title', descriptionKey: 'settingsPanel.general.terminalDisplayCalibration.help', anchorId: 'settings-section-terminal-display-calibration', @@ -88,7 +82,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'general.updates', pageId: 'general', - pageLabelKey: 'settingsPanel.nav.general', titleKey: 'settingsPanel.general.updates.title', descriptionKey: 'settingsPanel.general.updates.help', anchorId: 'settings-section-updates', @@ -102,7 +95,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'worker.home', pageId: 'worker', - pageLabelKey: 'settingsPanel.nav.workerConnections', titleKey: 'settingsPanel.worker.home.title', descriptionKey: 'settingsPanel.worker.home.help', anchorId: 'settings-section-worker-home', @@ -112,7 +104,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'worker.cli', pageId: 'worker', - pageLabelKey: 'settingsPanel.nav.workerConnections', titleKey: 'settingsPanel.worker.cli.title', descriptionKey: 'settingsPanel.worker.cli.help', anchorId: 'settings-section-worker-cli', @@ -122,7 +113,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'worker.local', pageId: 'worker', - pageLabelKey: 'settingsPanel.nav.workerConnections', titleKey: 'settingsPanel.worker.local.title', descriptionKey: 'settingsPanel.worker.local.help', anchorId: 'settings-section-worker-local', @@ -131,7 +121,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'endpoints.list', pageId: 'worker', - pageLabelKey: 'settingsPanel.nav.workerConnections', titleKey: 'settingsPanel.endpoints.list.title', descriptionKey: 'settingsPanel.endpoints.list.help', anchorId: 'settings-section-endpoints', @@ -142,7 +131,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'notifications.standby-banner', pageId: 'notifications', - pageLabelKey: 'settingsPanel.nav.notifications', titleKey: 'settingsPanel.notifications.agentStandbyBanner.enabledLabel', descriptionKey: 'settingsPanel.notifications.agentStandbyBanner.enabledHelp', anchorId: 'settings-section-notifications', @@ -158,7 +146,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'notifications.system-notifications', pageId: 'notifications', - pageLabelKey: 'settingsPanel.nav.notifications', titleKey: 'settingsPanel.notifications.systemNotifications.enabledLabel', descriptionKey: 'settingsPanel.notifications.systemNotifications.enabledHelp', anchorId: 'settings-section-notifications', @@ -167,7 +154,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'canvas.input-mode', pageId: 'canvas-windows', - pageLabelKey: 'settingsPanel.nav.canvasWindows', titleKey: 'settingsPanel.canvas.inputModeLabel', descriptionKey: 'settingsPanel.canvas.inputModeHelp', anchorId: 'settings-canvas-input-mode', @@ -181,7 +167,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'canvas.wheel', pageId: 'canvas-windows', - pageLabelKey: 'settingsPanel.nav.canvasWindows', titleKey: 'settingsPanel.canvas.wheelBehaviorLabel', descriptionKey: 'settingsPanel.canvas.wheelBehaviorHelp', anchorId: 'settings-canvas-wheel-behavior', @@ -196,7 +181,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'canvas.window-size', pageId: 'canvas-windows', - pageLabelKey: 'settingsPanel.nav.canvasWindows', titleKey: 'settingsPanel.canvas.standardWindowSizeLabel', descriptionKey: 'settingsPanel.canvas.standardWindowSizeHelp', anchorId: 'settings-standard-window-size', @@ -205,7 +189,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'canvas.terminal-profile', pageId: 'canvas-windows', - pageLabelKey: 'settingsPanel.nav.canvasWindows', titleKey: 'settingsPanel.terminal.profileLabel', descriptionKey: 'settingsPanel.terminal.profileHelp', anchorId: 'settings-terminal-profile', @@ -214,7 +197,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'canvas.focus', pageId: 'canvas-windows', - pageLabelKey: 'settingsPanel.nav.canvasWindows', titleKey: 'settingsPanel.canvas.focusOnClickLabel', descriptionKey: 'settingsPanel.canvas.focusOnClickHelp', anchorId: 'settings-focus-node-on-click', @@ -228,7 +210,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'experimental.remote-workers', pageId: 'worker', - pageLabelKey: 'settingsPanel.nav.workerConnections', titleKey: 'settingsPanel.experimental.remoteWorkersTitle', descriptionKey: 'settingsPanel.experimental.remoteWorkersHelp', anchorId: 'settings-section-experimental-remote-workers', @@ -238,7 +219,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'experimental.worker-web-ui', pageId: 'worker', - pageLabelKey: 'settingsPanel.nav.workerConnections', titleKey: 'settingsPanel.experimental.workerWebUi.title', descriptionKey: 'settingsPanel.experimental.workerWebUi.help', anchorId: 'settings-section-worker-web-ui', @@ -253,7 +233,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'experimental.website-windows', pageId: 'advanced', - pageLabelKey: 'settingsPanel.nav.advanced', titleKey: 'settingsPanel.experimental.websiteWindowsTitle', descriptionKey: 'settingsPanel.experimental.websiteWindowsHelp', anchorId: 'settings-section-website-windows', @@ -270,7 +249,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'shortcuts.disable-terminal', pageId: 'tasks-shortcuts', - pageLabelKey: 'settingsPanel.nav.tasksShortcuts', titleKey: 'settingsPanel.shortcuts.disableWhenTerminalFocusedLabel', descriptionKey: 'settingsPanel.shortcuts.disableWhenTerminalFocusedHelp', anchorId: 'settings-disable-shortcuts-terminal-focused', @@ -279,7 +257,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'shortcuts.bindings', pageId: 'tasks-shortcuts', - pageLabelKey: 'settingsPanel.nav.tasksShortcuts', titleKey: 'settingsPanel.shortcuts.bindings', descriptionKey: 'settingsPanel.shortcuts.bindingsHelp', anchorId: 'settings-section-keybindings', @@ -288,7 +265,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'quick-menu.commands', pageId: 'tasks-shortcuts', - pageLabelKey: 'settingsPanel.nav.tasksShortcuts', titleKey: 'settingsPanel.quickMenu.commands.title', descriptionKey: 'settingsPanel.quickMenu.commands.help', anchorId: 'settings-section-quick-commands', @@ -302,7 +278,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'quick-menu.phrases', pageId: 'tasks-shortcuts', - pageLabelKey: 'settingsPanel.nav.tasksShortcuts', titleKey: 'settingsPanel.quickMenu.phrases.title', descriptionKey: 'settingsPanel.quickMenu.phrases.help', anchorId: 'settings-section-quick-phrases', @@ -312,7 +287,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'tasks.title-generation', pageId: 'tasks-shortcuts', - pageLabelKey: 'settingsPanel.nav.tasksShortcuts', titleKey: 'settingsPanel.tasks.titleProviderLabel', descriptionKey: 'settingsPanel.tasks.titleProviderHelp', anchorId: 'settings-task-title-provider', @@ -325,7 +299,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'tasks.tags', pageId: 'tasks-shortcuts', - pageLabelKey: 'settingsPanel.nav.tasksShortcuts', titleKey: 'settingsPanel.tasks.tagsLabel', descriptionKey: 'settingsPanel.tasks.tagsHelp', anchorId: 'settings-section-task-tags', @@ -334,7 +307,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'integrations.github-prs', pageId: 'integrations', - pageLabelKey: 'settingsPanel.nav.integrations', titleKey: 'settingsPanel.integrations.githubPullRequestsLabel', descriptionKey: 'settingsPanel.integrations.githubPullRequestsHelp', anchorId: 'settings-github-pull-requests', @@ -343,7 +315,6 @@ const CORE_SEARCH_ENTRY_DEFINITIONS: Array< { id: 'diagnostics.performance', pageId: 'advanced', - pageLabelKey: 'settingsPanel.nav.advanced', titleKey: 'settingsPanel.diagnostics.title', descriptionKey: 'settingsPanel.diagnostics.help', anchorId: 'settings-section-diagnostics', @@ -377,6 +348,11 @@ function resolveEntry( definition: CoreSettingsSearchEntryDefinition, t: TranslateFn, ): SettingsSearchEntry { + const { navLabelKey } = resolveSettingsPage(definition.pageId) + if (!navLabelKey) { + throw new Error(`Missing settings navigation label for page: ${definition.pageId}`) + } + const keywordText = [ ...(definition.keywordKeys ?? []).map(key => t(key)), ...(definition.keywords ?? []), @@ -385,7 +361,7 @@ function resolveEntry( return { id: definition.id, pageId: definition.pageId, - pageLabel: t(definition.pageLabelKey), + pageLabel: t(navLabelKey), title: t(definition.titleKey), description: definition.descriptionKey ? t(definition.descriptionKey) : undefined, anchorId: definition.anchorId, diff --git a/src/contexts/settings/presentation/renderer/useSettingsPanelDialog.ts b/src/contexts/settings/presentation/renderer/useSettingsPanelDialog.ts new file mode 100644 index 000000000..7fb436c6c --- /dev/null +++ b/src/contexts/settings/presentation/renderer/useSettingsPanelDialog.ts @@ -0,0 +1,196 @@ +import { useEffect, useRef, type RefObject } from 'react' + +const TABBABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable]:not([contenteditable="false"])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +let pendingFocusRestoreTimer: number | null = null +let activeFocusOwnerId = 0 + +function getActiveFocusOwner(): HTMLElement | null { + const activeElement = document.activeElement + return activeElement instanceof HTMLElement && activeElement !== document.body + ? activeElement + : null +} + +function focusElement(element: HTMLElement | null): boolean { + if (!element?.isConnected) { + return false + } + + element.focus({ preventScroll: true }) + return document.activeElement === element +} + +function isTabbable(element: HTMLElement): boolean { + if ( + element.tabIndex < 0 || + element.hidden || + element.closest('[inert], [hidden], [aria-hidden="true"]') + ) { + return false + } + + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' +} + +function getTabbableElements(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll(TABBABLE_SELECTOR)).filter(isTabbable) +} + +function isOwnedCoveSelectPortal(panel: HTMLElement, target: Element): boolean { + const menu = target.closest('.cove-select__menu') + if (!menu?.id) { + return false + } + + return Array.from(panel.querySelectorAll('[aria-controls]')).some( + control => control.getAttribute('aria-controls') === menu.id, + ) +} + +function cancelPendingFocusRestore(): void { + if (pendingFocusRestoreTimer === null) { + return + } + + window.clearTimeout(pendingFocusRestoreTimer) + pendingFocusRestoreTimer = null +} + +export function useSettingsPanelDialog(panelRef: RefObject): void { + const returnFocusRef = useRef( + typeof document === 'undefined' ? null : getActiveFocusOwner(), + ) + const lastPanelFocusRef = useRef(null) + + useEffect(() => { + const panel = panelRef.current + if (!panel) { + return + } + + cancelPendingFocusRestore() + const focusOwnerId = ++activeFocusOwnerId + const returnTarget = returnFocusRef.current + const backgroundStates = Array.from(document.querySelectorAll('.app-shell')).map( + element => ({ element, wasInert: element.hasAttribute('inert') }), + ) + for (const { element } of backgroundStates) { + element.setAttribute('inert', '') + } + + const focusPanel = (): void => { + const lastPanelFocus = lastPanelFocusRef.current + if (lastPanelFocus && panel.contains(lastPanelFocus) && focusElement(lastPanelFocus)) { + return + } + + const firstTabbable = getTabbableElements(panel)[0] ?? null + if (focusElement(firstTabbable)) { + lastPanelFocusRef.current = firstTabbable + return + } + + focusElement(panel) + } + + const handleFocusIn = (event: FocusEvent): void => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + if (panel.contains(target)) { + if (target instanceof HTMLElement) { + lastPanelFocusRef.current = target + } + return + } + + if (target.closest('.cove-window') || isOwnedCoveSelectPortal(panel, target)) { + return + } + + focusPanel() + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Tab' || event.defaultPrevented) { + return + } + + const target = event.target + if (!(target instanceof Node) || !panel.contains(target)) { + return + } + + const tabbableElements = getTabbableElements(panel) + const firstTabbable = tabbableElements[0] ?? null + const lastTabbable = tabbableElements.at(-1) ?? null + if (!firstTabbable || !lastTabbable) { + event.preventDefault() + focusElement(panel) + return + } + + if (event.shiftKey && target === firstTabbable) { + event.preventDefault() + focusElement(lastTabbable) + return + } + + if (!event.shiftKey && target === lastTabbable) { + event.preventDefault() + focusElement(firstTabbable) + } + } + + document.addEventListener('focusin', handleFocusIn, true) + document.addEventListener('keydown', handleKeyDown, true) + + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && panel.contains(activeElement)) { + lastPanelFocusRef.current = activeElement + } else if ( + !(activeElement instanceof Element) || + (!activeElement.closest('.cove-window') && !isOwnedCoveSelectPortal(panel, activeElement)) + ) { + focusPanel() + } + + return () => { + document.removeEventListener('focusin', handleFocusIn, true) + document.removeEventListener('keydown', handleKeyDown, true) + for (const { element, wasInert } of backgroundStates) { + if (wasInert) { + element.setAttribute('inert', '') + } else { + element.removeAttribute('inert') + } + } + + cancelPendingFocusRestore() + pendingFocusRestoreTimer = window.setTimeout(() => { + pendingFocusRestoreTimer = null + if (activeFocusOwnerId !== focusOwnerId) { + return + } + + if (focusElement(returnTarget)) { + return + } + + focusElement(document.querySelector('[data-testid="app-header-settings"]')) + }, 0) + } + }, [panelRef]) +} diff --git a/src/contexts/settings/presentation/renderer/useSettingsPanelPageState.ts b/src/contexts/settings/presentation/renderer/useSettingsPanelPageState.ts index ed1a3107c..a42e5419b 100644 --- a/src/contexts/settings/presentation/renderer/useSettingsPanelPageState.ts +++ b/src/contexts/settings/presentation/renderer/useSettingsPanelPageState.ts @@ -1,16 +1,7 @@ import { useEffect, useMemo, useState, type RefObject } from 'react' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' import { isWorkspacePageId, type SettingsPageId } from './SettingsPanel.shared' - -type StaticSettingsPageId = Exclude - -const SETTINGS_PAGE_SCROLL_TARGET: Partial> = { - endpoints: 'settings-section-endpoints', - diagnostics: 'settings-section-diagnostics', - 'quick-menu': 'settings-section-quick-commands', - shortcuts: 'settings-section-shortcuts', - 'task-configuration': 'settings-section-task-configuration', -} +import { resolveSettingsPage } from './settingsPanel/settingsPageRegistry' export function useSettingsPanelPageState(options: { openPageId?: SettingsPageId | null @@ -19,11 +10,13 @@ export function useSettingsPanelPageState(options: { onFocusNodeTargetZoomPreviewChange: (isPreviewing: boolean) => void }): { activePageId: SettingsPageId + canonicalPageId: SettingsPageId setActivePageId: (pageId: SettingsPageId) => void activeWorkspace: WorkspaceState | null } { const { openPageId, workspaces, contentRef, onFocusNodeTargetZoomPreviewChange } = options const [activePageId, setActivePageId] = useState(() => openPageId ?? 'general') + const resolvedPage = useMemo(() => resolveSettingsPage(activePageId), [activePageId]) const activeWorkspace = useMemo(() => { if (!isWorkspacePageId(activePageId)) { @@ -55,9 +48,7 @@ export function useSettingsPanelPageState(options: { contentRef.current.scrollTop = 0 - const targetId = isWorkspacePageId(activePageId) - ? undefined - : SETTINGS_PAGE_SCROLL_TARGET[activePageId] + const targetId = resolvedPage.scrollTargetId if (!targetId) { return } @@ -65,16 +56,17 @@ export function useSettingsPanelPageState(options: { window.requestAnimationFrame(() => { document.getElementById(targetId)?.scrollIntoView({ block: 'start' }) }) - }, [activePageId, contentRef]) + }, [activePageId, contentRef, resolvedPage.scrollTargetId]) useEffect(() => { - if (activePageId !== 'canvas' && activePageId !== 'canvas-windows') { + if (resolvedPage.canonicalPageId !== 'canvas-windows') { onFocusNodeTargetZoomPreviewChange(false) } - }, [activePageId, onFocusNodeTargetZoomPreviewChange]) + }, [onFocusNodeTargetZoomPreviewChange, resolvedPage.canonicalPageId]) return { activePageId, + canonicalPageId: resolvedPage.canonicalPageId, setActivePageId: nextPageId => setActivePageId(nextPageId), activeWorkspace, } diff --git a/tests/e2e/control-center.spec.ts b/tests/e2e/control-center.spec.ts index 2f830cd12..0a373eedf 100644 --- a/tests/e2e/control-center.spec.ts +++ b/tests/e2e/control-center.spec.ts @@ -73,8 +73,17 @@ test.describe('Control Center', () => { await expect(window.locator('[data-testid="control-center"]')).toBeVisible() await window.locator('[data-testid="control-center-open-settings"]').click() - await expect(window.locator('.settings-panel')).toBeVisible() + const settingsPanel = window.locator('.settings-panel') + const settingsSearch = window.locator('[data-testid="settings-panel-search"]') + await expect(settingsPanel).toBeVisible() await expect(window.locator('[data-testid="control-center"]')).toHaveCount(0) + await expect(settingsSearch).toBeFocused() + await window.waitForTimeout(100) + await expect(settingsSearch).toBeFocused() + + await window.keyboard.press('Escape') + await expect(settingsPanel).toBeHidden() + await expect(window.locator('[data-testid="app-header-settings"]')).toBeFocused() } finally { await electronApp.close() } diff --git a/tests/e2e/m6.endpoints-mounts.integration.helpers.ts b/tests/e2e/m6.endpoints-mounts.integration.helpers.ts index 0af6265de..1045893b6 100644 --- a/tests/e2e/m6.endpoints-mounts.integration.helpers.ts +++ b/tests/e2e/m6.endpoints-mounts.integration.helpers.ts @@ -240,9 +240,28 @@ export async function closeSettings(window: Page): Promise { } export async function switchSettingsPage(window: Page, pageId: string): Promise { - const nav = window.locator(`[data-testid="settings-section-nav-${pageId}"]`) + const canonicalNavId: Record = { + endpoints: 'worker', + shortcuts: 'task-configuration', + 'quick-menu': 'task-configuration', + diagnostics: 'experimental', + } + const nav = window.locator( + `[data-testid="settings-section-nav-${canonicalNavId[pageId] ?? pageId}"]`, + ) await expect(nav).toBeVisible() await nav.click({ noWaitAfter: true }) + + const legacyTargetId: Record = { + endpoints: 'settings-section-endpoints', + shortcuts: 'settings-section-shortcuts', + 'quick-menu': 'settings-section-quick-commands', + diagnostics: 'settings-section-diagnostics', + } + const targetId = legacyTargetId[pageId] + if (targetId) { + await window.locator(`#${targetId}`).scrollIntoViewIfNeeded() + } } export async function pollForEndpointPing(window: Page, endpointId: string): Promise { diff --git a/tests/e2e/performance-monitor.mac.spec.ts b/tests/e2e/performance-monitor.mac.spec.ts index 97e7a4947..3579877ee 100644 --- a/tests/e2e/performance-monitor.mac.spec.ts +++ b/tests/e2e/performance-monitor.mac.spec.ts @@ -11,7 +11,7 @@ test.describe('Performance Diagnostics (macOS)', () => { try { await window.locator('[data-testid="app-header-settings"]').click({ noWaitAfter: true }) - await window.locator('[data-testid="settings-section-nav-diagnostics"]').click() + await window.locator('[data-testid="settings-section-nav-experimental"]').click() const diagnosticsSection = window.locator('#settings-section-diagnostics') await expect(diagnosticsSection).toContainText(/Process Totals|进程汇总/) diff --git a/tests/e2e/performance-monitor.windows.spec.ts b/tests/e2e/performance-monitor.windows.spec.ts index b238038fd..7a32f54ff 100644 --- a/tests/e2e/performance-monitor.windows.spec.ts +++ b/tests/e2e/performance-monitor.windows.spec.ts @@ -13,7 +13,7 @@ test.describe('Performance Monitor (Windows)', () => { void browserName await window.locator('[data-testid="app-header-settings"]').click({ noWaitAfter: true }) - await window.locator('[data-testid="settings-section-nav-diagnostics"]').click() + await window.locator('[data-testid="settings-section-nav-experimental"]').click() const headerToggle = window.locator( '[data-testid="settings-performance-monitor-header-button-enabled"]', diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts index a5129ba1f..daee18479 100644 --- a/tests/e2e/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -53,7 +53,7 @@ test.describe('Settings', () => { await expect(languageTrigger).toBeVisible() await selectCoveOption(window, 'settings-language', 'zh-CN') await expect(languageSelect).toHaveValue('zh-CN') - await expect(window.locator('.settings-panel__header h2')).toHaveText('设置') + await expect(window.locator('.settings-panel__header h2')).toHaveText('通用') await appearanceNav.click() diff --git a/tests/e2e/settings.visual.spec.ts b/tests/e2e/settings.visual.spec.ts new file mode 100644 index 000000000..7141a7d16 --- /dev/null +++ b/tests/e2e/settings.visual.spec.ts @@ -0,0 +1,397 @@ +import { + expect, + test, + type ElectronApplication, + type Locator, + type Page, + type TestInfo, +} from '@playwright/test' +import { launchApp, selectCoveOption } from './workspace-canvas.helpers' + +const NARROW_WINDOW_SIZE = { width: 720, height: 650 } + +const PRIMARY_SETTINGS_PAGE_CAPTURES = [ + { id: 'general', navTestId: 'settings-section-nav-general' }, + { id: 'appearance', navTestId: 'settings-section-nav-appearance' }, + { id: 'notifications', navTestId: 'settings-section-nav-notifications' }, + { id: 'canvas-windows', navTestId: 'settings-section-nav-canvas' }, + { id: 'agent', navTestId: 'settings-section-nav-agent' }, + { id: 'tasks-shortcuts', navTestId: 'settings-section-nav-task-configuration' }, + { id: 'worker', navTestId: 'settings-section-nav-worker' }, + { id: 'integrations', navTestId: 'settings-section-nav-integrations' }, + { id: 'advanced', navTestId: 'settings-section-nav-experimental' }, +] as const + +async function openSettings(window: Page): Promise { + const settingsTrigger = window.locator('[data-testid="app-header-settings"]') + await expect(settingsTrigger).toBeVisible() + await settingsTrigger.click({ noWaitAfter: true }) + + const dialog = window.getByRole('dialog') + await expect(dialog).toBeVisible() + return dialog +} + +async function readHeadingText(heading: Locator): Promise { + const text = (await heading.textContent())?.trim() ?? '' + expect(text.length).toBeGreaterThan(0) + return text +} + +async function expectNoHorizontalOverflow(locator: Locator, label: string): Promise { + const metrics = await locator.evaluate(element => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })) + + expect( + metrics.scrollWidth, + `${label} overflowed horizontally: ${JSON.stringify(metrics)}`, + ).toBeLessThanOrEqual(metrics.clientWidth + 1) +} + +async function pressKeyTimes(locator: Locator, key: string, remaining: number): Promise { + if (remaining <= 0) { + return + } + + await locator.press(key) + await pressKeyTimes(locator, key, remaining - 1) +} + +async function selectThemeAndAttachPanel( + window: Page, + dialog: Locator, + theme: 'dark' | 'light', + testInfo: TestInfo, +): Promise { + await selectTheme(window, theme) + + const screenshot = await dialog.screenshot({ animations: 'disabled' }) + await testInfo.attach(`settings-panel-${theme}`, { + body: screenshot, + contentType: 'image/png', + }) +} + +async function selectTheme(window: Page, theme: 'dark' | 'light'): Promise { + await selectCoveOption(window, 'settings-ui-theme', theme) + await expect + .poll(async () => { + return await window.evaluate(() => document.documentElement.dataset.coveTheme ?? null) + }) + .toBe(theme) +} + +async function verifyWorkerHierarchyAndAttach( + window: Page, + dialog: Locator, + theme: 'dark' | 'light', + testInfo: TestInfo, +): Promise { + await dialog.locator('[data-testid="settings-section-nav-appearance"]').click() + await selectTheme(window, theme) + await dialog.locator('[data-testid="settings-section-nav-worker"]').click() + + const runtime = dialog.getByRole('group', { name: 'Worker 运行', exact: true }) + const body = runtime.locator(':scope > .settings-panel__group-body') + const workerMode = runtime.getByRole('group', { name: 'Worker 模式' }) + const cli = runtime.getByRole('group', { name: 'CLI', exact: true }) + const localWorker = runtime.getByRole('group', { name: '本机 Worker' }) + await expect(body).toHaveCount(1) + await expect(workerMode).toBeVisible() + await expect(cli.getByTestId('settings-worker-cli-status')).toBeVisible() + await expect(localWorker.getByTestId('settings-worker-local-status')).toBeVisible() + + await expect(runtime.locator('.settings-panel__worker-module')).toHaveCount(0) + + const groupSurface = await body.evaluate(element => { + const style = getComputedStyle(element) + return { + borderRadius: style.borderRadius, + boxShadow: style.boxShadow, + overflow: style.overflow, + } + }) + expect(groupSurface.borderRadius).toBe('12px') + expect(groupSurface.boxShadow).toContain('inset') + expect(groupSurface.overflow).toBe('hidden') + + const moduleSurface = await workerMode.evaluate(element => { + const style = getComputedStyle(element) + return { backgroundColor: style.backgroundColor, borderRadius: style.borderRadius } + }) + expect(moduleSurface).toEqual({ backgroundColor: 'rgba(0, 0, 0, 0)', borderRadius: '0px' }) + await expectNoHorizontalOverflow( + dialog.locator('.settings-panel__content'), + `${theme} Worker settings content`, + ) + + const screenshot = await dialog.screenshot({ animations: 'disabled' }) + await testInfo.attach(`settings-worker-hierarchy-${theme}`, { + body: screenshot, + contentType: 'image/png', + }) +} + +async function capturePrimarySettingsPages( + dialog: Locator, + testInfo: TestInfo, + pageIndex = 0, +): Promise { + const page = PRIMARY_SETTINGS_PAGE_CAPTURES[pageIndex] + if (!page) { + return + } + + await dialog.locator(`[data-testid="${page.navTestId}"]`).click() + const groups = dialog.locator('.settings-panel__group') + await expect(groups.first()).toBeVisible() + expect( + await groups.evaluateAll( + elements => + elements.filter(element => { + const header = element.querySelector(':scope > .settings-panel__group-header') + const heading = header?.querySelector(':scope > .settings-panel__group-title') + return !header || !heading + }).length, + ), + ).toBe(0) + await expect( + dialog.locator('.settings-panel__group-body .settings-panel__group-body'), + ).toHaveCount(0) + await expectNoHorizontalOverflow( + dialog.locator('.settings-panel__content'), + `${page.id} settings content`, + ) + + const screenshot = await dialog.screenshot({ animations: 'disabled' }) + await testInfo.attach(`settings-groups-${page.id}-dark`, { + body: screenshot, + contentType: 'image/png', + }) + + await capturePrimarySettingsPages(dialog, testInfo, pageIndex + 1) +} + +async function resizeMainWindow( + electronApp: ElectronApplication, + requestedSize: { width: number; height: number }, +): Promise<{ width: number; height: number }> { + await expect + .poll(async () => { + return await electronApp.evaluate(({ BrowserWindow }) => { + return BrowserWindow.getAllWindows()[0]?.isVisible() ?? false + }) + }) + .toBe(true) + + return await electronApp.evaluate(({ BrowserWindow }, size) => { + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + throw new Error('Expected the OpenCove main BrowserWindow') + } + + const currentBounds = mainWindow.getBounds() + mainWindow.setBounds( + { + x: currentBounds.x, + y: currentBounds.y, + width: size.width, + height: size.height, + }, + false, + ) + mainWindow.setContentSize(size.width, size.height, false) + + const [width, height] = mainWindow.getContentSize() + return { width, height } + }, requestedSize) +} + +test.describe('Settings visual shell', () => { + test('uses an accessible page-aware dialog and captures both themes', async ({ + browserName, + }, testInfo) => { + const { electronApp, window } = await launchApp() + + try { + void browserName + const dialog = await openSettings(window) + await expect(dialog).toHaveAttribute('aria-modal', 'true') + const heading = dialog.getByRole('heading', { level: 2 }) + const initialTitle = await readHeadingText(heading) + await expect(dialog).toHaveAccessibleName(initialTitle) + + await dialog.locator('[data-testid="settings-section-nav-appearance"]').click() + const appearanceTitle = await readHeadingText(heading) + expect(appearanceTitle).not.toBe(initialTitle) + await expect(dialog).toHaveAccessibleName(appearanceTitle) + + await expectNoHorizontalOverflow(dialog, 'settings dialog') + await expectNoHorizontalOverflow( + dialog.locator('.settings-panel__content-wrapper'), + 'settings content wrapper', + ) + await expectNoHorizontalOverflow( + dialog.locator('.settings-panel__content'), + 'settings scroll content', + ) + + await selectThemeAndAttachPanel(window, dialog, 'dark', testInfo) + await selectThemeAndAttachPanel(window, dialog, 'light', testInfo) + } finally { + await electronApp.close() + } + }) + + test('adapts navigation and keyboard search to a narrow window', async ({ + browserName, + }, testInfo) => { + const { electronApp, window } = await launchApp() + + try { + void browserName + const actualSize = await resizeMainWindow(electronApp, NARROW_WINDOW_SIZE) + expect(Math.abs(actualSize.width - NARROW_WINDOW_SIZE.width)).toBeLessThanOrEqual(2) + expect(Math.abs(actualSize.height - NARROW_WINDOW_SIZE.height)).toBeLessThanOrEqual(2) + await expect + .poll(async () => await window.evaluate(() => window.innerWidth)) + .toBe(actualSize.width) + + const dialog = await openSettings(window) + const sidebar = dialog.locator('.settings-panel__sidebar') + const contentWrapper = dialog.locator('.settings-panel__content-wrapper') + await expect(sidebar).toBeVisible() + await expect(sidebar.locator('.settings-panel__nav-sections')).toHaveCount(1) + await expect(sidebar.locator('.settings-panel__nav-sections')).toBeHidden() + await expect( + sidebar.locator('[data-testid="settings-panel-page-selector-trigger"]'), + ).toBeVisible() + + const sidebarBox = await sidebar.boundingBox() + const contentBox = await contentWrapper.boundingBox() + expect(sidebarBox).not.toBeNull() + expect(contentBox).not.toBeNull() + expect(sidebarBox?.y ?? Number.POSITIVE_INFINITY).toBeLessThan( + contentBox?.y ?? Number.NEGATIVE_INFINITY, + ) + + const search = sidebar.locator('[data-testid="settings-panel-search"]') + await expect(search).toBeVisible() + await search.fill('e') + const searchResults = sidebar.locator('[data-testid="settings-panel-search-results"]') + const searchOptions = searchResults.getByRole('option') + await expect(searchOptions).toHaveCount(8) + await pressKeyTimes(search, 'ArrowDown', 8) + const activeResultId = await search.getAttribute('aria-activedescendant') + expect(activeResultId).toBeTruthy() + const activeResultIsVisible = await searchResults.evaluate((results, resultId) => { + const activeResult = resultId ? document.getElementById(resultId) : null + if (!activeResult) { + return false + } + + const resultsRect = results.getBoundingClientRect() + const activeRect = activeResult.getBoundingClientRect() + return activeRect.top >= resultsRect.top && activeRect.bottom <= resultsRect.bottom + }, activeResultId) + expect(activeResultIsVisible).toBe(true) + + await search.press('Escape') + await search.fill('theme') + await search.press('ArrowDown') + await search.press('Enter') + await expect(dialog.locator('[data-testid="settings-ui-theme-trigger"]')).toBeVisible() + + await expectNoHorizontalOverflow(dialog, 'narrow settings dialog') + await expectNoHorizontalOverflow(contentWrapper, 'narrow settings content wrapper') + await expectNoHorizontalOverflow( + dialog.locator('.settings-panel__content'), + 'narrow settings scroll content', + ) + + const screenshot = await dialog.screenshot({ animations: 'disabled' }) + await testInfo.attach('settings-panel-narrow', { + body: screenshot, + contentType: 'image/png', + }) + } finally { + await electronApp.close() + } + }) + + test('groups Worker controls into distinct modules in both themes', async ({ + browserName, + }, testInfo) => { + const { electronApp, window } = await launchApp() + + try { + void browserName + const dialog = await openSettings(window) + await dialog.locator('[data-testid="settings-section-nav-general"]').click() + await selectCoveOption(window, 'settings-language', 'zh-CN') + await expect(dialog.locator('[data-testid="settings-language"]')).toHaveValue('zh-CN') + + await verifyWorkerHierarchyAndAttach(window, dialog, 'dark', testInfo) + await verifyWorkerHierarchyAndAttach(window, dialog, 'light', testInfo) + } finally { + await electronApp.close() + } + }) + + test('captures a consistent grouped hierarchy across every primary page', async ({ + browserName, + }, testInfo) => { + const { electronApp, window } = await launchApp() + + try { + void browserName + const dialog = await openSettings(window) + await dialog.locator('[data-testid="settings-section-nav-general"]').click() + await selectCoveOption(window, 'settings-language', 'zh-CN') + await dialog.locator('[data-testid="settings-section-nav-appearance"]').click() + await selectTheme(window, 'dark') + + await capturePrimarySettingsPages(dialog, testInfo) + } finally { + await electronApp.close() + } + }) + + test('closes with Escape and restores focus to the settings trigger', async ({ browserName }) => { + const { electronApp, window } = await launchApp() + + try { + void browserName + const settingsTrigger = window.locator('[data-testid="app-header-settings"]') + const dialog = await openSettings(window) + const search = dialog.locator('[data-testid="settings-panel-search"]') + + await expect(window.locator('.app-shell')).toHaveAttribute('inert', '') + await expect(search).toBeFocused() + await search.press('Shift+Tab') + expect(await dialog.evaluate(panel => panel.contains(document.activeElement))).toBe(true) + + await dialog.locator('[data-testid="settings-section-nav-appearance"]').click() + const themeTrigger = dialog.locator('[data-testid="settings-ui-theme-trigger"]') + await themeTrigger.focus() + await themeTrigger.press('Enter') + const themeMenu = window.locator('[data-testid="settings-ui-theme-menu"]') + await expect(themeMenu).toBeVisible() + expect(await themeMenu.evaluate(menu => menu.parentElement === document.body)).toBe(true) + + await window.keyboard.press('Escape') + await expect(themeMenu).toBeHidden() + await expect(dialog).toBeVisible() + + await window.keyboard.press('Escape') + + await expect(dialog).toBeHidden() + await expect(window.locator('.app-shell')).not.toHaveAttribute('inert', '') + await expect(settingsTrigger).toBeFocused() + } finally { + await electronApp.close() + } + }) +}) diff --git a/tests/e2e/shortcuts.spec.ts b/tests/e2e/shortcuts.spec.ts index ded38ab11..a7ad091b8 100644 --- a/tests/e2e/shortcuts.spec.ts +++ b/tests/e2e/shortcuts.spec.ts @@ -7,7 +7,7 @@ async function openShortcutsSettings(window: Page): Promise { await window.locator('[data-testid="app-header-settings"]').click() await expect(window.locator('.settings-panel')).toBeVisible() - await window.locator('[data-testid="settings-section-nav-shortcuts"]').click() + await window.locator('[data-testid="settings-section-nav-task-configuration"]').click() await expect(window.locator('#settings-section-shortcuts')).toBeVisible() } diff --git a/tests/unit/contexts/settingsPanel.dialogFocus.spec.tsx b/tests/unit/contexts/settingsPanel.dialogFocus.spec.tsx new file mode 100644 index 000000000..4ce679a22 --- /dev/null +++ b/tests/unit/contexts/settingsPanel.dialogFocus.spec.tsx @@ -0,0 +1,176 @@ +import React, { StrictMode, useRef } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CoveSelect } from '../../../src/app/renderer/components/CoveSelect' +import { useSettingsPanelDialog } from '../../../src/contexts/settings/presentation/renderer/useSettingsPanelDialog' + +function DialogHarness({ withSelect = false }: { withSelect?: boolean }): React.JSX.Element { + const panelRef = useRef(null) + useSettingsPanelDialog(panelRef) + + return ( +
+ + {withSelect ? ( + undefined} + /> + ) : null} + +
+ ) +} + +function appendBackground(): { + appShell: HTMLDivElement + settingsTrigger: HTMLButtonElement + temporaryTrigger: HTMLButtonElement + outsideButton: HTMLButtonElement +} { + const appShell = document.createElement('div') + appShell.className = 'app-shell' + + const settingsTrigger = document.createElement('button') + settingsTrigger.dataset.testid = 'app-header-settings' + settingsTrigger.textContent = 'Settings' + appShell.append(settingsTrigger) + + const temporaryTrigger = document.createElement('button') + temporaryTrigger.textContent = 'Temporary settings shortcut' + appShell.append(temporaryTrigger) + + const outsideButton = document.createElement('button') + outsideButton.textContent = 'Outside action' + + document.body.append(appShell, outsideButton) + return { appShell, settingsTrigger, temporaryTrigger, outsideButton } +} + +afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + document.body.replaceChildren() +}) + +describe('useSettingsPanelDialog', () => { + it('makes only the background app shell inert while mounted', () => { + const { appShell, settingsTrigger, outsideButton } = appendBackground() + settingsTrigger.focus() + + const modal = render() + + expect(appShell).toHaveAttribute('inert') + expect(outsideButton).not.toHaveAttribute('inert') + + modal.unmount() + expect(appShell).not.toHaveAttribute('inert') + }) + + it('pulls focus from an unrelated external element back to the last panel control', () => { + const { settingsTrigger, outsideButton } = appendBackground() + settingsTrigger.focus() + render() + const lastSetting = screen.getByRole('button', { name: 'Last setting' }) + lastSetting.focus() + + outsideButton.focus() + + expect(lastSetting).toHaveFocus() + }) + + it('wraps Shift+Tab and Tab at the panel boundary', () => { + const { settingsTrigger } = appendBackground() + settingsTrigger.focus() + render() + const firstSetting = screen.getByRole('button', { name: 'First setting' }) + const lastSetting = screen.getByRole('button', { name: 'Last setting' }) + + firstSetting.focus() + const backwardTab = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }) + firstSetting.dispatchEvent(backwardTab) + + expect(backwardTab.defaultPrevented).toBe(true) + expect(lastSetting).toHaveFocus() + + const forwardTab = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }) + lastSetting.dispatchEvent(forwardTab) + + expect(forwardTab.defaultPrevented).toBe(true) + expect(firstSetting).toHaveFocus() + }) + + it('falls back to the header settings trigger when the opening control was removed', () => { + vi.useFakeTimers() + const { settingsTrigger, temporaryTrigger } = appendBackground() + temporaryTrigger.focus() + const modal = render() + temporaryTrigger.remove() + + modal.unmount() + act(() => { + vi.runOnlyPendingTimers() + }) + + expect(settingsTrigger).toHaveFocus() + }) + + it('does not let StrictMode effect cleanup steal focus from the mounted panel', () => { + vi.useFakeTimers() + const { settingsTrigger } = appendBackground() + settingsTrigger.focus() + + render( + + + , + ) + const firstSetting = screen.getByRole('button', { name: 'First setting' }) + + expect(firstSetting).toHaveFocus() + act(() => { + vi.runOnlyPendingTimers() + }) + expect(firstSetting).toHaveFocus() + }) + + it('allows focus in a CoveSelect body portal and a nested cove window', () => { + const { settingsTrigger } = appendBackground() + settingsTrigger.focus() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Theme' })) + const portalOption = screen.getByRole('option', { name: 'System' }) + expect(portalOption.closest('.cove-select__menu')?.parentElement).toBe(document.body) + portalOption.focus() + expect(portalOption).toHaveFocus() + + const nestedWindow = document.createElement('div') + nestedWindow.className = 'cove-window' + const nestedWindowControl = document.createElement('button') + nestedWindowControl.textContent = 'Nested dialog action' + nestedWindow.append(nestedWindowControl) + document.body.append(nestedWindow) + + nestedWindowControl.focus() + expect(nestedWindowControl).toHaveFocus() + }) +}) diff --git a/tests/unit/contexts/settingsPanel.formAccessibility.spec.tsx b/tests/unit/contexts/settingsPanel.formAccessibility.spec.tsx new file mode 100644 index 000000000..3300bd84b --- /dev/null +++ b/tests/unit/contexts/settingsPanel.formAccessibility.spec.tsx @@ -0,0 +1,308 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { applyUiLanguage } from '../../../src/app/renderer/i18n' +import * as systemFontsHook from '../../../src/app/renderer/shell/hooks/useSystemFonts' +import { DEFAULT_AGENT_SETTINGS } from '../../../src/contexts/settings/domain/agentSettings' +import { AgentProviderConfigurePanel } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/AgentProviderConfigurePanel' +import { AppearanceSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/AppearanceSection' +import { CanvasSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/CanvasSection' +import { ExperimentalSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalSection' +import { ExperimentalWorkerWebUiSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/ExperimentalWorkerWebUiSection' +import { GeneralSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection' +import { TaskConfigurationSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/TaskConfigurationSection' +import { TerminalProfileSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/TerminalProfileSection' +import { WorkerSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection' +import { WorkspaceSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/WorkspaceSection' + +const noop = (): void => undefined + +function expectNamedTestId(testId: string, name: string): void { + expect(screen.getByTestId(testId)).toHaveAccessibleName(name) +} + +function installWorkerApi(mode: 'local' | 'remote'): void { + const config = { + version: 1, + mode, + remote: + mode === 'remote' + ? { hostname: 'worker.example.com', port: 4317, token: 'worker-token' } + : null, + webUi: { + enabled: true, + port: 4318, + exposeOnLan: false, + passwordSet: true, + }, + updatedAt: null, + } + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + value: { + meta: { isPackaged: false }, + workerClient: { + getConfig: vi.fn().mockResolvedValue(config), + setConfig: vi.fn().mockResolvedValue(config), + setWebUiSettings: vi.fn().mockResolvedValue(config), + setWebUiSecurity: vi.fn().mockResolvedValue(config), + relaunch: vi.fn(), + }, + worker: { + getStatus: vi.fn().mockResolvedValue({ status: 'stopped', connection: null }), + start: vi.fn(), + stop: vi.fn(), + getWebUiUrl: vi.fn(), + }, + cli: { + getStatus: vi.fn().mockResolvedValue({ installed: false, path: null, healthy: false }), + install: vi.fn(), + uninstall: vi.fn(), + }, + clipboard: { writeText: vi.fn() }, + }, + }) +} + +describe('Settings form accessible names', () => { + beforeEach(async () => { + await applyUiLanguage('en') + vi.spyOn(systemFontsHook, 'useSystemFonts').mockReturnValue({ + fonts: [], + isLoading: false, + }) + }) + + afterEach(() => { + delete (window as { opencoveApi?: unknown }).opencoveApi + vi.restoreAllMocks() + }) + + it('names General and terminal profile select triggers by their setting context', () => { + render( + <> + + + , + ) + + expectNamedTestId('settings-language-trigger', 'Display Language') + expectNamedTestId('settings-update-policy-trigger', 'Update Behavior') + expectNamedTestId('settings-update-channel-trigger', 'Release Channel') + expectNamedTestId('settings-standard-window-size-trigger', 'Standard Window Size') + expectNamedTestId('settings-terminal-profile-trigger', 'Shell Profile') + }) + + it('names appearance inputs, theme selection, and terminal font controls', () => { + render( + , + ) + + expectNamedTestId('settings-ui-theme-trigger', 'Appearance') + expectNamedTestId('settings-ui-font-size', 'Interface Font Size') + expectNamedTestId('settings-terminal-font-size', 'Terminal Font Size') + expectNamedTestId('settings-terminal-font-family', 'Terminal Font') + + fireEvent.click(screen.getByTestId('settings-terminal-font-family')) + expect(screen.getByRole('textbox', { name: 'Search fonts…' })).toBeVisible() + }) + + it('names canvas select and zoom controls by their setting context', () => { + render( + , + ) + + expectNamedTestId('settings-canvas-input-mode-trigger', 'Input Mode') + expectNamedTestId('settings-canvas-wheel-behavior-trigger', 'Mouse Wheel') + expectNamedTestId('settings-canvas-wheel-zoom-modifier-trigger', 'Zoom Modifier') + expectNamedTestId('settings-focus-node-target-zoom', 'Target Zoom') + }) + + it('names task and experimental form controls', () => { + render( + <> + + + , + ) + + expectNamedTestId('settings-task-title-provider-trigger', 'Title Provider') + expectNamedTestId('settings-task-title-model', 'Title Model') + expectNamedTestId('settings-task-tag-add-input', 'Add tag') + expectNamedTestId('settings-website-window-default-mode-trigger', 'Default Open Mode') + expectNamedTestId('settings-browser-search-engine-trigger', 'Default Search Engine') + expectNamedTestId('settings-website-window-max-active', 'Max Active') + expectNamedTestId('settings-website-window-discard-after', 'Discard After') + expectNamedTestId( + 'settings-website-keep-alive-add-input', + 'Add host pattern (e.g. *.figma.com)', + ) + }) + + it('names model and dynamic agent environment inputs with provider and row context', () => { + const settings = { + ...DEFAULT_AGENT_SETTINGS, + customModelEnabledByProvider: { + ...DEFAULT_AGENT_SETTINGS.customModelEnabledByProvider, + 'claude-code': true, + }, + agentEnvByProvider: { + ...DEFAULT_AGENT_SETTINGS.agentEnvByProvider, + 'claude-code': [{ id: 'env-1', key: 'ANTHROPIC_API_KEY', value: 'secret', enabled: true }], + }, + } + + render( + , + ) + + expectNamedTestId('settings-custom-model-add-input-claude-code', 'Claude Code Add model...') + expect( + screen.getByRole('textbox', { name: 'Claude Code ANTHROPIC_API_KEY variable name' }), + ).toBeVisible() + expect( + screen.getByRole('textbox', { name: 'Claude Code ANTHROPIC_API_KEY variable value' }), + ).toBeVisible() + }) + + it('names Worker mode, remote connection, and Web UI security controls', async () => { + installWorkerApi('remote') + render( + <> + + + , + ) + + expectNamedTestId('settings-worker-home-mode-trigger', 'Worker Mode') + expectNamedTestId('settings-experimental-worker-web-ui-port', 'Port') + expectNamedTestId('settings-experimental-worker-web-ui-password', 'Password') + expect(await screen.findByTestId('settings-worker-remote-hostname')).toHaveAccessibleName( + 'Hostname', + ) + expectNamedTestId('settings-worker-remote-port', 'Port') + expectNamedTestId('settings-worker-remote-token', 'Token') + }) + + it('names project worktree and environment variable inputs', () => { + render( + , + ) + + expectNamedTestId('settings-worktree-root', 'Worktree Root') + expectNamedTestId('settings-env-var-key-input', 'Environment variable name') + expectNamedTestId('settings-env-var-value-input', 'Environment variable value') + }) + + it('keeps newly introduced environment input names localized in Chinese', async () => { + await applyUiLanguage('zh-CN') + + render( + , + ) + + expectNamedTestId('settings-worktree-root', 'Worktree 根目录') + expectNamedTestId('settings-env-var-key-input', '环境变量名') + expectNamedTestId('settings-env-var-value-input', '环境变量值') + }) +}) diff --git a/tests/unit/contexts/settingsPanel.groupHierarchy.spec.tsx b/tests/unit/contexts/settingsPanel.groupHierarchy.spec.tsx new file mode 100644 index 000000000..581245204 --- /dev/null +++ b/tests/unit/contexts/settingsPanel.groupHierarchy.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { applyUiLanguage } from '../../../src/app/renderer/i18n' +import { GeneralSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/GeneralSection' +import { WorkerSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection' +import { installSettingsPanelWorkerApi } from './settingsPanelWorkerApiTestUtils' + +const noop = (): void => undefined + +function expectSettingsGroup(name: string): { + group: HTMLElement + body: HTMLElement +} { + const group = screen.getByRole('group', { name }) + const directChildren = Array.from(group.children) + const header = directChildren.find(element => + element.classList.contains('settings-panel__group-header'), + ) + const body = directChildren.find(element => + element.classList.contains('settings-panel__group-body'), + ) + const heading = within(group).getByRole('heading', { level: 3, name }) + + expect(header).toBeInstanceOf(HTMLElement) + expect(body).toBeInstanceOf(HTMLElement) + expect(header).toContainElement(heading) + expect(body).not.toContainElement(heading) + + return { group, body: body as HTMLElement } +} + +describe('Settings group hierarchy', () => { + beforeEach(async () => { + await applyUiLanguage('en') + }) + + afterEach(() => { + delete (window as { opencoveApi?: unknown }).opencoveApi + vi.restoreAllMocks() + }) + + it('uses named groups with separate headers and bodies for General settings', () => { + render( + , + ) + + expectSettingsGroup('Language & Region') + expectSettingsGroup('Software Updates') + }) + + it('uses one Worker Runtime body with module headings instead of nested module cards', async () => { + installSettingsPanelWorkerApi() + const { container } = render() + + expect(await screen.findByTestId('settings-worker-cli-status')).toBeVisible() + const { group, body } = expectSettingsGroup('Worker Runtime') + expect(group.querySelectorAll('.settings-panel__group-body')).toHaveLength(1) + + for (const moduleName of ['Worker Mode', 'CLI', 'Local Worker']) { + expect(within(body).getByRole('heading', { level: 4, name: moduleName })).toBeVisible() + } + + expect(within(body).getByTestId('settings-worker-cli-status')).toBeVisible() + expect(within(body).getByTestId('settings-worker-local-start')).toBeVisible() + expect(container.querySelector('.settings-panel__worker-module')).not.toBeInTheDocument() + }) +}) diff --git a/tests/unit/contexts/settingsPanel.searchKeyboard.spec.tsx b/tests/unit/contexts/settingsPanel.searchKeyboard.spec.tsx new file mode 100644 index 000000000..90f676028 --- /dev/null +++ b/tests/unit/contexts/settingsPanel.searchKeyboard.spec.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SettingsPanelSidebar } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/SettingsPanelSidebar' +import type { SettingsSearchResult } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex' + +const searchHarness = vi.hoisted(() => ({ + currentResults: [] as SettingsSearchResult[], +})) + +vi.mock( + '../../../src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex', + async importOriginal => { + const actual = + await importOriginal< + typeof import('../../../src/contexts/settings/presentation/renderer/settingsPanel/settingsSearchIndex') + >() + + return { + ...actual, + searchSettingsEntries: vi.fn(() => searchHarness.currentResults), + } + }, +) + +function createSearchResult(id: string): SettingsSearchResult { + return { + id, + pageId: 'general', + pageLabel: 'General', + title: `Setting ${id}`, + anchorId: `anchor-${id}`, + keywords: [], + score: 100, + } +} + +function createSidebarProps(endpointsEnabled = false) { + return { + activePageId: 'general' as const, + workspaces: [], + endpointsEnabled, + onSelectPage: vi.fn(), + onSelectSearchResult: vi.fn(), + } +} + +describe('SettingsPanelSidebar search keyboard navigation', () => { + let originalScrollIntoView: PropertyDescriptor | undefined + const scrollIntoView = vi.fn() + + beforeEach(() => { + searchHarness.currentResults = [] + scrollIntoView.mockClear() + originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'scrollIntoView', + ) + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + writable: true, + value: scrollIntoView, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView) + } else { + delete (HTMLElement.prototype as Partial).scrollIntoView + } + }) + + it('scrolls the active option into view while navigating all eight visible results', () => { + searchHarness.currentResults = Array.from({ length: 8 }, (_, index) => + createSearchResult(`result-${index + 1}`), + ) + render() + + const search = screen.getByRole('searchbox', { name: 'Search settings' }) + fireEvent.change(search, { target: { value: 'setting' } }) + for (let index = 0; index < 8; index += 1) { + fireEvent.keyDown(search, { key: 'ArrowDown', code: 'ArrowDown' }) + } + + expect(screen.getByTestId('settings-panel-search-result-result-8')).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(scrollIntoView).toHaveBeenCalledTimes(8) + expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'nearest' }) + }) + + it('keeps the active result valid when a rerender shortens and reorders the results', () => { + const initialResults = Array.from({ length: 8 }, (_, index) => + createSearchResult(`result-${index + 1}`), + ) + const retainedResult = initialResults[7] + const props = createSidebarProps() + searchHarness.currentResults = initialResults + const { rerender } = render() + + const search = screen.getByRole('searchbox', { name: 'Search settings' }) + fireEvent.change(search, { target: { value: 'setting' } }) + for (let index = 0; index < 8; index += 1) { + fireEvent.keyDown(search, { key: 'ArrowDown', code: 'ArrowDown' }) + } + + searchHarness.currentResults = [retainedResult, createSearchResult('result-new')] + rerender() + + expect(search).toHaveAttribute('aria-activedescendant', 'settings-panel-search-result-result-8') + expect(search.getAttribute('aria-activedescendant')).not.toContain('undefined') + expect(() => { + fireEvent.keyDown(search, { key: 'Enter', code: 'Enter' }) + }).not.toThrow() + expect(props.onSelectSearchResult).toHaveBeenCalledWith(retainedResult) + }) +}) diff --git a/tests/unit/contexts/settingsPanel.shell.spec.tsx b/tests/unit/contexts/settingsPanel.shell.spec.tsx new file mode 100644 index 000000000..84f23ae8d --- /dev/null +++ b/tests/unit/contexts/settingsPanel.shell.spec.tsx @@ -0,0 +1,167 @@ +import React from 'react' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as terminalProfilesHook from '../../../src/app/renderer/shell/hooks/useTerminalProfiles' +import { + AGENT_PROVIDERS, + DEFAULT_AGENT_SETTINGS, +} from '../../../src/contexts/settings/domain/agentSettings' +import { SettingsPanel } from '../../../src/contexts/settings/presentation/renderer/SettingsPanel' +import type { WorkspaceState } from '../../../src/contexts/workspace/presentation/renderer/types' + +type SettingsPanelProps = React.ComponentProps + +function createModelCatalog(): SettingsPanelProps['modelCatalogByProvider'] { + return Object.fromEntries( + AGENT_PROVIDERS.map(provider => [ + provider, + { + models: [], + source: null, + fetchedAt: null, + isLoading: false, + error: null, + }, + ]), + ) as SettingsPanelProps['modelCatalogByProvider'] +} + +function renderSettingsPanel(overrides: Partial = {}) { + vi.spyOn(terminalProfilesHook, 'useTerminalProfiles').mockReturnValue({ + terminalProfiles: [], + detectedDefaultTerminalProfileId: null, + refreshTerminalProfiles: async () => undefined, + }) + + return render( + undefined} + onWorkspaceEnvironmentVariablesChange={() => undefined} + isFocusNodeTargetZoomPreviewing={false} + onFocusNodeTargetZoomPreviewChange={() => undefined} + onChange={() => undefined} + onCheckForUpdates={() => undefined} + onDownloadUpdate={() => undefined} + onInstallUpdate={() => undefined} + onClose={() => undefined} + {...overrides} + />, + ) +} + +function createWorkspace(): WorkspaceState { + return { + id: 'workspace-1', + name: 'Cove App', + path: '/Users/example/cove', + worktreesRoot: '.opencove/worktrees', + environmentVariables: {}, + nodes: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + spaces: [], + activeSpaceId: null, + spaceArchiveRecords: [], + } +} + +describe('SettingsPanel shell', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes modal dialog semantics and a meaningfully named close button', () => { + renderSettingsPanel() + + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(within(dialog).getByRole('button', { name: /close/i })).toBeVisible() + }) + + it('updates the page heading when primary navigation changes pages', () => { + renderSettingsPanel() + + fireEvent.click(screen.getByTestId('settings-section-nav-appearance')) + + expect(screen.getByRole('heading', { level: 2, name: 'Display & Fonts' })).toBeVisible() + }) + + it('groups primary navigation and omits secondary aliases from the sidebar', () => { + renderSettingsPanel() + + const sidebar = screen.getByLabelText('Settings sections') + for (const groupName of ['Application', 'Workspace', 'Connections', 'Advanced']) { + expect(within(sidebar).getByRole('group', { name: groupName })).toBeVisible() + } + + expect(within(sidebar).queryByTestId('settings-section-nav-shortcuts')).not.toBeInTheDocument() + expect(within(sidebar).queryByTestId('settings-section-nav-quick-menu')).not.toBeInTheDocument() + expect( + within(sidebar).queryByTestId('settings-section-nav-diagnostics'), + ).not.toBeInTheDocument() + }) + + it('selects the theme search result with ArrowDown and Enter', () => { + renderSettingsPanel() + + const search = screen.getByRole('searchbox', { name: 'Search settings' }) + fireEvent.change(search, { target: { value: 'theme' } }) + expect(screen.getByTestId('settings-panel-search-result-general.theme')).toBeVisible() + + fireEvent.keyDown(search, { key: 'ArrowDown', code: 'ArrowDown' }) + fireEvent.keyDown(search, { key: 'Enter', code: 'Enter' }) + + expect(screen.getByRole('heading', { level: 2, name: 'Display & Fonts' })).toBeVisible() + const appearanceSection = document.getElementById('settings-section-appearance') + expect(appearanceSection).not.toBeNull() + expect(within(appearanceSection as HTMLElement).getByText('Appearance')).toBeVisible() + }) + + it('clears a non-empty search with Escape without closing settings', () => { + const onClose = vi.fn() + renderSettingsPanel({ onClose }) + + const search = screen.getByRole('searchbox', { name: 'Search settings' }) + fireEvent.change(search, { target: { value: 'theme' } }) + fireEvent.keyDown(search, { key: 'Escape', code: 'Escape' }) + + expect(search).toHaveValue('') + expect(onClose).not.toHaveBeenCalled() + }) + + it('closes settings with Escape when search is empty', () => { + const onClose = vi.fn() + renderSettingsPanel({ onClose }) + + fireEvent.keyDown(screen.getByRole('searchbox', { name: 'Search settings' }), { + key: 'Escape', + code: 'Escape', + }) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('gives notification toggles accessible names', () => { + renderSettingsPanel() + + fireEvent.click(screen.getByTestId('settings-section-nav-notifications')) + + for (const checkbox of screen.getAllByRole('checkbox')) { + expect(checkbox).toHaveAccessibleName() + } + }) + + it('keeps project settings in a collapsible navigation group', () => { + renderSettingsPanel({ workspaces: [createWorkspace()] }) + + expect(screen.getByText('Cove App')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: 'Collapse projects' })) + + expect(screen.queryByText('Cove App')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Expand projects' })).toBeVisible() + }) +}) diff --git a/tests/unit/contexts/settingsPanel.spec.tsx b/tests/unit/contexts/settingsPanel.spec.tsx index 7db2595bc..704c56492 100644 --- a/tests/unit/contexts/settingsPanel.spec.tsx +++ b/tests/unit/contexts/settingsPanel.spec.tsx @@ -350,11 +350,14 @@ describe('SettingsPanel', () => { }) }) - it('hides endpoints settings until remote workers are enabled', () => { + it('keeps endpoints inside Worker settings and gates registration until enabled', () => { mockTerminalProfiles() + installSettingsPanelWorkerApi() const { rerender } = renderSettingsPanel() - expect(screen.queryByTestId('settings-section-nav-endpoints')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('settings-section-nav-worker')) + expect(screen.getByText('Remote Workers disabled')).toBeVisible() + expect(screen.queryByTestId('settings-endpoints-empty-register')).not.toBeInTheDocument() rerender( { />, ) - expect(screen.getByTestId('settings-section-nav-endpoints')).toBeVisible() + expect(screen.getByTestId('settings-endpoints-empty-register')).toBeVisible() }) it('toggles the opt-in header performance monitor button from diagnostics settings', () => { @@ -403,7 +406,7 @@ describe('SettingsPanel', () => { mockTerminalProfiles() renderSettingsPanel({ onChange }) - fireEvent.click(screen.getByTestId('settings-section-nav-diagnostics')) + fireEvent.click(screen.getByTestId('settings-section-nav-experimental')) const toggle = screen.getByTestId( 'settings-performance-monitor-header-button-enabled', @@ -467,7 +470,7 @@ describe('SettingsPanel', () => { mockTerminalProfiles() renderSettingsPanel() - fireEvent.click(screen.getByTestId('settings-section-nav-diagnostics')) + fireEvent.click(screen.getByTestId('settings-section-nav-experimental')) expect(await screen.findByText('Performance Diagnostics')).toBeVisible() await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(1)) diff --git a/tests/unit/contexts/workerSection.hierarchy.spec.tsx b/tests/unit/contexts/workerSection.hierarchy.spec.tsx new file mode 100644 index 000000000..0a0081f46 --- /dev/null +++ b/tests/unit/contexts/workerSection.hierarchy.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkerSection } from '../../../src/contexts/settings/presentation/renderer/settingsPanel/WorkerSection' + +function installWorkerApi(): void { + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + value: { + meta: { isPackaged: false }, + workerClient: { + getConfig: vi.fn().mockResolvedValue({ + version: 1, + mode: 'standalone', + remote: null, + webUi: { + enabled: false, + port: null, + exposeOnLan: false, + passwordSet: false, + }, + updatedAt: null, + }), + setConfig: vi.fn(), + relaunch: vi.fn(), + }, + worker: { + getStatus: vi.fn().mockResolvedValue({ status: 'stopped', connection: null }), + start: vi.fn(), + stop: vi.fn(), + getWebUiUrl: vi.fn(), + }, + cli: { + getStatus: vi.fn().mockResolvedValue({ installed: false, path: null, healthy: false }), + install: vi.fn(), + uninstall: vi.fn(), + }, + clipboard: { writeText: vi.fn() }, + }, + }) +} + +describe('WorkerSection hierarchy', () => { + afterEach(() => { + delete (window as { opencoveApi?: unknown }).opencoveApi + vi.restoreAllMocks() + }) + + it('groups Worker Mode, CLI, and Local Worker controls under named modules', async () => { + installWorkerApi() + + render() + + expect(await screen.findByRole('group', { name: 'Worker Mode' })).toBeVisible() + + const cliModule = screen.getByRole('group', { name: 'CLI' }) + expect(within(cliModule).getByTestId('settings-worker-cli-status')).toBeVisible() + expect(within(cliModule).getByTestId('settings-worker-cli-install')).toBeVisible() + expect(within(cliModule).getByTestId('settings-worker-cli-uninstall')).toBeVisible() + + const localWorkerModule = screen.getByRole('group', { name: 'Local Worker' }) + expect(within(localWorkerModule).getByTestId('settings-worker-local-status')).toBeVisible() + expect(within(localWorkerModule).getByTestId('settings-worker-local-start')).toBeVisible() + expect(within(localWorkerModule).getByTestId('settings-worker-local-stop')).toBeVisible() + }) +}) diff --git a/tests/unit/contexts/workspaceSection.spec.tsx b/tests/unit/contexts/workspaceSection.spec.tsx index 9e25543bd..4818c7c35 100644 --- a/tests/unit/contexts/workspaceSection.spec.tsx +++ b/tests/unit/contexts/workspaceSection.spec.tsx @@ -20,7 +20,7 @@ describe('WorkspaceSection', () => { />, ) - expect(screen.getByText('Workspace Worktree')).toBeVisible() + expect(screen.getByRole('group', { name: 'Worktrees' })).toBeVisible() expect(screen.getByTestId('settings-workspace-path-display')).toHaveTextContent('demo') expect(screen.getByTestId('settings-workspace-path-display')).toHaveAttribute( 'title', @@ -60,7 +60,7 @@ describe('WorkspaceSection', () => { expect(screen.queryByTestId('settings-worktree-root')).not.toBeInTheDocument() }) - it('renders Environment Variables section when workspace is selected', () => { + it('renders Runtime Environment section when workspace is selected', () => { render( { />, ) - expect(screen.getByText('Environment Variables')).toBeVisible() + expect(screen.getByRole('group', { name: 'Runtime Environment' })).toBeVisible() expect(screen.getByText(/No environment variables configured/i)).toBeVisible() }) - it('does not render Environment Variables section when no workspace is selected', () => { + it('does not render Runtime Environment section when no workspace is selected', () => { render( { />, ) - expect(screen.queryByText('Environment Variables')).not.toBeInTheDocument() + expect(screen.queryByRole('group', { name: 'Runtime Environment' })).not.toBeInTheDocument() }) it('renders existing environment variables', () => {