diff --git a/docs/design/assets/settings-exclusions/default.png b/docs/design/assets/settings-exclusions/default.png new file mode 100644 index 00000000000..780d6262c8f Binary files /dev/null and b/docs/design/assets/settings-exclusions/default.png differ diff --git a/docs/design/assets/settings-exclusions/excluded.png b/docs/design/assets/settings-exclusions/excluded.png new file mode 100644 index 00000000000..a8e1867473a Binary files /dev/null and b/docs/design/assets/settings-exclusions/excluded.png differ diff --git a/docs/design/assets/settings-exclusions/mobile.png b/docs/design/assets/settings-exclusions/mobile.png new file mode 100644 index 00000000000..27c637c8080 Binary files /dev/null and b/docs/design/assets/settings-exclusions/mobile.png differ diff --git a/docs/design/web-shell-settings-exclusions.md b/docs/design/web-shell-settings-exclusions.md new file mode 100644 index 00000000000..274dd684ede --- /dev/null +++ b/docs/design/web-shell-settings-exclusions.md @@ -0,0 +1,39 @@ +# Embedded settings exclusions + +[English](web-shell-settings-exclusions.md) | [简体中文](web-shell-settings-exclusions.zh-CN.md) + +## Problem and scope + +The native settings page has hardcoded exclusions but no host presentation option. Its model-management card depends on ordinary Model descriptors. This proposal implements the reduced scope of #11949 in one PR: item exclusions, curated stable IDs, and independent model management. It does not add allowlists, category/scope policies, field overrides, item deep links, or an upgrade validator. + +## Design + +Export WebShellSettingsOptions with an optional readonly excludeItems list and a WebShellSettingItemId union plus WEB_SHELL_SETTING_ITEM_IDS. Curated aliases map explicitly to configuration keys; the mapping is internal and aliases remain stable across schema renames. Builtins cover chat width, browser notifications, Live setup, Local Control, and model management. Unknown runtime IDs match nothing. Missing options or an empty list preserve existing behavior. + +Build the baseline settings categories before exclusions, then filter all items and remove empty categories. Insert model management as an independent item in Model, retaining its separate card and the existing row count when ordinary Model rows are present; a model-only category counts as one. Native capability and hardcoded exclusions always apply. Browser notifications do not depend on chat-width visibility. Category navigation falls back to a remaining category and all-empty output uses the existing empty state. + +Forward options through App and the public provider wrappers. Settings-launched nested pickers must close when their source item is excluded; command-launched model pickers remain available. Exclusions do not change configuration, backend permissions, or model add/delete operations outside settings. + +## Files and compatibility + +Changes stay in the web-shell package: client/settings.ts, App.tsx, index.tsx, SettingsMessage.tsx, focused tests, and embedding documentation. No daemon protocol changes. Both scopes and all existing visual styling remain unchanged. Setting aliases are manually maintained; new schema settings remain visible until a supported alias is explicitly excluded. + +## Verification + +Test default/empty-list equivalence, ordinary exclusions in both scopes, builtin independence, empty categories and initial-category fallback, model list/select after all ordinary Model fields are excluded, model block exclusion, and dynamic exclusions of settings-launched dialogs. Exercise a browser fixture at desktop and mobile sizes, then build/typecheck, focused tests, full preflight, and two clean self-audit passes. Parent integration additionally verifies runtime, Console, extension, real model interaction and portable deployment packaging. + +A gating browser smoke test reads settings descriptors from a real daemon and verifies that all published exclusions empty the page in both scopes. This detects newly rendered settings missing an alias without duplicating schema or hidden-key lists. App tests also pin host-option forwarding and runtime updates. + +## Decisions and status + +Implemented locally, 2026-09-16. Focused tests and desktop/mobile browser checks pass; full validation is pending. This reduced scope does not require scope-policy changes from #6974. + +## Browser evidence + +Isolated fixture with synthetic models: default presentation, excluded ordinary model fields, and mobile layout. Selection callbacks were verified; these images do not claim real-provider model execution. + +![Default](assets/settings-exclusions/default.png) + +![Excluded](assets/settings-exclusions/excluded.png) + +![Mobile](assets/settings-exclusions/mobile.png) diff --git a/docs/design/web-shell-settings-exclusions.zh-CN.md b/docs/design/web-shell-settings-exclusions.zh-CN.md new file mode 100644 index 00000000000..ccd6eedfb6a --- /dev/null +++ b/docs/design/web-shell-settings-exclusions.zh-CN.md @@ -0,0 +1,39 @@ +# 嵌入式设置条目排除 + +[English](web-shell-settings-exclusions.md) | [简体中文](web-shell-settings-exclusions.zh-CN.md) + +## 问题与范围 + +原生设置页有硬编码排除规则,但没有宿主展示选项;模型管理卡片依赖普通 Model 描述符。本设计用一个 PR 实现 #11949 收敛后的范围:条目排除、精选稳定 ID、独立模型管理。不增加白名单、分类/作用域策略、字段覆盖、条目深链或升级校验器。 + +## 设计 + +导出 WebShellSettingsOptions,包含可选 readonly excludeItems 列表,同时导出 WebShellSettingItemId 联合类型与 WEB_SHELL_SETTING_ITEM_IDS。精选别名显式映射到配置 key;映射为内部实现,schema 改名时别名保持稳定。builtin 覆盖聊天宽度、浏览器通知、Live 设置、本地控制和模型管理。未知运行时 ID 不匹配任何项。省略选项或空列表保持既有行为。 + +先构建原始设置分类,再过滤全部条目并移除空分类。模型管理作为独立条目加入 Model,保持独立卡片;存在普通 Model 行时保留原有行数计数,仅模型管理存在时计数为一。原生能力与硬编码排除始终生效。浏览器通知不依赖聊天宽度是否显示。分类导航回退到剩余分类,全空使用既有空状态。 + +通过 App 和公开 provider 包装器透传选项。设置页启动的嵌套选择器在来源项被排除时关闭;命令启动的模型选择器仍可用。排除不修改配置、后端权限或设置页之外的模型增删操作。 + +## 文件与兼容性 + +修改限于 web-shell 包:client/settings.ts、App.tsx、index.tsx、SettingsMessage.tsx、定向测试及嵌入文档。无 daemon 协议变更。两个作用域及既有视觉样式保持不变。设置别名手动维护;新增 schema 设置默认显示,直到其受支持别名被显式排除。 + +## 验证 + +测试默认/空列表等价、两个作用域中的普通项排除、builtin 独立性、空分类与初始分类回退、所有普通 Model 项排除后的模型列表与切换、模型区排除以及设置弹框的动态排除。在桌面和移动视口运行浏览器 fixture,再执行构建/类型检查、定向测试、完整 preflight 和两轮干净自审。主仓库集成另验证 runtime、Console、Extension、真实模型交互及便携生产包。 + +CI 浏览器 smoke 测试从真实 daemon 读取设置描述符,验证排除全部公开 ID 后两个作用域均为空,避免复制 schema 或隐藏名单,并及时发现新展示项缺少别名。App 测试同时验证宿主选项传递与运行时更新。 + +## 决策与状态 + +已在本地实现,2026-09-16。定向测试与桌面/移动端浏览器检查通过,完整验证待完成。本次收敛范围不依赖 #6974 的作用域策略改动。 + +## 浏览器证据 + +隔离 fixture 使用模拟模型,展示默认界面、排除普通模型字段及移动端布局。已验证模型选择回调;这些截图不代表真实模型服务调用。 + +![Default](assets/settings-exclusions/default.png) + +![Excluded](assets/settings-exclusions/excluded.png) + +![Mobile](assets/settings-exclusions/mobile.png) diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index 66755b64925..71749b004b2 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { beforeEach, describe, it, expect, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; @@ -1009,3 +1012,67 @@ it.each(['failed', 'deferred', 'rejected', 'closed'] as const)( else expect(response.body.requiresRestart).toBe(status !== 'deferred'); }, ); + +// The web shell settings panel (`packages/web-shell/client`) hides rows behind +// the stable public aliases in client/settings.ts, while the served key set +// drifts whenever the schema gains a showInDialog key — omni.enabled shipped +// days without an alias (#11975). Compare the live route output against the +// alias table so the next missing alias reddens CI instead of shipping. +describe('web-shell settings alias drift', () => { + const webShellClientDir = join( + dirname(fileURLToPath(import.meta.url)), + '../../../../web-shell/client', + ); + + function parseKeySet(source: string, name: string): Set { + const match = source.match( + new RegExp(`const ${name} = new Set\\(\\[([\\s\\S]*?)\\]\\)`), + ); + if (!match) throw new Error(`${name} not found`); + return new Set( + [...match[1].replace(/\/\/[^\n]*/g, '').matchAll(/'([^']+)'/g)].map( + (m) => m[1]!, + ), + ); + } + + it('aliases every rendered row and serves every published alias', async () => { + const { app } = makeApp(); + const res = await request(app).get('/workspace/settings'); + expect(res.status).toBe(200); + const servedKeys = (res.body.settings as Array<{ key: string }>).map( + (setting) => setting.key, + ); + + // The panel drops HIDDEN/LIVE keys before rendering; parse both sets from + // the component so a change on either side is caught here. + const panelSource = readFileSync( + join(webShellClientDir, 'components/messages/SettingsMessage.tsx'), + 'utf8', + ); + const hidden = parseKeySet(panelSource, 'HIDDEN_SETTING_KEYS'); + const live = parseKeySet(panelSource, 'LIVE_SETTING_KEYS'); + const rendered = servedKeys.filter( + (key) => !hidden.has(key) && !live.has(key), + ); + + const aliasSource = readFileSync( + join(webShellClientDir, 'settings.ts'), + 'utf8', + ); + const table = aliasSource.match( + /const SETTING_KEYS = \{([\s\S]*?)\} as const/, + ); + if (!table) throw new Error('SETTING_KEYS not found in settings.ts'); + const aliased = [...table[1].matchAll(/'[^']+':\s*'([^']+)'/g)].map( + (m) => m[1]!, + ); + + // A second stable ID for one control is invisible to the two membership + // checks below, and once published it cannot be removed without breaking + // a host that adopted it. + expect(aliased.length).toBe(new Set(aliased).size); + expect(rendered.filter((key) => !aliased.includes(key))).toEqual([]); + expect(aliased.filter((key) => !rendered.includes(key))).toEqual([]); + }); +}); diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 76f764e076f..1debbb8b85d 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -350,6 +350,7 @@ const projection = projectChatRecordsToDaemonTranscript(records); | `sessionContext` | `DaemonProductSessionContext` | 显式产品上下文;standalone 或 Live 上下文不能同时传 `workspaceId`、`workspaceCwd` 或 `lockWorkspaceCwd` | | `lockWorkspaceCwd` | `string` | 锁定到指定工作区路径;未注册时自动持久注册,并隐藏其他工作区及添加、移除和选择入口 | | `restartSseOnPrompt` | `boolean` | 每次 prompt 被 daemon 接收后重建存活 SSE 流;流断开时提交 prompt 总会立即重建(与此开关无关);默认关闭 | +| `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 | ### WebShell @@ -367,6 +368,7 @@ const projection = projectChatRecordsToDaemonTranscript(records); | `onBrandResolved` | `(brand: WebShellResolvedBrand) => void` | 品牌解析完成后触发,载荷只含 `name` 与 `logoDataUri`(不含 `logo` 节点),供宿主应用到自己的文档;shell 自身从不写 `document.title` 或 favicon | | `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 | | `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 | +| `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 | 宿主可以通过 `onContextUsageOpen?: (sessionId: string) => void` 接管上下文 详情的打开操作: @@ -506,6 +508,26 @@ daemon 不净化它读到的文件。该配置只从 User / System / SystemDefau 自定义内容仍使用内置的展开、收起行为,`expanded` 会随状态更新;文件夹行右侧的内置操作不会渲染。 未提供 `lockWorkspaceCwd` 时,该 renderer 不会执行。 +## 原生设置呈现 + +嵌入方宿主可以在保留原生表单和模型选择器的前提下,隐藏单个原生设置项: + +```tsx + +``` + +`WebShellSettingsOptions.excludeItems` 接受 `WebShellSettingItemId` 值。可导入 `WEB_SHELL_SETTING_ITEM_IDS` 获取受支持的只读列表。这些 ID 是经过整理的别名,而不是 daemon 的配置路径:`setting:language` 对应语言控件,`setting:fast-model` 对应快速模型选择器。即使内部 schema 路径变化,别名也保持稳定。新增的上游设置默认仍然可见;未知的运行时 ID 会被忽略。 + +前端内置块有自己的 ID:`builtin:chat-width`、`builtin:browser-notifications`、`builtin:live-setup`、`builtin:local-control` 和 `builtin:model-management`。隐藏普通 Model 字段时,模型列表与选择仍然可用;如需隐藏模型管理块,需显式排除 `builtin:model-management`。浏览器通知与聊天宽度相互独立。既有的能力限制仍然适用。 + +不传 `settings`、不传 `excludeItems` 或传入空列表,都会保持现有呈现。排除在两个设置作用域(工作区与用户)中都生效。被排空的分类会消失,分类导航回退到可用分类;排除全部条目则显示现有的空状态。从设置面板打开的选择器会在其来源条目被排除时关闭。 + +**呈现限制不是访问控制。** 排除不会改写已保存的配置,也不限制 daemon 写入、斜杠命令、其他入口的模型管理或直接文件访问。该选项不提供白名单、作用域策略、字段覆盖或条目级深链。 + ## Markdown 图表接入 `WebShell` 已内置 `markdown-chart` renderer 和 ECharts 运行时。宿主只需将 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f7b5fde0169..1135d785e4d 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -39,6 +39,7 @@ import { type SessionSourcesResult, } from '@qwen-code/sdk/daemon'; import type { WebShellApi } from './App'; +import type { WebShellSettingsOptions } from './settings'; import { DEFAULT_SESSION_ACTION_ITEMS } from './components/sidebar/WebShellSidebar'; import type { Message } from './adapters/types'; import type { TurnOutputOpenRequest } from './components/artifacts/TurnOutputs'; @@ -746,8 +747,12 @@ const { sessionWorkflowEnabled?: boolean; } | null, latestSettingsInitialCategory: undefined as string | undefined, + latestSettingsPresentation: undefined as + | WebShellSettingsOptions + | undefined, latestModelManagement: null as { busy?: boolean; + onAddModel?: () => void; onSelectModel?: (modelId: string) => void; onDeleteModel?: (target: { authType: string; @@ -1241,13 +1246,16 @@ vi.mock('./components/messages/SettingsMessage', async () => { settings: DaemonSettingDescriptor[]; }; initialCategory?: string; + presentation?: WebShellSettingsOptions; onSubDialog?: (key: string, scope: 'user' | 'workspace') => void; onLanguageChange?: ( language: string, scope: 'user' | 'workspace', ) => void; + presentation?: WebShellSettingsOptions; modelManagement?: { busy?: boolean; + onAddModel?: () => void; onSelectModel?: (modelId: string) => void; onDeleteModel?: (target: { authType: string; @@ -1258,6 +1266,7 @@ vi.mock('./components/messages/SettingsMessage', async () => { }) => { testState.latestSettingsState = props.settingsState; testState.latestSettingsInitialCategory = props.initialCategory; + testState.latestSettingsPresentation = props.presentation; testState.latestModelManagement = props.modelManagement ?? null; return React.createElement( 'div', @@ -1283,7 +1292,12 @@ vi.mock('./components/messages/SettingsMessage', async () => { }, 'fast model (user)', ), - ...['visionModel', 'modelFallbacks'].flatMap((key) => + ...[ + 'visionModel', + 'modelFallbacks', + 'advisorModel', + 'imageModel', + ].flatMap((key) => (['user', 'workspace'] as const).map((scope) => React.createElement( 'button', @@ -10762,6 +10776,7 @@ beforeEach(() => { testState.latestProvidersHookOptions = undefined; testState.latestSettingsState = null; testState.latestSettingsInitialCategory = undefined; + testState.latestSettingsPresentation = undefined; testState.latestModelManagement = null; testState.latestScheduledTasksProps = null; testState.latestGoalsProps = null; @@ -34730,6 +34745,543 @@ describe('App session callbacks', () => { } }); + it('forwards host exclusions to the settings page and updates them at runtime', async () => { + const settings: WebShellSettingsOptions = { + excludeItems: ['setting:fast-model', 'builtin:model-management'], + }; + const { container, rerender } = renderApp({ settings }); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(testState.latestSettingsPresentation).toBe(settings); + + const updated: WebShellSettingsOptions = { + excludeItems: ['setting:language'], + }; + rerender({ settings: updated }); + await flush(); + expect(testState.latestSettingsPresentation).toBe(updated); + + rerender({ settings: undefined }); + await flush(); + expect(testState.latestSettingsPresentation).toBeUndefined(); + }); + + it('closes settings Add Model on exclusion without restricting command auth', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + testState.latestModelManagement?.onAddModel?.(); + }); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['builtin:model-management'] } }); + await flush(); + expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull(); + testState.prompt = '/auth'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['builtin:model-management'] } }); + await flush(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + }); + + it('closes an excluded settings picker and still allows a command-launched picker', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-fast-model"]') + ?.click(); + }); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['setting:fast-model'] } }); + await flush(); + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + await act(async () => { + container + .querySelector('[data-testid="open-fast-model"]') + ?.click(); + }); + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + testState.prompt = '/model --fast'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + }); + + it('keeps a status-bar model picker open when a stale settings key is excluded', async () => { + mockConnection.workspaceCwd = '/work/secondary'; + mockWorkspace.capabilities = { + workspaceCwd: '/work/primary', + features: [ + 'workspace_qualified_voice', + 'workspace_qualified_rest_core', + 'workspace_settings', + ], + workspaces: [ + { + id: 'primary', + cwd: '/work/primary', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/work/secondary', + primary: false, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + const voiceStatus = voiceWorkspaceStatus('/work/secondary'); + const voiceResult = deferred(); + qualifiedWorkspaceVoice.mockReturnValue(voiceResult.promise); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + // Start the settings Voice picker with the status fetch in flight, so + // 'voiceModel' stays in the settings-dialog key ref. + await act(async () => { + container + .querySelector('[data-testid="open-voice-model"]') + ?.click(); + await Promise.resolve(); + }); + // The status-bar model chip opens a 'main' picker without touching the + // settings-dialog key. + act(() => { + testState.latestStatusBarOnSelectModel?.(); + }); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['setting:voice-model'] } }); + await flush(); + // The exclusion owns only the voice picker; the status-bar picker stays. + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + await act(async () => { + voiceResult.resolve(voiceStatus); + await Promise.resolve(); + }); + }); + + it('keeps the exclusion armed across a /model sub-path that opens no dialog', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector( + '[data-testid="open-modelFallbacks-workspace"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="fallbacks-confirm"]'), + ).not.toBeNull(); + // A /model sub-path that only writes a setting opens no dialog, so it + // must not clear the key that arms exclusion for the open dialog. + testState.prompt = '/model --vision qwen-vl-max'; + await clickSubmit(container); + await flush(); + rerender({ settings: { excludeItems: ['setting:model-fallbacks'] } }); + await flush(); + expect( + container.querySelector('[data-testid="fallbacks-confirm"]'), + ).toBeNull(); + }); + + it('does not close a command-launched fast picker when its setting is excluded', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-fast-model"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + // The command takes over the picker surface and clears the settings key. + testState.prompt = '/model --fast'; + await clickSubmit(container); + await flush(); + rerender({ settings: { excludeItems: ['setting:fast-model'] } }); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + }); + + it('does not close a command auth dialog when its settings key was cleared at launch', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + testState.latestModelManagement?.onAddModel?.(); + }); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + testState.prompt = '/auth'; + await clickSubmit(container); + await flush(); + rerender({ settings: { excludeItems: ['builtin:model-management'] } }); + await flush(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + }); + + it('keeps a settings fast picker excludable across an /auth command', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-fast-model"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + // /auth takes over only the Add Model surface, so the fast picker's + // settings key must stay armed and the exclusion must still close it. + testState.prompt = '/auth'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['setting:fast-model'] } }); + await flush(); + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + }); + + it('does not close a settings fast picker when /auth launches without an exclusion', async () => { + const { container } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-fast-model"]') + ?.click(); + await Promise.resolve(); + }); + testState.prompt = '/auth'; + await clickSubmit(container); + await flush(); + // The command opens the auth dialog over the picker without closing it, so + // the close in the exclusion case above must come from the exclusion. + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + }); + + function armVoiceWorkspacePicker() { + mockConnection.workspaceCwd = '/work/secondary'; + mockWorkspace.capabilities = { + workspaceCwd: '/work/primary', + features: [ + 'workspace_qualified_voice', + 'workspace_qualified_rest_core', + 'workspace_settings', + ], + workspaces: [ + { + id: 'primary', + cwd: '/work/primary', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/work/secondary', + primary: false, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + const voiceStatus = voiceWorkspaceStatus('/work/secondary'); + const voiceResult = deferred(); + qualifiedWorkspaceVoice.mockReturnValue(voiceResult.promise); + return { voiceStatus, voiceResult }; + } + + it('closes a settings fallbacks dialog when model-fallbacks is excluded', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector( + '[data-testid="open-modelFallbacks-workspace"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="fallbacks-confirm"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['setting:model-fallbacks'] } }); + await flush(); + expect( + container.querySelector('[data-testid="fallbacks-confirm"]'), + ).toBeNull(); + }); + + it('forwards the host settings presentation to the settings panel', async () => { + const settings = { excludeItems: ['setting:fast-model' as const] }; + const { container } = renderApp({ settings }); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(testState.latestSettingsPresentation).toBe(settings); + }); + + it.each([ + ['visionModel', 'setting:vision-model'], + ['advisorModel', 'setting:advisor-model'], + ['imageModel', 'setting:image-model'], + ] as const)( + 'closes the settings-launched %s picker when %s is excluded', + async (key, excludedId) => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector( + `[data-testid="open-${key}-workspace"]`, + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: [excludedId] } }); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).toBeNull(); + }, + ); + + it('closes a settings-launched voice picker when voice-model is excluded', async () => { + const { voiceStatus, voiceResult } = armVoiceWorkspacePicker(); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-voice-model"]') + ?.click(); + await Promise.resolve(); + }); + expect(qualifiedWorkspaceVoice).toHaveBeenCalledOnce(); + await act(async () => { + voiceResult.resolve(voiceStatus); + await Promise.resolve(); + }); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['setting:voice-model'] } }); + await flush(); + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + }); + + it('does not close a command-taken-over vision picker when its setting is excluded', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector( + '[data-testid="open-visionModel-workspace"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + // The command takes over the vision surface and disarms the settings key. + testState.prompt = '/model --vision'; + await clickSubmit(container); + await flush(); + rerender({ settings: { excludeItems: ['setting:vision-model'] } }); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + }); + + it('keeps a settings fallbacks dialog excludable across a bare /model command', async () => { + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector( + '[data-testid="open-modelFallbacks-workspace"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="fallbacks-confirm"]'), + ).not.toBeNull(); + // The bare command opens the main picker but owns no settings surface, so + // the fallbacks dialog's exclusion key must stay armed. + testState.prompt = '/model'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + rerender({ settings: { excludeItems: ['setting:model-fallbacks'] } }); + await flush(); + expect( + container.querySelector('[data-testid="fallbacks-confirm"]'), + ).toBeNull(); + }); + + it('keeps a settings-launched voice picker excludable when /model --voice cannot take over', async () => { + const { voiceStatus, voiceResult } = armVoiceWorkspacePicker(); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-voice-model"]') + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + voiceResult.resolve(voiceStatus); + await Promise.resolve(); + }); + await flush(); + expect( + container.querySelector('[data-testid="model-select"]'), + ).not.toBeNull(); + // The command's picker refuses to open over an open model dialog, so the + // settings-launched picker keeps its exclusion key and still closes. + testState.prompt = '/model --voice'; + await clickSubmit(container); + await flush(); + rerender({ settings: { excludeItems: ['setting:voice-model'] } }); + await flush(); + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + }); + + it('never opens a settings Voice picker whose item is excluded mid-flight', async () => { + mockConnection.workspaceCwd = '/work/secondary'; + mockWorkspace.capabilities = { + workspaceCwd: '/work/primary', + features: [ + 'workspace_qualified_voice', + 'workspace_qualified_rest_core', + 'workspace_settings', + ], + workspaces: [ + { + id: 'primary', + cwd: '/work/primary', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/work/secondary', + primary: false, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + const voiceStatus = voiceWorkspaceStatus('/work/secondary'); + const voiceResult = deferred(); + qualifiedWorkspaceVoice.mockReturnValue(voiceResult.promise); + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="open-voice-model"]') + ?.click(); + await Promise.resolve(); + }); + expect(qualifiedWorkspaceVoice).toHaveBeenCalledOnce(); + rerender({ settings: { excludeItems: ['setting:voice-model'] } }); + await flush(); + await act(async () => { + voiceResult.resolve(voiceStatus); + await Promise.resolve(); + }); + await flush(); + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + }); + it('closes the panel, sends /model --fast, and reloads settings on fast-model pick', async () => { const { container } = renderApp(); await flush(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 12e406495d8..f935a1262a9 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -242,6 +242,11 @@ import { PluginManagerPage } from './components/plugins/PluginManagerPage'; import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage'; import { ShadowDomBoundary } from './components/ShadowDomBoundary'; import { McpAppHostContext } from './mcpAppHostContext'; +import { + isItemExcluded, + isSettingExcluded, + type WebShellSettingsOptions, +} from './settings'; import { SettingsMessage } from './components/messages/SettingsMessage'; import { isAskUserPermission } from './utils/askUserPermission'; import { ToolApproval } from './components/messages/ToolApproval'; @@ -1121,6 +1126,8 @@ export type WebShellSlashCommandHandler = ( ) => boolean | void; export interface WebShellProps { + /** Native settings-page presentation. Does not restrict commands or daemon access. */ + settings?: WebShellSettingsOptions; /** Host-specific label for the Ask User Question free-text choice. */ askUserFreeTextLabel?: string; /** Called whenever the attached daemon session or workspace changes. */ @@ -3062,6 +3069,7 @@ export function App({ bottomStatusItems, chatMaxWidth, sidebar, + settings: settingsPresentation, header, rightPanel, environmentPanel, @@ -8322,6 +8330,9 @@ export function App({ const [modelSettingScope, setModelSettingScope] = useState< 'workspace' | 'user' >('workspace'); + const settingsDialogKeyRef = useRef(undefined); + const settingsPresentationRef = useRef(settingsPresentation); + settingsPresentationRef.current = settingsPresentation; const [showFallbacksDialog, setShowFallbacksDialog] = useState(false); const showFallbacksDialogRef = useRef(showFallbacksDialog); const [voiceModels, setVoiceModels] = useState([]); @@ -11643,7 +11654,8 @@ export function App({ voiceFeaturesRef.current, ) && (source === 'settings' - ? activePanelRef.current === 'settings' + ? activePanelRef.current === 'settings' && + !isSettingExcluded('voiceModel', settingsPresentationRef.current) : activePanelRef.current === null) && mainViewRef.current === 'chat' && modelDialogModeRef.current === null && @@ -15348,6 +15360,12 @@ export function App({ return true; } if (cmd === 'auth') { + // Take over only the surface this command owns: clearing an + // unrelated settings-launched key would disarm its exclusion + // force-close. + if (settingsDialogKeyRef.current === 'builtin:model-management') { + settingsDialogKeyRef.current = undefined; + } setShowAuthDialog(true); return true; } @@ -15361,6 +15379,9 @@ export function App({ return true; } if (modelArg === '--fast') { + if (settingsDialogKeyRef.current === 'fastModel') { + settingsDialogKeyRef.current = undefined; + } setModelDialogMode('fast'); return true; } @@ -15389,6 +15410,9 @@ export function App({ return true; } if (modelArg === '--vision') { + if (settingsDialogKeyRef.current === 'visionModel') { + settingsDialogKeyRef.current = undefined; + } setModelDialogMode('vision'); return true; } @@ -17158,6 +17182,61 @@ export function App({ } }, [modelDialogMode, showFallbacksDialog, showAuthDialog]); + useEffect(() => { + const key = settingsDialogKeyRef.current; + if (!key) return; + const excluded = + key === 'builtin:model-management' + ? isItemExcluded(key, settingsPresentation) + : isSettingExcluded(key, settingsPresentation); + if (excluded) { + if (pendingVoicePickerSourceRef.current === 'settings') { + voicePickerRequestRef.current++; + pendingVoicePickerSourceRef.current = undefined; + } + // Close only the dialog the held key owns: a stale key left over from a + // settings launch must not close a surface a command or the status bar + // opened afterwards. + if (key === 'modelFallbacks') { + setShowFallbacksDialog(false); + } else if (key === 'builtin:model-management') { + handleCloseAuthDialog(); + } else { + const ownedMode = + key === 'fastModel' + ? 'fast' + : key === 'visionModel' + ? 'vision' + : key === 'advisorModel' + ? 'advisor' + : key === 'imageModel' + ? 'image' + : key === 'voiceModel' + ? 'voice' + : null; + if (ownedMode) { + setModelDialogMode((cur) => (cur === ownedMode ? null : cur)); + } + } + settingsDialogKeyRef.current = undefined; + } else if ( + !modelDialogMode && + !showFallbacksDialog && + !showApprovalModeDialog && + !showAuthDialog && + pendingVoicePickerSourceRef.current !== 'settings' + ) { + settingsDialogKeyRef.current = undefined; + } + }, [ + settingsPresentation, + showAuthDialog, + handleCloseAuthDialog, + modelDialogMode, + showFallbacksDialog, + showApprovalModeDialog, + ]); + const useWorkspaceSkillSnapshot = workspaceContextActive && loadedSkillsReady && @@ -18858,6 +18937,7 @@ export function App({ {activePanel === 'settings' ? ( setShowAuthDialog(true), + onAddModel: () => { + if ( + isItemExcluded( + 'builtin:model-management', + settingsPresentation, + ) + ) + return; + settingsDialogKeyRef.current = + 'builtin:model-management'; + setShowAuthDialog(true); + }, }} onSubDialog={(key, scope) => { + if (isSettingExcluded(key, settingsPresentation)) return; + settingsDialogKeyRef.current = key; // Record the persist scope only for model settings — // the reset effect is gated on the dialog/fallback/auth // flags, so it never runs for the approvalMode dialog diff --git a/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx b/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx index a13456a587a..4a759598a1a 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx +++ b/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx @@ -8,6 +8,11 @@ import type { DaemonWorkspaceSettingsStatus, DaemonWorkspaceProviderStatus, } from '@qwen-code/web-shell/daemon-react-sdk'; +import { + WEB_SHELL_SETTING_ITEM_IDS, + type WebShellSettingItemId, + type WebShellSettingsOptions, +} from '../../settings'; import { I18nProvider } from '../../i18n'; import { SettingsMessage, @@ -33,6 +38,19 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { }; }); +// The browser-notifications row exists only when the hook returns a value; +// individual tests opt in by assigning the stub. +const browserNotificationsStub = vi.hoisted(() => ({ + current: undefined as + | ReturnType< + typeof import('../../browser-turn-notifications').useBrowserNotificationSettings + > + | undefined, +})); +vi.mock('../../browser-turn-notifications', () => ({ + useBrowserNotificationSettings: () => browserNotificationsStub.current, +})); + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -40,6 +58,7 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const mounted: Array<{ root: Root; container: HTMLElement }> = []; afterEach(() => { + browserNotificationsStub.current = undefined; for (const { root, container } of mounted.splice(0)) { act(() => root.unmount()); container.remove(); @@ -198,6 +217,7 @@ function renderPanel( onThemeChange: (theme: 'dark' | 'light') => void; modelManagement: ModelManagementProps; initialCategory: string; + presentation: WebShellSettingsOptions; }> = {}, ): HTMLElement { return render( @@ -206,6 +226,7 @@ function renderPanel( settingsState={state} embedded initialCategory={overrides.initialCategory} + presentation={overrides.presentation} onLanguageChange={noop} onThemeChange={overrides.onThemeChange ?? noop} onSubDialog={overrides.onSubDialog ?? noop} @@ -709,5 +730,239 @@ describe('SettingsMessage user-scope editing', () => { const block = container.querySelector('[data-testid="model-management"]'); expect(block).toBeTruthy(); expect(block?.textContent).toContain('GPT-4o'); + // Ordinary rows exist, so the category card renders above the block with + // the paired mt-4 spacing. + expect(container.querySelector('[data-slot="card"]')).toBeTruthy(); + expect(block?.parentElement?.className).toContain('mt-4'); }); + it('keeps the model list and selection when ordinary Model fields are excluded', () => { + const modelManagement = makeModelManagement(); + modelManagement.currentModelId = 'other'; + modelManagement.providers[0]!.models[0]!.isCurrent = false; + const container = renderPanel(makeState([subDialogSetting()], vi.fn()), { + modelManagement, + presentation: { excludeItems: ['setting:fast-model'] }, + }); + expect(container.textContent).not.toContain('Fast Model'); + const block = container.querySelector('[data-testid="model-management"]'); + expect(block?.textContent).toContain('GPT-4o'); + const modelButton = Array.from(block!.querySelectorAll('button')).find( + (b) => b.getAttribute('aria-label')?.includes('Set current'), + ); + expect(modelButton).toBeTruthy(); + act(() => modelButton!.click()); + expect(modelManagement.onSelectModel).toHaveBeenCalledWith( + 'gpt-4o(openai)', + ); + }); + + it('excludes the model block without hiding ordinary Model settings', () => { + const container = renderPanel(makeState([subDialogSetting()], vi.fn()), { + modelManagement: makeModelManagement(), + presentation: { excludeItems: ['builtin:model-management'] }, + }); + expect(container.textContent).toContain('Fast Model'); + expect( + container.querySelector('[data-testid="model-management"]'), + ).toBeNull(); + }); + + it('falls back from an excluded category and keeps exclusions in user scope', () => { + const container = renderPanel(makeState([subDialogSetting()], vi.fn()), { + initialCategory: 'Model', + presentation: { excludeItems: ['setting:fast-model'] }, + }); + expect(container.querySelector('nav')?.textContent).not.toContain('Model'); + expect( + container.querySelector('[aria-current="page"]')?.textContent, + ).toContain('UI'); + clickUserTab(container); + // The nav must stay filtered on the User tab too: an exclusion dropped + // from the user-scope path would return the Model category there. + expect(container.querySelector('nav')?.textContent).not.toContain('Model'); + }); + + it('hides Omni media delivery when all published settings items are excluded', () => { + const state = makeState( + [ + { + ...boolSetting(), + key: 'omni.enabled', + label: 'Enable Omni Media Delivery', + category: 'Experimental', + }, + ], + vi.fn(), + ); + const baseline = renderPanel(state); + expect(baseline.textContent).toContain('Enable Omni Media Delivery'); + const excluded = renderPanel(state, { + presentation: { excludeItems: WEB_SHELL_SETTING_ITEM_IDS }, + }); + expect(excluded.querySelectorAll('nav button')).toHaveLength(0); + expect(excluded.querySelector('[data-slot="empty"]')).toBeTruthy(); + clickUserTab(excluded); + expect(excluded.querySelectorAll('nav button')).toHaveLength(0); + expect(excluded.querySelector('[data-slot="empty"]')).toBeTruthy(); + }); + + it('shows an empty state when every available item is excluded', () => { + const container = renderPanel(makeState([subDialogSetting()], vi.fn()), { + modelManagement: makeModelManagement(), + presentation: { excludeItems: WEB_SHELL_SETTING_ITEM_IDS }, + }); + expect(container.querySelectorAll('nav button')).toHaveLength(0); + expect(container.querySelector('[data-slot="empty"]')).toBeTruthy(); + const emptyTitle = container.querySelector('[data-slot="empty-title"]'); + const emptyDescription = container.querySelector( + '[data-slot="empty-description"]', + ); + expect(emptyTitle?.textContent).toBeTruthy(); + expect(emptyDescription?.textContent ?? null).not.toBe( + emptyTitle?.textContent, + ); + }); + + it('preserves default content and counts for an empty exclusion list', () => { + const state = makeState([subDialogSetting()], vi.fn()); + const options = { modelManagement: makeModelManagement() }; + const baseline = renderPanel(state, options); + const empty = renderPanel(state, { + ...options, + presentation: { excludeItems: [] }, + }); + expect(empty.textContent).toBe(baseline.textContent); + expect(empty.querySelector('nav')?.textContent).toBe( + baseline.querySelector('nav')?.textContent, + ); + }); + + it('keeps a model-only category when descriptors are absent', () => { + const container = renderPanel(makeState([], vi.fn()), { + initialCategory: 'Model', + modelManagement: makeModelManagement(), + }); + const block = container.querySelector('[data-testid="model-management"]'); + expect(block).toBeTruthy(); + expect( + container.querySelector('[aria-current="page"]')?.textContent, + ).toContain('1'); + // With zero ordinary rows the category card is skipped entirely, and the + // block loses the mt-4 offset that separates it from the card. + expect(container.querySelector('[data-slot="card"]')).toBeNull(); + expect(block?.parentElement?.className ?? '').not.toContain('mt-4'); + }); + + it('does not invent a Model category during the initial settings load', () => { + const state: SettingsMessageSettingsState = { + status: undefined, + settings: [], + loading: true, + error: undefined, + reload: vi.fn(async () => undefined), + setValue: vi.fn(), + }; + const container = renderPanel(state, { + modelManagement: makeModelManagement(), + }); + const navLabels = Array.from(container.querySelectorAll('nav button')).map( + (button) => button.textContent, + ); + expect(navLabels.some((text) => text?.includes('Model'))).toBe(false); + }); + + it('counts only ordinary rows in the Model nav badge', () => { + const modelFallbacksSetting: DaemonSettingDescriptor = { + key: 'modelFallbacks', + type: 'string', + label: 'Model Fallbacks', + category: 'Model', + requiresRestart: false, + default: '', + values: { effective: '' }, + }; + const container = renderPanel( + makeState([subDialogSetting(), modelFallbacksSetting], vi.fn()), + { modelManagement: makeModelManagement() }, + ); + const modelNav = Array.from(container.querySelectorAll('nav button')).find( + (button) => button.textContent?.includes('Model'), + ); + // Two ordinary Model rows plus the management card: the badge reads 2. + expect(modelNav?.textContent).toContain('2'); + }); + + it('confines the model-management block to the Model category', () => { + const container = renderPanel( + makeState([boolSetting(), subDialogSetting()], vi.fn()), + { modelManagement: makeModelManagement(), initialCategory: 'Model' }, + ); + expect( + container.querySelector('[data-testid="model-management"]'), + ).toBeTruthy(); + + const clickCategory = (label: string) => { + const button = Array.from( + container.querySelectorAll('nav button'), + ).find((b) => b.textContent?.includes(label)); + if (!button) throw new Error(`category ${label} not found`); + act(() => { + button.click(); + }); + }; + clickCategory('General'); + expect( + container.querySelector('[data-testid="model-management"]'), + ).toBeNull(); + clickCategory('Model'); + expect( + container.querySelector('[data-testid="model-management"]'), + ).toBeTruthy(); + }); + + it.each([ + ['builtin:chat-width', 'UI', 'Chat width', 'Browser task notifications'], + [ + 'builtin:browser-notifications', + 'UI', + 'Browser task notifications', + 'Chat width', + ], + ['builtin:live-setup', 'Experimental', 'Qwen Live', undefined], + ['builtin:local-control', 'Daemon', 'Local Control', undefined], + ['builtin:model-management', 'Model', 'model-management', undefined], + ] as const)( + 'excludes only %s while the sibling builtins remain', + (id, category, label, uiSibling) => { + browserNotificationsStub.current = { + enabled: true, + permission: 'granted', + pending: false, + persistent: true, + error: false, + setEnabled: vi.fn(async () => {}), + refreshPermission: vi.fn(), + syncLanguage: vi.fn(), + }; + const container = renderPanel(makeState([], vi.fn(), liveSetup(false)), { + modelManagement: makeModelManagement(), + presentation: { excludeItems: [id as WebShellSettingItemId] }, + }); + const navText = container.querySelector('nav')?.textContent ?? ''; + if (category === 'UI') { + // UI stays active by default; the excluded row is gone and the + // sibling row in the same category remains. + expect(container.textContent).not.toContain(label); + expect(container.textContent).toContain(uiSibling ?? ''); + expect(navText).toContain('UI'); + } else { + // The excluded item was the category's only row, so the category + // itself leaves the nav. + expect(navText).not.toContain(category); + } + for (const other of ['UI', 'Experimental', 'Daemon', 'Model']) { + if (other !== category) expect(navText).toContain(other); + } + }, + ); }); diff --git a/packages/web-shell/client/components/messages/SettingsMessage.tsx b/packages/web-shell/client/components/messages/SettingsMessage.tsx index 451feb5b49a..75fa2a4ba4a 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.tsx +++ b/packages/web-shell/client/components/messages/SettingsMessage.tsx @@ -50,13 +50,7 @@ import { Alert, AlertDescription } from '../ui/alert'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '../ui/empty'; +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty'; import { Field, FieldContent, @@ -78,10 +72,17 @@ import { Spinner } from '../ui/spinner'; import { Switch } from '../ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; +import { + isItemExcluded, + isSettingExcluded, + type WebShellSettingsOptions, +} from '../../settings'; + type ChatWidthMode = '1000' | 'wide'; interface SettingsMessageProps { settingsState: SettingsMessageSettingsState; + presentation?: WebShellSettingsOptions; onLanguageChange: (language: WebShellLanguage, scope: Scope) => void; onSubDialog: (settingKey: string, scope: Scope) => void; onThemeChange: (theme: WebShellTheme) => void; @@ -259,7 +260,8 @@ type SettingsPageItem = | { type: 'setting'; setting: DaemonSettingDescriptor } | { type: 'local'; localKey: 'chatWidth' | 'browserNotifications' } | { type: 'local-control' } - | { type: 'live' }; + | { type: 'live' } + | { type: 'model-management' }; interface SettingsPageCategory { id: string; @@ -422,6 +424,7 @@ export function nextSettingIdx( export function SettingsMessage({ settingsState, + presentation, onLanguageChange, onSubDialog, onThemeChange, @@ -513,8 +516,46 @@ export function SettingsMessage({ items: [{ type: 'local-control' }], }); } - return groups; - }, [liveSetup, settings, t, hasNotifications]); + if (modelManagement && !showInitialLoading) { + const model = groups.find((group) => group.id === 'Model'); + if (model) model.items.push({ type: 'model-management' }); + else + groups.push({ + id: 'Model', + label: formatSettingCategory('Model', t), + items: [{ type: 'model-management' }], + }); + } + return groups + .map((group) => ({ + ...group, + items: group.items.filter((item) => { + if (item.type === 'setting') { + return !isSettingExcluded(item.setting.key, presentation); + } + const id = + item.type === 'local' + ? item.localKey === 'chatWidth' + ? 'builtin:chat-width' + : 'builtin:browser-notifications' + : item.type === 'live' + ? 'builtin:live-setup' + : item.type === 'local-control' + ? 'builtin:local-control' + : 'builtin:model-management'; + return !isItemExcluded(id, presentation); + }), + })) + .filter((group) => group.items.length > 0); + }, [ + liveSetup, + settings, + t, + hasNotifications, + modelManagement, + presentation, + showInitialLoading, + ]); useEffect(() => { if (categories.length === 0) return; @@ -575,10 +616,10 @@ export function SettingsMessage({ categories.find((category) => category.id === activeCategory) ?? categories[0]; - // The model-management block is surfaced inside the "Model" category, detected - // by the raw category of its dialog settings (fastModel etc.). - const isModelCategory = activeGroup?.items.some( - (item) => item.type === 'setting' && item.setting.category === 'Model', + const activeRows = + activeGroup?.items.filter((item) => item.type !== 'model-management') ?? []; + const showModelManagement = activeGroup?.items.some( + (item) => item.type === 'model-management', ); const renderSelect = ( @@ -791,7 +832,12 @@ export function SettingsMessage({ {category.label} - {category.items.length} + {Math.max( + 1, + category.items.filter( + (item) => item.type !== 'model-management', + ).length, + )} ))} @@ -805,196 +851,200 @@ export function SettingsMessage({ {t('settings.empty')} - {t('settings.empty')} )} {activeGroup && (
- - - - - {activeGroup.label} - - - - - {activeGroup.items.map((item, index) => { - const separator = index > 0 && ( - - ); - if ( - item.type === 'local' && - item.localKey === 'browserNotifications' && - notifications - ) { - const unavailable = - notifications.permission === 'unavailable'; - const status = unavailable - ? 'unavailable' - : notifications.pending - ? 'requesting' - : notifications.permission === 'denied' - ? 'denied' - : notifications.error - ? 'error' - : notifications.enabled - ? notifications.permission === 'granted' - ? 'enabled' - : 'waiting' - : 'disabled'; + {activeRows.length > 0 && ( + + + + + {activeGroup.label} + + + + + {activeRows.map((item, index) => { + const separator = index > 0 && ( + + ); + if ( + item.type === 'local' && + item.localKey === 'browserNotifications' && + notifications + ) { + const unavailable = + notifications.permission === 'unavailable'; + const status = unavailable + ? 'unavailable' + : notifications.pending + ? 'requesting' + : notifications.permission === 'denied' + ? 'denied' + : notifications.error + ? 'error' + : notifications.enabled + ? notifications.permission === 'granted' + ? 'enabled' + : 'waiting' + : 'disabled'; + return ( +
+ {separator} + + {notifications.enabled && + notifications.permission === + 'default' && ( + + )} + + void notifications.setEnabled(enabled) + } + /> +
+ } + /> +
+ ); + } + if (item.type === 'local') { + return ( +
+ {separator} + + onChatWidthModeChange( + next as ChatWidthMode, + ), + [ + { + value: '1000', + label: t( + 'settings.option.ui.chatWidth.1000', + ), + }, + { + value: 'wide', + label: t( + 'settings.option.ui.chatWidth.wide', + ), + }, + ], + t('settings.label.ui.chatWidth'), + false, + )} + /> +
+ ); + } + if (item.type === 'live') { + return liveSetup ? ( +
+ {separator} + +
+ ) : null; + } + if (item.type === 'local-control') { + return ( +
+ {separator} + +
+ ); + } + + const setting = item.setting; + const description = formatSettingDescription( + setting, + t, + ); + const hintKey = scopeHintKey(setting, scope); + const hasScopeValue = scopeHasValue(setting, scope); + const scopeHint = hintKey + ? t(hintKey, { + scope: t( + scope === 'workspace' + ? 'settings.scope.user' + : 'settings.scope.workspace', + ), + }) + : undefined; return ( -
+
{separator} + {scope === 'workspace' + ? t('settings.scope.workspace') + : t('settings.scope.user')} + + ) : undefined + } control={ -
- {notifications.enabled && - notifications.permission === - 'default' && ( - - )} - - void notifications.setEnabled(enabled) - } - /> -
+ busyKey === setting.key ? ( + + ) : ( + renderSettingControl(setting) + ) } />
); - } - if (item.type === 'local') { - return ( -
- {separator} - - onChatWidthModeChange( - next as ChatWidthMode, - ), - [ - { - value: '1000', - label: t( - 'settings.option.ui.chatWidth.1000', - ), - }, - { - value: 'wide', - label: t( - 'settings.option.ui.chatWidth.wide', - ), - }, - ], - t('settings.label.ui.chatWidth'), - false, - )} - /> -
- ); - } - if (item.type === 'live') { - return liveSetup ? ( -
- {separator} - -
- ) : null; - } - if (item.type === 'local-control') { - return ( -
- {separator} - -
- ); - } - - const setting = item.setting; - const description = formatSettingDescription( - setting, - t, - ); - const hintKey = scopeHintKey(setting, scope); - const hasScopeValue = scopeHasValue(setting, scope); - const scopeHint = hintKey - ? t(hintKey, { - scope: t( - scope === 'workspace' - ? 'settings.scope.user' - : 'settings.scope.workspace', - ), - }) - : undefined; - return ( -
- {separator} - - {scope === 'workspace' - ? t('settings.scope.workspace') - : t('settings.scope.user')} - - ) : undefined - } - control={ - busyKey === setting.key ? ( - - ) : ( - renderSettingControl(setting) - ) - } - /> -
- ); - })} - - - - {isModelCategory && modelManagement && ( -
+ })} + + + + )} + {showModelManagement && modelManagement && ( +
0 ? 'mt-4' : undefined}>
)} diff --git a/packages/web-shell/client/e2e/settings-harness.html b/packages/web-shell/client/e2e/settings-harness.html new file mode 100644 index 00000000000..ab2c6f452ea --- /dev/null +++ b/packages/web-shell/client/e2e/settings-harness.html @@ -0,0 +1,12 @@ + + + + + + Settings harness + + +
+ + + diff --git a/packages/web-shell/client/e2e/settings-harness.tsx b/packages/web-shell/client/e2e/settings-harness.tsx new file mode 100644 index 00000000000..7d1f595d113 --- /dev/null +++ b/packages/web-shell/client/e2e/settings-harness.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import '../styles/standalone.css'; +import type { WebShellSettingItemId } from '../settings'; +import type { WebShellTheme } from '../themeContext'; + +const indexEntry = '../index.tsx'; +const { WebShellWithProviders, WEB_SHELL_SETTING_ITEM_IDS } = await import( + /* @vite-ignore */ indexEntry +); + +const params = new URLSearchParams(window.location.search); +const sessionId = params.get('sessionId') ?? 'settings-harness-e2e'; +const theme: WebShellTheme = params.get('theme') === 'light' ? 'light' : 'dark'; + +// main.tsx owns the theme class on the standalone entry; a harness +// page mounts the shell directly and must apply it itself. +document.documentElement.classList.add(`theme-${theme}`); +document.documentElement.classList.toggle('dark', theme === 'dark'); + +const validIds: ReadonlySet = new Set(WEB_SHELL_SETTING_ITEM_IDS); +const excludeItems = (params.get('exclude') ?? '') + .split(',') + .map((item) => item.trim()) + .filter((item): item is WebShellSettingItemId => validIds.has(item)); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/web-shell/client/e2e/visuals/harness.ts b/packages/web-shell/client/e2e/visuals/harness.ts index 2b233f13a80..47d27c2d4c1 100644 --- a/packages/web-shell/client/e2e/visuals/harness.ts +++ b/packages/web-shell/client/e2e/visuals/harness.ts @@ -91,8 +91,8 @@ async function primeTheme(page: Page, theme: VisualTheme): Promise { * surface as a bare expect timeout. Such a scenario must seed already-expired * timestamps or drive `page.clock.fastForward` / `runFor` itself. * - * `visual-capture-contracts.test.ts` pins that both navigation helpers still - * call this before `page.goto`; nothing in the visuals suite reads the clock, + * `visual-capture-contracts.test.ts` pins that every navigation helper still + * calls this before `page.goto`; nothing in the visuals suite reads the clock, * so a dropped call would otherwise stay green. */ export async function freezeWallClock(page: Page): Promise { @@ -171,6 +171,45 @@ export async function gotoNewSession( await expect(page.locator('html')).toHaveClass(new RegExp(`theme-${theme}`)); } +/** + * Navigate to the settings harness page, which maps `?exclude=` onto the + * shell's `settings` prop — the standalone entry never passes one, so the + * host-exclusion scenarios can only render through that page. Freezes the + * clock before navigating like every other helper; the theme assertion keys + * on the shell's own surface because the harness paints the `` theme + * class from the same `?theme=` param, where it cannot mislabel. + */ +export async function gotoSettingsHarness( + page: Page, + scenario: WebShellDaemonScenario, + daemon: MockDaemonController, + theme: VisualTheme, + exclude: readonly string[] = [], +): Promise { + await freezeWallClock(page); + const params = new URLSearchParams({ theme, sessionId: scenario.sessionId }); + if (exclude.length > 0) params.set('exclude', exclude.join(',')); + await page.goto(`/e2e/settings-harness.html?${params.toString()}`); + await expect( + page.locator('[data-web-shell-root]:not([data-web-shell-gate])'), + ).toBeVisible(); + // The shell's own theme must agree with the filename: the app root carries + // a plain `dark` literal only in the dark theme. The last bare root is the + // app; the session provider's loading placeholder is also a bare + // [data-web-shell-root]. + const rootClass = await page + .locator('[data-web-shell-root]:not([data-web-shell-gate])') + .last() + .getAttribute('class'); + expect(rootClass?.split(/\s+/).includes('dark')).toBe(theme === 'dark'); + await completeReplay( + page, + daemon, + scenario.sessionId, + scenario.events.length, + ); +} + export async function completeReplay( page: Page, daemon: MockDaemonController, diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index cc60fc63b58..e8bec1f2cb0 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { devices, expect, test } from '@playwright/test'; +import { devices, expect, test, type Page } from '@playwright/test'; import type { DaemonEvent, DaemonSessionSummary, DaemonSessionContextUsageStatus, + DaemonSettingDescriptor, } from '@qwen-code/sdk/daemon'; import { assistantTextEvent, @@ -17,6 +18,7 @@ import { toolCallEvent, turnCompleteEvent, userTextEvent, + type WebShellDaemonScenario, } from '../utils/mockDaemon'; import { captureScreenshot, @@ -24,6 +26,7 @@ import { fillComposer, gotoNewSession, gotoSession, + gotoSettingsHarness, installScenario, resolveBaseURL, submitLocalCommand, @@ -102,6 +105,104 @@ function createTerminalGoalStatusEvent( }; } +/** + * The settings panel's host-driven exclusions only exist when the host passes + * the `settings` prop, which the standalone entry never does — so these + * scenarios render the shell through the settings harness page, which maps + * `?exclude=` onto that prop. + */ +function createSettingsPanelScenario( + theme: VisualTheme, +): WebShellDaemonScenario { + const descriptors: DaemonSettingDescriptor[] = [ + { + key: 'general.enableAutoUpdate', + type: 'boolean', + label: 'Auto-update', + category: 'General', + requiresRestart: false, + default: true, + values: { effective: true }, + }, + { + key: 'general.showSessionRecap', + type: 'boolean', + label: 'Session Recap', + category: 'General', + requiresRestart: false, + default: true, + values: { effective: true }, + }, + { + key: 'ui.theme', + type: 'enum', + label: 'Theme', + category: 'UI', + requiresRestart: false, + default: 'Qwen Dark', + options: [ + { value: 'Qwen Dark', label: 'Qwen Dark' }, + { value: 'Qwen Light', label: 'Qwen Light' }, + ], + values: { + effective: theme === 'light' ? 'Qwen Light' : 'Qwen Dark', + }, + }, + { + key: 'output.showTimestamps', + type: 'boolean', + label: 'Show Timestamps', + // The real schema files this under General; the panel must render the + // category set a daemon actually serves. + category: 'General', + requiresRestart: false, + default: false, + values: { effective: false }, + }, + { + key: 'tools.webSearch.enabled', + type: 'boolean', + label: 'Web Search', + category: 'Tools', + requiresRestart: false, + default: true, + values: { effective: true }, + }, + { + key: 'fastModel', + type: 'string', + label: 'Fast Model', + category: 'Model', + requiresRestart: false, + default: '', + values: { effective: '' }, + }, + { + key: 'modelFallbacks', + type: 'string', + label: 'Model Fallbacks', + category: 'Model', + requiresRestart: false, + default: '', + values: { effective: '' }, + }, + ]; + return createWebShellDaemonScenario({ + settings: { settings: descriptors }, + }); +} + +async function openSettingsPanel(page: Page): Promise { + await submitLocalCommand(page, '/settings'); + const nav = page.getByRole('navigation', { name: 'Settings' }); + await expect(nav).toBeVisible(); + // Rows render only for the active category, and which category is active + // follows the fixture's descriptor order — gate on a descriptor-derived nav + // button instead, which renders for every group. Only a General descriptor + // creates one, so this still cannot resolve before the settings fetch lands. + await expect(nav.getByRole('button', { name: /^General/ })).toBeVisible(); +} + for (const theme of THEMES) { test.describe(`web-shell screenshots (${theme})`, () => { test('context usage', async ({ page }, testInfo) => { @@ -1297,6 +1398,88 @@ for (const theme of THEMES) { await captureScreenshot(page, `theme-dialog-${theme}`); }); + test(`settings panel`, async ({ page }, testInfo) => { + const scenario = createSettingsPanelScenario(theme); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + // The mock daemon has no GET /workspace/models route; answer with an + // empty list so the model block renders providers without an error hint. + await page.route('**/workspace/models', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ json: { models: [] } }); + } else { + await route.fallback(); + } + }); + await gotoSettingsHarness(page, scenario, daemon, theme); + await openSettingsPanel(page); + + const nav = page.getByRole('navigation', { name: 'Settings' }); + await expect(nav.getByRole('button', { name: /^UI/ })).toBeVisible(); + await expect(nav.getByRole('button', { name: /^Tools/ })).toBeVisible(); + await nav.getByRole('button', { name: /^Model/ }).click(); + await expect(page.getByText('Fast Model', { exact: true })).toBeVisible(); + await expect(page.getByTestId('model-management')).toBeVisible(); + await captureScreenshot(page, `settings-panel-${theme}`); + }); + + test(`settings panel with host exclusions`, async ({ page }, testInfo) => { + const scenario = createSettingsPanelScenario(theme); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await page.route('**/workspace/models', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ json: { models: [] } }); + } else { + await route.fallback(); + } + }); + await gotoSettingsHarness(page, scenario, daemon, theme, [ + 'builtin:chat-width', + 'builtin:model-management', + 'setting:fast-model', + 'setting:timestamps', + 'setting:web-search', + ]); + await openSettingsPanel(page); + + // Excluded rows vanish, and a category left with nothing visible — + // here Tools — drops out of the nav entirely. + const nav = page.getByRole('navigation', { name: 'Settings' }); + await expect(nav.getByRole('button', { name: /^Tools/ })).toHaveCount(0); + // Rows render only for the active category, so the chat-width absence + // is observable only after opening UI — pin the category itself first. + await nav.getByRole('button', { name: /^UI/ }).click(); + await expect(page.getByText('Theme', { exact: true })).toBeVisible(); + await expect(page.getByText('Chat width', { exact: true })).toHaveCount( + 0, + ); + // General lost only `setting:timestamps`, so the category keeps its nav + // button and its surviving rows while the excluded row leaves. + await nav.getByRole('button', { name: /^General/ }).click(); + await expect( + page.getByText('Auto-update', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Show Timestamps', { exact: true }), + ).toHaveCount(0); + await nav.getByRole('button', { name: /^Model/ }).click(); + await expect( + page.getByText('Model Fallbacks', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('Fast Model', { exact: true })).toHaveCount( + 0, + ); + await expect(page.getByTestId('model-management')).toHaveCount(0); + await captureScreenshot(page, `settings-panel-exclusions-${theme}`); + }); + test(`permission panel`, async ({ page }, testInfo) => { const scenario = createWebShellDaemonScenario({ events: [permissionRequestEvent('perm-visual', { id: 1 })], diff --git a/packages/web-shell/client/e2e/web-shell.settings-exclusions.spec.ts b/packages/web-shell/client/e2e/web-shell.settings-exclusions.spec.ts new file mode 100644 index 00000000000..d8dca21d87b --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.settings-exclusions.spec.ts @@ -0,0 +1,146 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import type { DaemonWorkspaceSettingsStatus } from '@qwen-code/sdk/daemon'; +import { WEB_SHELL_SETTING_ITEM_IDS } from '../settings'; +import { createWebShellDaemonScenario } from './utils/mockDaemon'; +import { + completeReplay, + installScenario, + resolveBaseURL, + submitLocalCommand, +} from './visuals/harness'; + +test('all live daemon settings have host exclusions @smoke', async ({ + page, +}, testInfo) => { + test.skip(process.platform === 'win32', 'Daemon harness requires POSIX'); + const directory = await mkdtemp(join(tmpdir(), 'qwen-settings-exclusions-')); + const token = 'settings-exclusions-test'; + const daemon = spawn( + process.execPath, + [ + fileURLToPath(new URL('../../../cli/dist/index.js', import.meta.url)), + 'serve', + '--port', + '0', + '--hostname', + '127.0.0.1', + '--token', + token, + '--workspace', + directory, + ], + { + env: { + ...process.env, + QWEN_HOME: join(directory, 'config'), + QWEN_RUNTIME_DIR: join(directory, 'runtime'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + daemon.stdout.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + daemon.stderr.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + let spawnError: Error | undefined; + daemon.on('error', (error) => { + spawnError = error; + }); + try { + await expect + .poll( + () => { + if (spawnError) throw spawnError; + if (daemon.exitCode !== null) throw new Error(output); + return output.match(/listening on http:\/\/127\.0\.0\.1:(\d+)/)?.[1]; + }, + { timeout: 30_000 }, + ) + .toBeTruthy(); + const port = output.match(/listening on http:\/\/127\.0\.0\.1:(\d+)/)![1]; + const response = await fetch( + `http://127.0.0.1:${port}/workspace/settings`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }, + ); + expect(response.status).toBe(200); + const settings = (await response.json()) as DaemonWorkspaceSettingsStatus; + expect(settings.settings.some(({ key }) => key === 'omni.enabled')).toBe( + true, + ); + + // Use live descriptors with the existing browser harness: no copied schema + // or hidden-key list can drift independently from the daemon and page. + const scenario = createWebShellDaemonScenario({ settings }); + const mock = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await page.route('**/workspace/models', (route) => + route.fulfill({ json: { models: [] } }), + ); + await page.route('**/live/setup', (route) => + route.fulfill({ + status: 404, + json: { error: 'not available in fixture' }, + }), + ); + for (const exclude of [[], WEB_SHELL_SETTING_ITEM_IDS]) { + const params = new URLSearchParams({ + sessionId: scenario.sessionId, + exclude: exclude.join(','), + }); + const loaded = page.waitForResponse( + (res) => + res.url().endsWith('/workspace/settings') && + res.request().method() === 'GET', + ); + await page.goto(`/e2e/settings-harness.html?${params}`); + await completeReplay( + page, + mock, + scenario.sessionId, + scenario.events.length, + ); + await submitLocalCommand(page, '/settings'); + await loaded; + const nav = page.getByRole('navigation', { name: 'Settings' }); + if (exclude.length === 0) { + await nav.getByRole('button', { name: /^Experimental/ }).click(); + await expect( + page.getByText('Enable Omni Media Delivery', { exact: true }), + ).toBeVisible(); + } else { + await expect(nav.getByRole('button')).toHaveCount(0); + await expect(page.locator('[data-slot="empty"]')).toBeVisible(); + await page.getByRole('tab', { name: 'User' }).click(); + await expect(nav.getByRole('button')).toHaveCount(0); + await expect(page.locator('[data-slot="empty"]')).toBeVisible(); + } + } + } finally { + if (daemon.pid && daemon.exitCode === null && daemon.signalCode === null) { + const exited = once(daemon, 'exit'); + daemon.kill('SIGTERM'); + const timeout = setTimeout(() => daemon.kill('SIGKILL'), 5_000); + try { + await exited; + } finally { + clearTimeout(timeout); + } + } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/web-shell/client/index.test.tsx b/packages/web-shell/client/index.test.tsx index a68df6c639d..4f09d92840d 100644 --- a/packages/web-shell/client/index.test.tsx +++ b/packages/web-shell/client/index.test.tsx @@ -80,7 +80,11 @@ vi.mock('./components/WebShellTranscript', async () => { // A variable specifier loads the TSX library entry without requiring // allowImportingTsExtensions in this test configuration. const indexEntry = './index.tsx'; -const { WebShellTranscript, WebShellWithProviders } = await import(indexEntry); +const { + WEB_SHELL_SETTING_ITEM_IDS, + WebShellTranscript, + WebShellWithProviders, +} = await import(indexEntry); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -118,6 +122,24 @@ afterEach(() => { vi.unstubAllGlobals(); }); +it('forwards settings presentation through the public provider wrapper', async () => { + const settings = { excludeItems: ['setting:fast-model'] as const }; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + await act(async () => { + root.render(); + }); + expect(appProps.at(-1)?.settings).toBe(settings); +}); + +it('re-exports the published setting item ids from the public entry', async () => { + const { WEB_SHELL_SETTING_ITEM_IDS: direct } = await import('./settings'); + expect(WEB_SHELL_SETTING_ITEM_IDS).toBe(direct); + expect(WEB_SHELL_SETTING_ITEM_IDS.length).toBeGreaterThan(0); +}); + describe('WebShellWithProviders top-level boundary', () => { it('exports the readonly transcript entry', () => { const container = render(); diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index ac4ba88a340..1688d8dbdbe 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -297,3 +297,9 @@ export type { EchartsRuntime, EchartsRuntimeLoader, } from './components/messages/MarkdownChartRenderer'; + +export { WEB_SHELL_SETTING_ITEM_IDS } from './settings'; +export type { + WebShellSettingItemId, + WebShellSettingsOptions, +} from './settings'; diff --git a/packages/web-shell/client/settings.test.ts b/packages/web-shell/client/settings.test.ts new file mode 100644 index 00000000000..7bd9f74b3a4 --- /dev/null +++ b/packages/web-shell/client/settings.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + isItemExcluded, + isSettingExcluded, + WEB_SHELL_SETTING_ITEM_IDS, + type WebShellSettingItemId, +} from './settings'; + +describe('settings presentation aliases', () => { + it('maps stable public aliases to configuration keys without accepting raw paths', () => { + expect( + isSettingExcluded('fastModel', { excludeItems: ['setting:fast-model'] }), + ).toBe(true); + expect( + isSettingExcluded('general.language', { + excludeItems: ['setting:language'], + }), + ).toBe(true); + expect( + isSettingExcluded('visionModel', { + excludeItems: ['setting:fast-model'], + }), + ).toBe(false); + expect( + isSettingExcluded('fastModel', { + excludeItems: ['setting:fastModel' as WebShellSettingItemId], + }), + ).toBe(false); + }); + it('aliases the omni media delivery row', () => { + expect( + isSettingExcluded('omni.enabled', { + excludeItems: ['setting:omni-media-delivery'], + }), + ).toBe(true); + expect(WEB_SHELL_SETTING_ITEM_IDS).toContain('setting:omni-media-delivery'); + }); + it('matches published builtin ids by direct membership', () => { + expect( + isItemExcluded('builtin:model-management', { + excludeItems: ['builtin:model-management'], + }), + ).toBe(true); + expect( + isItemExcluded('builtin:chat-width', { + excludeItems: ['builtin:model-management'], + }), + ).toBe(false); + expect(isItemExcluded('builtin:local-control')).toBe(false); + }); + it('ignores unknown runtime IDs and inherited property names', () => { + for (const id of ['unknown', 'toString', '__proto__']) { + expect( + isSettingExcluded('fastModel', { + excludeItems: [id as WebShellSettingItemId], + }), + ).toBe(false); + } + expect(isSettingExcluded('fastModel')).toBe(false); + expect(isSettingExcluded('fastModel', { excludeItems: [] })).toBe(false); + }); + it('ignores ids inherited from a polluted Object.prototype', () => { + (Object.prototype as Record).someHostProp = 'fastModel'; + try { + expect( + isSettingExcluded('fastModel', { + excludeItems: ['someHostProp' as WebShellSettingItemId], + }), + ).toBe(false); + } finally { + delete (Object.prototype as Record).someHostProp; + } + }); + it('publishes unique IDs including each native frontend block', () => { + expect(new Set(WEB_SHELL_SETTING_ITEM_IDS).size).toBe( + WEB_SHELL_SETTING_ITEM_IDS.length, + ); + for (const id of [ + 'builtin:chat-width', + 'builtin:browser-notifications', + 'builtin:live-setup', + 'builtin:local-control', + 'builtin:model-management', + ]) { + expect(WEB_SHELL_SETTING_ITEM_IDS).toContain(id); + } + }); +}); diff --git a/packages/web-shell/client/settings.ts b/packages/web-shell/client/settings.ts new file mode 100644 index 00000000000..bca7442b4ce --- /dev/null +++ b/packages/web-shell/client/settings.ts @@ -0,0 +1,102 @@ +/** Stable presentation aliases; schema paths are private implementation details. */ +const SETTING_KEYS = { + 'setting:auto-update': 'general.enableAutoUpdate', + 'setting:session-recap': 'general.showSessionRecap', + 'setting:session-recap-away-threshold': + 'general.sessionRecapAwayThresholdMinutes', + 'setting:cleanup-period': 'general.cleanupPeriodDays', + 'setting:git-commit-co-author': 'general.gitCoAuthor.commit', + 'setting:git-pr-co-author': 'general.gitCoAuthor.pr', + 'setting:language': 'general.language', + 'setting:prevent-system-sleep': 'general.preventSystemSleep', + 'setting:review-attribution': 'review.attribution', + 'setting:review-sandbox': 'review.sandbox', + 'setting:review-effort': 'review.effort', + 'setting:review-comment': 'review.comment', + 'setting:review-severity-floor': 'review.severityFloor', + 'setting:review-reverse-audit-rounds': 'review.reverseAuditRounds', + 'setting:review-approach-rounds': 'review.approachRounds', + 'setting:timestamps': 'output.showTimestamps', + 'setting:theme': 'ui.theme', + 'setting:workflow-keyword-trigger': 'ui.disableWorkflowKeywordTrigger', + 'setting:status-in-title': 'ui.showStatusInTitle', + 'setting:response-speed': 'ui.showResponseTokensPerSecond', + 'setting:followup-suggestions': 'ui.enableFollowupSuggestions', + 'setting:tool-call-details': 'ui.showToolCallDetails', + 'setting:shell-output-limit': 'ui.shellOutputMaxLines', + 'setting:usage-statistics': 'privacy.usageStatisticsEnabled', + 'setting:fast-model': 'fastModel', + 'setting:advisor-model': 'advisorModel', + 'setting:vision-model': 'visionModel', + 'setting:model-fallbacks': 'modelFallbacks', + 'setting:respect-git-ignore': 'context.fileFiltering.respectGitIgnore', + 'setting:respect-qwen-ignore': 'context.fileFiltering.respectQwenIgnore', + 'setting:fuzzy-file-search': 'context.fileFiltering.enableFuzzySearch', + 'setting:code-mode-only': 'tools.codeModeOnly', + 'setting:web-search': 'tools.webSearch.enabled', + 'setting:web-search-model': 'tools.webSearch.model', + 'setting:web-extractor': 'tools.webSearch.webExtractor', + 'setting:web-search-timeout': 'tools.webSearch.timeoutMs', + 'setting:web-search-limit': 'tools.webSearch.maxPerSession', + 'setting:tool-search': 'tools.toolSearch.enabled', + 'setting:tool-search-threshold': 'tools.toolSearch.threshold', + 'setting:list-directory': 'tools.listDirectory.enabled', + 'setting:todo-write': 'tools.todoWrite.enabled', + 'setting:interactive-shell': 'tools.shell.enableInteractiveShell', + 'setting:workflows': 'tools.workflowsEnabled', + 'setting:workflow-size': 'tools.workflowSizeGuideline', + 'setting:permission-strategy': 'policy.permissionStrategy', + 'setting:model-proposed-goals': 'goals.modelProposed', + 'setting:arena-artifacts': 'agents.arena.preserveArtifacts', + 'setting:session-workflow': 'experimental.sessionWorkflow', + 'setting:scheduled-tasks': 'experimental.cron', + 'setting:session-writer-lease': 'experimental.sessionWriterLease', + 'setting:agent-team': 'experimental.agentTeam', + 'setting:omni-media-delivery': 'omni.enabled', + 'setting:artifacts': 'experimental.artifact', + 'setting:tool-use-summaries': 'experimental.emitToolUseSummaries', + 'setting:voice-model': 'voiceModel', + 'setting:image-model': 'imageModel', +} as const; + +const BUILTIN_IDS = [ + 'builtin:chat-width', + 'builtin:browser-notifications', + 'builtin:live-setup', + 'builtin:local-control', + 'builtin:model-management', +] as const; + +export type WebShellSettingItemId = + | keyof typeof SETTING_KEYS + | (typeof BUILTIN_IDS)[number]; + +export const WEB_SHELL_SETTING_ITEM_IDS: readonly WebShellSettingItemId[] = [ + ...(Object.keys(SETTING_KEYS) as Array), + ...BUILTIN_IDS, +]; + +export interface WebShellSettingsOptions { + /** Hide native settings items. Presentation only; other commands/APIs remain available. */ + excludeItems?: readonly WebShellSettingItemId[]; +} + +export function isItemExcluded( + id: WebShellSettingItemId, + options?: WebShellSettingsOptions, +): boolean { + return options?.excludeItems?.includes(id) ?? false; +} + +export function isSettingExcluded( + key: string, + options?: WebShellSettingsOptions, +): boolean { + return ( + options?.excludeItems?.some( + (id) => + Object.hasOwn(SETTING_KEYS, id) && + SETTING_KEYS[id as keyof typeof SETTING_KEYS] === key, + ) ?? false + ); +} diff --git a/packages/web-shell/client/visual-capture-contracts.test.ts b/packages/web-shell/client/visual-capture-contracts.test.ts index 2772742216a..55328540831 100644 --- a/packages/web-shell/client/visual-capture-contracts.test.ts +++ b/packages/web-shell/client/visual-capture-contracts.test.ts @@ -80,14 +80,18 @@ describe('visual capture contracts', () => { expect(sources.length).toBeGreaterThan(5); }); - it('keeps both navigation helpers freezing the clock before they navigate', () => { + it('keeps every navigation helper freezing the clock before it navigates', () => { // `freezeWallClock` runs only implicitly, and nothing in the visuals suite // reads the page clock -- so dropping the call, or moving it after // `page.goto`, leaves every test green while timestamped captures silently // resume drifting between the base and head passes. const harness = readFileSync(join(VISUALS_DIR, 'harness.ts'), 'utf8'); - for (const helper of ['gotoSession', 'gotoNewSession']) { + for (const helper of [ + 'gotoSession', + 'gotoNewSession', + 'gotoSettingsHarness', + ]) { const body = harness.slice( harness.indexOf(`export async function ${helper}(`), );