From 6773e10645bef3896eba5e911314237910204cc0 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 14:04:44 +0800 Subject: [PATCH 01/12] feat(web): add fuzzy search to composer model picker Signed-off-by: Yao Meng --- apps/web/tests/default-model.e2e.ts | 14 ++++- .../expected/default-model/search.expected.md | 5 ++ .../ui-model-selection/README.i18n.yaml | 8 +-- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.module.css | 5 ++ .../src/client/ModelSelect.tsx | 47 ++++++++++++----- .../ui-model-selection/src/client/locales.ts | 4 ++ .../tests/model-select.client.spec.tsx | 52 ++++++++++++++++--- 9 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 apps/web/tests/expected/default-model/search.expected.md diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index aa17206061..88610f68ec 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -107,6 +107,18 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') await trigger.click() await page.getByRole('menuitem', { name: /模型/ }).click() + const search = page.getByRole('searchbox', { name: '搜索模型…' }) + await search.fill('zzzz') + await page.getByText('没有匹配的模型。', { exact: true }).waitFor() + await search.fill(' ACMLG ') + expect(await page.getByRole('menuitemradio').allTextContents()).toEqual(['Acme Large']) + expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)), + await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), + webSnapshotMode(), + ) + await search.press('ArrowDown') const entered = Promise.withResolvers() const release = Promise.withResolvers() const blocked = scaffold.ctx.hmr.runExclusive(async () => { @@ -115,7 +127,7 @@ describe('web e2e: the composer model switch is the default for later sessions', }) try { await entered.promise - await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() + await page.getByRole('menuitemradio', { name: 'Acme Large' }).press('Enter') await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false') await expect.poll(() => trigger.textContent()).toContain('Acme Large') expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL }) diff --git a/apps/web/tests/expected/default-model/search.expected.md b/apps/web/tests/expected/default-model/search.expected.md new file mode 100644 index 0000000000..646b890501 --- /dev/null +++ b/apps/web/tests/expected/default-model/search.expected.md @@ -0,0 +1,5 @@ +- menu "模型与推理等级": + - searchbox "搜索模型…": ACMLG + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Large" diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 65c04a8b64..c15da07a87 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-model-selection/use-this-package: - en: e5a1df3a4dfe5041 - zh: 6269e73026b32a33 + en: 292d778a6bfb4130 + zh: 15009b57f706cfb4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: e38d0195677a7b69 - zh: 4eea8a9b1664afd9 + en: 89daf261ce28f29a + zh: c72833dfb8345daf /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index f1da11e94a..118f3be24f 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,13 +31,13 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses the model search field or the current effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open, and Tab returns to the current row. ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Empty groups are hidden. Tab or an arrow key moves from search into the results; reopening the model pane clears the query. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 42719f4476..dae1316885 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,13 +31,13 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻聚焦模型搜索框或当前推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。没有匹配项的分组会隐藏。Tab 或方向键从搜索框进入结果列表;重新打开模型面板会清空搜索内容。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 74b3311cdb..ec04efe576 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -155,6 +155,11 @@ cursor: pointer; } +.search { + flex-shrink: 0; + margin: 4px; +} + .groups { min-height: 0; overflow-y: auto; diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 28e4bfa800..0ce19850eb 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -9,9 +9,10 @@ * step taken while the trigger still holds focus enters at the near end), Tab * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and * otherwise close back to the trigger. A drilled pane hands focus to the row - * of the value in use, and returning to the root pane hands it back to the - * cell that opened it. Data and submission ride the SAME per-session - * ModelDirectory as the /model popup; exact-model reasoning metadata and the + * of the effort in use or the model search field. Model names match a + * case-insensitive ordered subsequence within each provider group. Returning + * to the root pane hands focus back to the cell that opened it. Data and + * submission ride the SAME per-session ModelDirectory as the /model popup; exact-model reasoning metadata and the * selected effort come from the Host rather than a client-owned vocabulary. A * rejected selection announces through the shared transient Toast anchored to * the composer card; the in-menu strip with Retry remains the catalog-load @@ -29,7 +30,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, - IconDataOutlineRegular, IconWarningOutlineRegular, StateDot, Toast, + IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' @@ -64,6 +65,7 @@ export function ModelSelect( ) const [open, setOpen] = useState(false) const [pane, setPane] = useState('root') + const [query, setQuery] = useState('') // The in-menu error strip serves catalog loads (its Retry re-runs the // load); a rejected SELECTION announces through the transient toast // instead, so the strip renders only while the latest failure-capable @@ -93,6 +95,9 @@ export function ModelSelect( : { reasoningEffort: model.reasoning.defaultEffort }, } satisfies ModelSelection, }))), [groups]) + const filteredGroups = useMemo(() => groups.map(group => ({ + ...group, models: rankByName(group.models, query.trim()), + })).filter(group => group.models.length > 0), [groups, query]) const selectedIndex = state.current === null ? -1 : choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model) @@ -136,17 +141,18 @@ export function ModelSelect( return () => { document.removeEventListener('mousedown', closeOutside) } }, [open]) - // A pane switch unmounts the row that had focus, which drops focus onto the - // page body — outside the card's subtree, where its key handling no longer - // sees a keystroke. Every switch therefore names where the keyboard lands: - // drilling on the pane's current value, coming back on the cell that opened - // the pane left. + // Pane switches unmount the focused row; restore focus inside the menu so + // keyboard navigation remains available. const paneFocus = useRef<'drill' | 'model' | 'effort' | null>(null) useEffect(() => { const intent = paneFocus.current paneFocus.current = null if (!open || intent === null) return if (intent === 'drill') { + if (pane === 'model') { + menuRef.current?.querySelector('input')?.focus() + return + } // The checked row is the value in use; a pane without one opens on its // first row. const checked = menuRef.current?.querySelector('[role="menuitemradio"][aria-checked="true"]:not([disabled])') @@ -191,7 +197,7 @@ export function ModelSelect( window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, pane, state]) + }, [open, pane, state, query]) /* jscpd:ignore-end */ if (!available) return null @@ -211,6 +217,7 @@ export function ModelSelect( } const drill = (next: Pane): void => { + setQuery('') paneFocus.current = 'drill' setPane(next) } @@ -235,6 +242,7 @@ export function ModelSelect( } const onRootKeyDown = (event: KeyboardEvent): void => { + if (event.nativeEvent.isComposing) return if (event.key === 'Escape' && open) { event.preventDefault() // Escape backs out of a drilled pane first, then closes. @@ -243,6 +251,11 @@ export function ModelSelect( return } if (!open) return + if (event.key === 'Tab' && !event.shiftKey && event.target instanceof HTMLInputElement) { + event.preventDefault() + moveFocus(1) + return + } // Tab settles like Enter and Shift+Tab leaves like Escape, so the menu's // keys mean what they mean in the composer. Both are consumed: the card // keeps the browser's focus traversal out while it is open. @@ -419,6 +432,14 @@ export function ModelSelect( {pane === 'model' && ( <> + { setQuery(event.target.value) }} + /> {state.status === 'loading' && (
{t('status.loading')}
)} @@ -435,7 +456,7 @@ export function ModelSelect( ))}
- {groups.map((group) => { + {filteredGroups.map((group) => { const headingId = `${id}-${group.id}` return (
@@ -469,8 +490,8 @@ export function ModelSelect( ) })}
- {state.status === 'ready' && choices.length === 0 && ( -
{t('empty.models')}
+ {state.status === 'ready' && filteredGroups.length === 0 && ( +
{t(choices.length === 0 ? 'empty.models' : 'search.empty')}
)} )} diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index bb61b7e931..b5e142a476 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -30,6 +30,8 @@ export const zh = { 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', + 'search.placeholder': '搜索模型…', + 'search.empty': '没有匹配的模型。', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -59,6 +61,8 @@ export const en = { 'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', + 'search.placeholder': 'Search models…', + 'search.empty': 'No matching models.', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index aafc5b98a9..bbfbbc919d 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -469,9 +469,7 @@ describe('ModelSelect keyboard walk', () => { const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) - // No rows to hand the keyboard to: the trigger keeps it, so the card's - // keys still reach the menu. - expect(document.activeElement).toBe(trigger) + expect(document.activeElement).toBe(screen.getByRole('searchbox')) const retry = screen.getByRole('button', { name: '重试' }) expect(fireEvent.mouseDown(retry)).toBe(false) @@ -489,12 +487,12 @@ describe('ModelSelect keyboard walk', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('drills into the model list on the selected model', () => { + it('focuses search while retaining the checked model', () => { mountOpen() fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows[0]!.getAttribute('aria-checked')).toBe('true') - expect(document.activeElement).toBe(rows[0]) + expect(document.activeElement).toBe(screen.getByRole('searchbox')) }) it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => { @@ -520,7 +518,7 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(cells[0]) }) - it('a pane whose rows mark no current value opens on its first row', () => { + it('focuses search when no model is checked', () => { // The session runs a model the catalog no longer lists: no row is checked. render( { fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) - expect(document.activeElement).toBe(rows[0]) + expect(document.activeElement).toBe(screen.getByRole('searchbox')) + }) +}) + +describe('ModelSelect search', () => { + it('filters model names fuzzily, hides empty groups, clears on reopening, and selects a result', async () => { + const directory = createSnapshotStore(state({ groups: [ + ...state().groups, + { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, + ] })) + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const search = screen.getByRole('searchbox') + expect(document.activeElement).toBe(search) + expect(fireEvent.keyDown(search, { key: 'ArrowDown', isComposing: true })).toBe(true) + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: ' GMFL ' } }) + expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Gemini Flash']) + expect(screen.queryByRole('group', { name: 'DeepSeek' })).toBeNull() + expect(trigger.textContent).toContain('DeepSeek-V4-Flash') + fireEvent.change(search, { target: { value: 'zzzz' } }) + expect(screen.getByRole('status').textContent).toBe('没有匹配的模型。') + expect(fireEvent.keyDown(search, { key: 'ArrowDown' })).toBe(false) + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: '' } }) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(2) + fireEvent.change(search, { target: { value: 'gmfl' } }) + fireEvent.keyDown(search, { key: 'Escape' }) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.getByRole('searchbox').getAttribute('value')).toBe('') + const reopened = screen.getByRole('searchbox') + fireEvent.change(reopened, { target: { value: 'gmfl' } }) + fireEvent.keyDown(reopened, { key: 'Tab' }) + const row = screen.getByRole('menuitemradio', { name: 'Gemini Flash' }) + expect(document.activeElement).toBe(row) + fireEvent.keyDown(row, { key: 'Tab' }) + await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() }) + expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gemini' }) }) }) From b29578b8f0f4e2ed8ae8e1ce491764dae4bd40b2 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 14:50:38 +0800 Subject: [PATCH 02/12] style(web): soften model search field and add leading icon Signed-off-by: Yao Meng --- .../src/client/ModelSelect.module.css | 18 ++++++++++++++++-- .../src/client/ModelSelect.tsx | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index ec04efe576..7f57d8371a 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -155,9 +155,23 @@ cursor: pointer; } -.search { +.menu .search { flex-shrink: 0; - margin: 4px; + gap: 8px; + height: 34px; + margin: 6px; + padding: 0 10px; + border: none; + border-radius: 10px; + background: var(--dsw-alias-bg-module-platform); +} + +.search:focus-within { + box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); +} + +.search input { + font-size: 13px; } .groups { diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 0ce19850eb..c8a5ff76c9 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -30,7 +30,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, - IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, + IconDataOutlineRegular, IconSearchOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' @@ -433,6 +433,7 @@ export function ModelSelect( {pane === 'model' && ( <> } className={clsx(css.search)} type="search" aria-label={t('search.placeholder')} From 4ff33be0bdc291832a177dda3fc08aecfd4a1135 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 14:52:02 +0800 Subject: [PATCH 03/12] test(web): update provider model picker search snapshot Signed-off-by: Yao Meng --- .../tests/expected/deepseek-messages-settings/picker.expected.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index 37066e17e2..25ead71098 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,4 +1,5 @@ - menu "模型与推理等级": + - searchbox "搜索模型…" - group "DeepSeek": - text: DeepSeek - menuitemradio "Messages Flash" [checked] From b3d6da908332f9bdb145b5f8b4e4bae7c3ce9332 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 15:23:18 +0800 Subject: [PATCH 04/12] fix(web): address model search accessibility review Signed-off-by: Yao Meng --- .../tests/deepseek-messages-settings.e2e.ts | 2 +- apps/web/tests/default-model.e2e.ts | 6 ++-- .../picker.expected.md | 11 +++---- .../expected/default-model/search.expected.md | 9 +++--- .../ui-model-selection/README.i18n.yaml | 4 +-- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 29 ++++++++++++------- .../tests/model-select.client.spec.tsx | 7 ++++- 9 files changed, 44 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 6543428836..0b2ebfb531 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'), - await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode()) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 88610f68ec..fad2f1a182 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -115,10 +115,12 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0) await compareOrRefreshGolden( fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)), - await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode(), ) await search.press('ArrowDown') + await expect.poll(() => page.getByRole('menuitemradio', { name: 'Acme Large' }) + .evaluate(row => row === row.ownerDocument.activeElement)).toBe(true) const entered = Promise.withResolvers() const release = Promise.withResolvers() const blocked = scaffold.ctx.hmr.runExclusive(async () => { @@ -127,7 +129,7 @@ describe('web e2e: the composer model switch is the default for later sessions', }) try { await entered.promise - await page.getByRole('menuitemradio', { name: 'Acme Large' }).press('Enter') + await page.keyboard.press('Enter') await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false') await expect.poll(() => trigger.textContent()).toContain('Acme Large') expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL }) diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index 25ead71098..ab0219c316 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,6 +1,7 @@ -- menu "模型与推理等级": +- group "模型与推理等级": - searchbox "搜索模型…" - - group "DeepSeek": - - text: DeepSeek - - menuitemradio "Messages Flash" [checked] - - menuitemradio "DeepSeek-V4-Pro" + - menu "模型": + - group "DeepSeek": + - text: DeepSeek + - menuitemradio "Messages Flash" [checked] + - menuitemradio "DeepSeek-V4-Pro" diff --git a/apps/web/tests/expected/default-model/search.expected.md b/apps/web/tests/expected/default-model/search.expected.md index 646b890501..457cc4d5f9 100644 --- a/apps/web/tests/expected/default-model/search.expected.md +++ b/apps/web/tests/expected/default-model/search.expected.md @@ -1,5 +1,6 @@ -- menu "模型与推理等级": +- group "模型与推理等级": - searchbox "搜索模型…": ACMLG - - group "Acme Gateway": - - text: Acme Gateway - - menuitemradio "Acme Large" + - menu "模型": + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Large" diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index c15da07a87..31d9628090 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 292d778a6bfb4130 zh: 15009b57f706cfb4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 89daf261ce28f29a - zh: c72833dfb8345daf + en: 4cf17d5a20e43800 + zh: 2e865599fbf6c591 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 118f3be24f..ceb36aac1e 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -37,7 +37,7 @@ Mouse selection uses native browser clicks, including their cancellation behavio ### Model and effort -Models stay grouped by provider. The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Empty groups are hidden. Tab or an arrow key moves from search into the results; reopening the model pane clears the query. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Tab or an arrow key moves from search into the results; reopening the model pane clears the query. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index dae1316885..70e28bf96b 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -37,7 +37,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。没有匹配项的分组会隐藏。Tab 或方向键从搜索框进入结果列表;重新打开模型面板会清空搜索内容。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。Tab 或方向键从搜索框进入结果列表;重新打开模型面板会清空搜索内容。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index c8a5ff76c9..6800fe7f10 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -10,15 +10,17 @@ * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and * otherwise close back to the trigger. A drilled pane hands focus to the row * of the effort in use or the model search field. Model names match a - * case-insensitive ordered subsequence within each provider group. Returning - * to the root pane hands focus back to the cell that opened it. Data and - * submission ride the SAME per-session ModelDirectory as the /model popup; exact-model reasoning metadata and the - * selected effort come from the Host rather than a client-owned vocabulary. A - * rejected selection announces through the shared transient Toast anchored to - * the composer card; the in-menu strip with Retry remains the catalog-load - * surface. While the directory's pending selection is unsettled, the trigger - * shows a spinner in place of its chevron, and each row whose value that - * selection carries shows one in place of its check mark. + * case-insensitive ordered subsequence within each provider group, ranked by + * prefix, alignment score, then catalog order. Returning to the root pane + * hands focus back to the cell that opened it. Data and submission ride the + * same per-session ModelDirectory as the /model popup; exact-model reasoning + * metadata and the selected effort come from the Host rather than a + * client-owned vocabulary. A rejected selection announces through the shared + * transient Toast anchored to the composer card; the in-menu strip with + * Retry remains the catalog-load surface. While the directory's pending + * selection is unsettled, the trigger shows a spinner in place of its + * chevron, and each row whose value that selection carries shows one in place + * of its check mark. */ import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' import { @@ -409,7 +411,7 @@ export function ModelSelect( id={`${id}-menu`} className={css.menu} style={menuPos ?? MEASURE_STYLE} - role="menu" + role={pane === 'model' ? 'group' : 'menu'} aria-label={t('menu.aria')} aria-busy={state.status === 'loading' || busy} > @@ -456,7 +458,12 @@ export function ModelSelect( ))} -
+ ))} +
} {state.status === 'loading' && (
{t('status.loading')}
)} @@ -571,12 +595,16 @@ export function ModelSelect( role="menuitemradio" aria-checked={selected} id={`${id}-model-${index}`} - tabIndex={-1} + tabIndex={showSearch ? -1 : 0} + onFocus={() => { setHighlightedIndex(index) }} data-highlighted={index === activeModelIndex ? '' : undefined} className={clsx( css.option, css.modelOption, selected && css.selected, index === activeModelIndex && css.optionActive, )} - onMouseMove={busy || index === activeModelIndex ? undefined : () => { setHighlightedIndex(index) }} + onMouseMove={busy || index === activeModelIndex ? undefined : () => { + if (showSearch) setHighlightedIndex(index) + else itemRefs.current[index]?.focus() + }} key={model.id} title={model.name} disabled={busy} diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 38c87c6a4a..0341daa436 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -53,6 +53,16 @@ function state(overrides: Partial = {}): ModelDirectoryStat } } +function modelGroups(count: number): ModelDirectoryState['groups'] { + const group = state().groups[0]! + return [{ ...group, models: [ + ...group.models, + ...Array.from({ length: Math.max(0, count - 1) }, (_, index) => ({ + id: `model-${index + 2}`, name: `Model ${index + 2}`, + })), + ].slice(0, count) }] +} + const scrollIntoView = vi.fn() beforeEach(() => { scrollIntoView.mockClear() @@ -236,10 +246,8 @@ describe('ModelSelect reasoning effort', () => { // The selection failure does not render the in-menu load strip (no Retry). expect(screen.queryByRole('button', { name: '重试' })).toBeNull() fireEvent.keyDown(trigger, { key: 'Tab' }) - const search = screen.getByRole('searchbox') - expect(document.activeElement).toBe(search) - expect(search.getAttribute('aria-activedescendant')) - .toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }).id) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })) }) it('spins on the trigger and the chosen model row until the selection settles, across pane changes', async () => { @@ -487,13 +495,14 @@ describe('ModelSelect keyboard walk', () => { const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) - expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(trigger) const retry = screen.getByRole('button', { name: '重试' }) expect(fireEvent.mouseDown(retry)).toBe(false) fireEvent.click(retry) expect(load).toHaveBeenCalledTimes(2) - expect(screen.getByRole('menu')).toBeTruthy() + expect(screen.getByRole('group', { name: '模型与推理等级' })).toBeTruthy() retry.focus() // A control that is not a row keeps the browser's traversal. expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true) @@ -505,12 +514,13 @@ describe('ModelSelect keyboard walk', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('focuses search while retaining the checked model', () => { + it('focuses the checked model in a small catalog', () => { mountOpen() fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows[0]!.getAttribute('aria-checked')).toBe('true') - expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(rows[0]) }) it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => { @@ -536,7 +546,7 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(cells[0]) }) - it('focuses search when no model is checked', () => { + it('focuses the first row when no model is checked in a small catalog', () => { // The session runs a model the catalog no longer lists: no row is checked. render( { fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) - expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(rows[0]) + }) +}) + +describe('ModelSelect catalog size', () => { + it.each([0, 1, 4, 5])('shows search only above four models (%i models)', (count) => { + const groups = modelGroups(count) + const current = { provider: 'deepseek-official', model: count > 1 ? 'model-2' : 'deepseek-v4-flash' } + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const rows = screen.queryAllByRole('menuitemradio') + expect(rows).toHaveLength(count) + if (count > 4) { + const search = screen.getByRole('searchbox') + expect(search).toBeInstanceOf(HTMLInputElement) + expect(document.activeElement).toBe(search) + expect(search.getAttribute('aria-activedescendant')).toBe(rows[1]!.id) + expect(rows.every(row => row.tabIndex === -1)).toBe(true) + } else { + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(count === 0 ? trigger : rows[count > 1 ? 1 : 0]) + expect(rows.every(row => row.tabIndex === 0)).toBe(true) + } + if (count === 0) expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + }) + + it.each([0, 1, 4, 5])('opens an unselected %i-model catalog on search or its first row', (count) => { + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + fireEvent.click(trigger) + const rows = screen.queryAllByRole('menuitemradio') + expect(rows).toHaveLength(count) + expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) + expect(document.activeElement).toBe(count > 4 ? screen.getByRole('searchbox') : rows[0] ?? trigger) + if (count <= 4) expect(screen.queryByRole('searchbox')).toBeNull() + }) + + it.each([true, false])('clears search and restores focus across 5 → 4 → 5 models (checked: %s)', (checked) => { + const current = { provider: 'deepseek-official', model: checked ? 'model-3' : 'removed' } + const directory = createSnapshotStore(state({ groups: modelGroups(5), current })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const search = screen.getByRole('searchbox') + fireEvent.change(search, { target: { value: 'Model 5' } }) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(1) + expect(screen.getByRole('searchbox')).toBe(search) + expect(document.activeElement).toBe(search) + + act(() => { directory.set(state({ groups: modelGroups(4), current })) }) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull() + const rows = screen.getAllByRole('menuitemradio') + expect(rows).toHaveLength(4) + expect(document.activeElement).toBe(rows[checked ? 2 : 0]) + + act(() => { directory.set(state({ groups: modelGroups(5), current })) }) + const restored = screen.getByRole('searchbox') + expect(restored.getAttribute('value')).toBe('') + expect(document.activeElement).toBe(restored) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + expect(restored.getAttribute('aria-activedescendant')) + .toBe(screen.getAllByRole('menuitemradio')[checked ? 2 : 0]!.id) + }) + + it('moves actual row focus with arrows, leaves Enter native, and selects with Tab in a small catalog', async () => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const rows = screen.getAllByRole('menuitemradio') + expect(document.activeElement).toBe(rows[1]) + expect(fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })).toBe(false) + expect(document.activeElement).toBe(rows[2]) + fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(rows[3]) + fireEvent.keyDown(rows[3]!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(rows[0]) + fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(rows[3]) + fireEvent.keyDown(rows[3]!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(rows[2]) + expect(rows[2]!.hasAttribute('data-highlighted')).toBe(true) + expect(fireEvent.keyDown(rows[2]!, { key: 'Enter' })).toBe(true) + expect(fireEvent.keyDown(rows[2]!, { key: 'ArrowLeft' })).toBe(true) + expect(document.activeElement).toBe(rows[2]) + expect(select).not.toHaveBeenCalled() + expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false) + expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + }) + + it('keeps Tab selection available after hovering a small-catalog row', async () => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const row = screen.getByRole('menuitemradio', { name: 'Model 3' }) + fireEvent.mouseMove(row) + expect(document.activeElement).toBe(row) + expect(row.hasAttribute('data-highlighted')).toBe(true) + expect(fireEvent.keyDown(row, { key: 'Tab' })).toBe(false) + expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' }) + await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) + }) + + it.each(['Escape', 'Tab'])('leaves a small model pane with %s and returns to its root cell', (key) => { + const select = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const row = screen.getAllByRole('menuitemradio')[0]! + expect(fireEvent.keyDown(row, { key, shiftKey: key === 'Tab' })).toBe(false) + const cell = screen.getByRole('menuitem', { name: /^模型/ }) + expect(document.activeElement).toBe(cell) + expect(select).not.toHaveBeenCalled() + fireEvent.keyDown(cell, { key, shiftKey: key === 'Tab' }) + expect(screen.queryByRole('menu')).toBeNull() }) }) describe('ModelSelect search', () => { + it('clears search when reopening an unselected large catalog', () => { + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + fireEvent.click(trigger) + const search = screen.getByRole('searchbox') + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: 'zzzz' } }) + fireEvent.keyDown(search, { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + fireEvent.click(trigger) + expect(screen.getByRole('searchbox').getAttribute('value')).toBe('') + expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + }) + + it('hides empty provider headings and announces an empty catalog', () => { + const directory = createSnapshotStore(state({ groups: [ + ...state().groups, { id: 'empty', name: 'Empty Provider', models: [] }, + ] })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByRole('group', { name: 'Empty Provider' })).toBeNull() + act(() => { directory.set(state({ groups: [] })) }) + expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + expect(screen.getByRole('status').closest('[role="menu"]')).toBeNull() + }) + + it.each(['Enter', 'Tab'])('returns focus silently after %s accepts the current model, without suppressing later focus', async (key) => { + const select = vi.fn() + render(<> + + + ) + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + fireEvent.keyDown(screen.getByRole('searchbox'), { key }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + expect(select).not.toHaveBeenCalled() + act(() => { screen.getByRole('button', { name: 'Outside' }).focus() }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(false) + act(() => { trigger.focus() }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(false) + }) + it.each(['Enter', 'Tab'])('keeps typing focus while arrows wrap across groups and %s accepts the highlight', async (key) => { const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) const directory = createSnapshotStore(state({ current: { provider: 'deepseek-official', model: 'beta' }, groups: [ { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }] }, - { id: 'other', name: 'Other', models: [{ id: 'gamma', name: 'Gamma' }] }, + { id: 'other', name: 'Other', models: [ + { id: 'delta', name: 'Delta' }, { id: 'epsilon', name: 'Epsilon' }, { id: 'gamma', name: 'Gamma' }, + ] }, ], })) render() fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) const search = screen.getByRole('searchbox') - const [alpha, beta, gamma] = screen.getAllByRole('menuitemradio') + expect(search).toBeInstanceOf(HTMLInputElement) + const [alpha, beta, delta, epsilon, gamma] = screen.getAllByRole('menuitemradio') expect(search.getAttribute('aria-activedescendant')).toBe(beta!.id) fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(delta!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(epsilon!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id) expect(document.activeElement).toBe(search) fireEvent.keyDown(search, { key: 'ArrowDown' }) @@ -594,17 +791,20 @@ describe('ModelSelect search', () => { fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) const restored = screen.getAllByRole('menuitemradio') expect(search.getAttribute('aria-activedescendant')).toBe(restored[0]!.id) - fireEvent.mouseMove(restored[2]!) - expect(search.getAttribute('aria-activedescendant')).toBe(restored[2]!.id) + fireEvent.mouseMove(restored[4]!) + expect(search.getAttribute('aria-activedescendant')).toBe(restored[4]!.id) expect(document.activeElement).toBe(search) fireEvent.keyDown(search, { key }) expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gamma' }) await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) + const trigger = screen.getByRole('button', { name: /选择模型/ }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) }) it('filters model names fuzzily, hides empty groups, clears on reopening, and selects a result', async () => { const directory = createSnapshotStore(state({ groups: [ - ...state().groups, + ...modelGroups(4), { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, ] })) const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) @@ -619,6 +819,8 @@ describe('ModelSelect search', () => { expect(document.activeElement).toBe(search) fireEvent.change(search, { target: { value: ' GMFL ' } }) expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Gemini Flash']) + expect(screen.getByRole('searchbox')).toBe(search) + expect(document.activeElement).toBe(search) expect(screen.queryByRole('group', { name: 'DeepSeek' })).toBeNull() expect(trigger.textContent).toContain('DeepSeek-V4-Flash') fireEvent.change(search, { target: { value: 'zzzz' } }) @@ -632,7 +834,7 @@ describe('ModelSelect search', () => { expect(search.getAttribute('value')).toBe('') expect(document.activeElement).toBe(search) expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull() - expect(screen.getAllByRole('menuitemradio')).toHaveLength(2) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) fireEvent.change(search, { target: { value: 'gmfl' } }) fireEvent.keyDown(search, { key: 'Escape' }) fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) @@ -699,10 +901,15 @@ it('shows the unselected model control with the inherited effort', async () => { expect(trigger.textContent).toContain('High') fireEvent.click(trigger) expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() - const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) - expect(document.activeElement).toBe(model) - fireEvent.keyDown(model, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() + expect(screen.queryByRole('searchbox')).toBeNull() + const row = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(row) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + fireEvent.click(trigger) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a971b9e834..6b157578fb 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: b521f773eff5c728 - zh: 7b56c5b4777a0b00 + en: c4d22d4642ec6fe0 + zh: 27c4dcae204d1f8f /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 663832fe07..f69307078a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. | | `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. | | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | -| `Input` | Single-line text entry for search boxes and inline forms. | +| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 03b43c609a..cfc7653184 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -44,7 +44,7 @@ kind: "package-library" | `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 | | `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 | | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | -| `Input` | 单行文本输入,用于搜索框与行内表单。 | +| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | diff --git a/packages/client/ui-primitives/src/Input.tsx b/packages/client/ui-primitives/src/Input.tsx index 3af60f9d18..888bc11afb 100644 --- a/packages/client/ui-primitives/src/Input.tsx +++ b/packages/client/ui-primitives/src/Input.tsx @@ -1,23 +1,24 @@ // Input: single-line text input atom (search boxes, inline forms). Composer // textareas are NOT this atom — they live with the conversation package. -import type { InputHTMLAttributes, ReactNode } from 'react' +import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react' import clsx from 'clsx' import css from './Input.module.css' /** * Render a text input with an optional leading icon. * @param props.icon - optional 16px leading icon node. + * @param ref - the native input, cleared when it unmounts. * @returns wrapper span containing the native input; input attributes pass through. */ -export function Input({ icon, className, ...rest }: { +export const Input = forwardRef) { +} & InputHTMLAttributes>(function Input({ icon, className, ...rest }, ref) { return ( {icon != null && {icon}} - + ) -} +}) diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 842ea536cf..bc48ea0d38 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -59,6 +59,16 @@ describe('Pill', () => { }) describe('Input', () => { + it('exposes its native input for focus and releases the ref on unmount', () => { + const ref = createRef() + const { unmount } = render() + expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Search' })) + ref.current?.focus() + expect(document.activeElement).toBe(ref.current) + unmount() + expect(ref.current).toBeNull() + }) + it('forwards value/onChange and renders the leading icon', () => { const onChange = vi.fn() render(} value="q" onChange={onChange} placeholder="search" />) From 989b93d86ff6b796bb8355bbbd741439343c955e Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 28 Sep 2026 15:33:28 +0800 Subject: [PATCH 10/12] feat(web): unify grouped model picker ordering and sticky headers --- apps/web/tests/default-model.e2e.ts | 53 ++++- .../default-model/command-picker.expected.md | 16 +- packages/client/ui-commands/README.i18n.yaml | 8 +- packages/client/ui-commands/README.md | 6 +- packages/client/ui-commands/README.zh.md | 6 +- .../src/client/PopupSelectView.tsx | 65 +++--- .../client/ui-commands/src/client/contract.ts | 13 ++ .../client/ui-commands/src/client/index.ts | 2 +- .../ui-commands/src/client/option-groups.ts | 24 ++ .../client/ui-commands/src/client/popup.ts | 47 ++-- .../tests/popup-view.client.spec.tsx | 24 ++ .../ui-commands/tests/popup.client.spec.ts | 30 +++ .../ui-model-selection/README.i18n.yaml | 8 +- packages/client/ui-model-selection/README.md | 6 +- .../client/ui-model-selection/README.zh.md | 6 +- .../src/client/ModelSelect.module.css | 24 -- .../src/client/ModelSelect.tsx | 38 +-- .../ui-model-selection/src/client/index.ts | 6 +- .../src/client/provider-order.ts | 12 + .../tests/browser-plugin.client.spec.ts | 29 ++- .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 5 + packages/client/ui-primitives/README.zh.md | 5 + .../ui-primitives/src/MenuGroup.module.css | 23 ++ .../client/ui-primitives/src/MenuGroup.tsx | 56 +++++ packages/client/ui-primitives/src/index.ts | 1 + .../tests/menu-group.client.spec.tsx | 219 ++++++++++++++++++ 27 files changed, 610 insertions(+), 130 deletions(-) create mode 100644 packages/client/ui-commands/src/client/option-groups.ts create mode 100644 packages/client/ui-model-selection/src/client/provider-order.ts create mode 100644 packages/client/ui-primitives/src/MenuGroup.module.css create mode 100644 packages/client/ui-primitives/src/MenuGroup.tsx create mode 100644 packages/client/ui-primitives/tests/menu-group.client.spec.tsx diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 53fbb421c5..d87bf4ce4d 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -13,7 +13,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' @@ -135,6 +135,57 @@ describe('web e2e: the composer model switch is the default for later sessions', } }) + it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => { + const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]') + .evaluateAll((groups, rowRole) => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll(`[role="${rowRole}"]`)].map(row => row.textContent), + })), role) + const checkSticky = async (surface: Locator, viewport: Locator) => { + // Bound only this test's list viewport so native scrolling can cross a heading. + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + const heading = surface.locator('[data-menu-group-heading]').first() + await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull() + const clear = await heading.evaluate(node => getComputedStyle(node).backgroundColor) + expect(clear).toBe('rgba(0, 0, 0, 0)') + await viewport.evaluate((node) => { node.scrollTop = 10 }) + await expect.poll(() => heading.getAttribute('data-stuck')).toBe('') + const stuck = await heading.evaluate((node) => { + const style = getComputedStyle(node) + return { fill: style.backgroundColor, radius: style.borderRadius, font: style.fontSize, padding: style.padding } + }) + await viewport.evaluate((node) => { node.scrollTop = 0 }) + await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull() + return stuck + } + try { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + const menu = page.getByRole('group', { name: '模型与推理等级', exact: true }) + const menuSearch = page.getByRole('searchbox', { name: '搜索模型…' }) + const order = await readGroups(menu, 'menuitemradio') + const sticky = await checkSticky(menu, menu.getByRole('menu', { name: '模型', exact: true })) + await menuSearch.fill(' ACMLG ') + const filtered = await readGroups(menu, 'menuitemradio') + await menuSearch.press('Escape') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + const popup = page.locator('[aria-label="/model 选项"]') + await popup.getByRole('option').first().waitFor() + expect(await readGroups(popup, 'option')).toEqual(order) + expect(await checkSticky(popup, popup.getByRole('listbox'))).toEqual(sticky) + const popupSearch = page.getByRole('textbox', { name: '筛选选项', exact: true }) + await popupSearch.fill(' ACMLG ') + expect(await readGroups(popup, 'option')).toEqual(filtered) + expect(await popupSearch.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + } finally { + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await page.locator('[data-composer-input]').first().fill('') + } + }) + it('hides search only in the button menu when four models remain', async () => { const trigger = page.getByRole('button', { name: /^选择模型/ }) const search = page.getByRole('searchbox', { name: '搜索模型…' }) diff --git a/apps/web/tests/expected/default-model/command-picker.expected.md b/apps/web/tests/expected/default-model/command-picker.expected.md index 5a37bd75f7..22cfbf980f 100644 --- a/apps/web/tests/expected/default-model/command-picker.expected.md +++ b/apps/web/tests/expected/default-model/command-picker.expected.md @@ -1,8 +1,14 @@ - textbox "筛选选项": - /placeholder: 搜索模型… - listbox "/model 匹配项": - - option "DeepSeek-V4-Flash DeepSeek" - - option "DeepSeek-V4-Flash-Vision-Exp DeepSeek" - - option "Origin Large Origin Gateway" - - option "Acme Large Acme Gateway" [selected] - - option "Acme Small Acme Gateway" + - group "DeepSeek": + - text: DeepSeek + - option "DeepSeek-V4-Flash" + - option "DeepSeek-V4-Flash-Vision-Exp" + - group "Origin Gateway": + - text: Origin Gateway + - option "Origin Large" + - group "Acme Gateway": + - text: Acme Gateway + - option "Acme Large" [selected] + - option "Acme Small" diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 5ea56f3c7f..0b344dbeac 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -18,8 +18,8 @@ en: 01adce72791579fa zh: 70eb55a1529038e7 /deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations: - en: 986777b40354f8e5 - zh: 405c5581db8d4c56 + en: 57679a20574d8b3f + zh: 98d5e5a7781e6de1 /deepseek-ai-dsh-client-ui-commands/use-this-package/built-in-row-faces: en: e96156be95ea87e3 zh: f5350db26483ec9f @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: 01fb7e3ac511fecd - zh: b8b29c21c85f7a9c + en: 9fffb036b0346871 + zh: 1e0364e9b4f5a24b /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 43693b4623..0cba9477f4 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -33,6 +33,10 @@ A contribution is a client-owned command; a host-name collision fails loudly. It A popupSelect spec may supply `searchLabels()` to resolve its placeholder, empty-catalog text, and no-match text for each opening. Omitted labels retain the shell's generic copy; labels from one popup never carry into the next. +Options may carry `group: { name, label }`: `name` identifies the group and `label` supplies its localized sticky heading. Equal names share one group; groups retain first-occurrence order in the loaded options, including when a query hides earlier rows. Ungrouped options form one headingless block at their first occurrence. Groups without matching rows are hidden. + +The optional `searchMode` defaults to `'substring'`, matching `label` and `detail` case-insensitively without ranking. `'fuzzy-label'` matches only labels as ordered, case-insensitive subsequences and ranks within each group: prefixes first, then alignment score, then original row order. Both modes ignore leading and trailing query spaces; a blank query keeps every row in group order. Rendering, keyboard navigation, and selection use the same filtered order. `/model` opts into `'fuzzy-label'`; other commands retain substring matching unless they opt in. + ### Built-in row faces First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands. @@ -46,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl ## Understand the implementation -Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). +Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; the view cleans up and rebuilds the observer when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
Implementation internals — click to expand diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 204778a10e..bfa03d9093 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -33,6 +33,10 @@ kind: "package-reference" popupSelect 规格可提供 `searchLabels()`,在每次打开时解析占位文字、空目录提示和无匹配提示。未提供时保留外壳的通用文案;一个弹窗的文案不会沿用到下一个弹窗。 +选项可携带 `group: { name, label }`:`name` 标识分组,`label` 提供本地化吸顶标题。同名选项归入同一组;分组保持在已加载选项中首次出现的顺序,即使查询隐藏了较早的行也不变。未分组选项在首次出现的位置组成一个无标题区块。没有匹配行的分组会隐藏。 + +可选的 `searchMode` 默认为 `'substring'`,不区分大小写地匹配 `label` 与 `detail` 的子串,不进行相关性排序。`'fuzzy-label'` 仅匹配标签中按顺序、不区分大小写的子序列,并在组内排序:前缀优先,其次按匹配得分,最后按原始行顺序。两种模式均忽略查询首尾空格;空查询按分组顺序保留所有行。渲染、键盘导航与选择使用相同的筛选顺序。`/model` 启用 `'fuzzy-label'`;其他命令未显式启用时仍使用子串匹配。 + ### 内置行的展示面 内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。 @@ -46,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen ## 理解实现 -弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。 +弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;渲染分组变化时,视图清理并重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
实现细节——点击展开 diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index efa55b554c..355b913502 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -10,13 +10,15 @@ * null; the overlay slot stays mounted. The card height clamps to the space * above the composer. */ -import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' -import { useEffect, useRef } from 'react' +import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' +import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' +import type { SelectOption } from './contract.ts' +import { groupOptions } from './option-groups.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -44,6 +46,15 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { ) const cardRef = useRef(null) const searchRef = useRef(null) + const viewportRef = useRef(null) + const rows = useMemo(() => filterOptions(state.options, state.search, state.searchMode), + [state.options, state.search, state.searchMode]) + const groups = useMemo(() => groupOptions(rows), [rows]) + useLayoutEffect(() => { + const viewport = viewportRef.current + if (viewport === null) return + return observeStickyMenuGroups(viewport) + }, [state.open, state.status, state.confirming, groups]) // The card is bottom-anchored above the composer; clamp the design cap to // the space above it, re-measured on every store update. const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state) @@ -54,7 +65,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { useEffect(() => { if (active === null) return cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) - }, [active]) + }, [active, rows, state.confirming]) // Focus ownership: the search input grabs on open, and ANY outside // pointer interaction dismisses — @@ -78,7 +89,6 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { if (!state.open) return null - const rows = filterOptions(state.options, state.search) const confirmation = state.confirming?.confirmation const emptyLabel = state.searchLabels === null ? t('status.empty') @@ -121,6 +131,25 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { } } + const renderOption = (option: SelectOption, index: number) => ( +
{ void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + + {option.label} + {option.badge !== undefined && {option.badge}} + + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
+ ) + let optionIndex = 0 return ( <> {state.confirming === null && ( @@ -153,28 +182,12 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { {state.submitting &&
{t('status.applying')}
} {state.status === 'ready' && rows.length === 0 &&
{emptyLabel}
} {state.status === 'ready' && ( -
- {rows.map((option, index) => ( -
{ void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - - {option.label} - {option.badge !== undefined && {option.badge}} - - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
- ))} +
+ {groups.map(({ group, rows: groupRows }) => group === undefined + ? {groupRows.map(option => renderOption(option, optionIndex++))} + : + {groupRows.map(option => renderOption(option, optionIndex++))} + )}
)} diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 6b30e265ab..5cca3ee174 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -17,6 +17,15 @@ export interface SelectConfirmation { readonly confirmLabel: string } +/** Caller-owned group name and localized heading for popup options. */ +export interface SelectOptionGroup { + readonly name: string + readonly label: string +} + +/** Search behavior selected by the popup owner. */ +export type PopupSearchMode = 'substring' | 'fuzzy-label' + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string @@ -24,6 +33,8 @@ export interface SelectOption { /** Optional short marker rendered as a superscript beside the label. */ readonly badge?: string readonly detail?: string + /** Equal group names share a sticky heading; groups retain first-occurrence order. */ + readonly group?: SelectOptionGroup /** * The row the shell's highlight parks on when the panel opens, so an accept * gesture made without looking confirms the value in use. A business package @@ -52,6 +63,8 @@ export interface PopupSearchLabels { */ export interface PopupSelectSpec { readonly kind: 'popupSelect' + /** Defaults to substring search; fuzzy-label ranks names within each group. */ + readonly searchMode?: PopupSearchMode /** * Supply localized search copy for this opening; omitted uses the shell's generic copy. * @returns search placeholder and empty-state labels. diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index 9a93e23cd0..a629bd3240 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -28,7 +28,7 @@ export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './pop export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSearchLabels, PopupSelectSpec, - SelectConfirmation, SelectOption, + SelectConfirmation, SelectOption, SelectOptionGroup, PopupSearchMode, } from './contract.ts' export type { CommandKey } from './locales.ts' diff --git a/packages/client/ui-commands/src/client/option-groups.ts b/packages/client/ui-commands/src/client/option-groups.ts new file mode 100644 index 0000000000..ac2ff883e6 --- /dev/null +++ b/packages/client/ui-commands/src/client/option-groups.ts @@ -0,0 +1,24 @@ +/** Stable grouping shared by popup filtering and rendering. */ +import type { SelectOption, SelectOptionGroup } from './contract.ts' + +interface OptionGroup { + readonly group: SelectOptionGroup | undefined + readonly rows: readonly SelectOption[] +} + +/** + * Group rows by their caller-owned group name, without sorting groups or rows. + * Ungrouped rows occupy one block at their first occurrence. + * @param options - Options in display order. + * @returns groups in first-occurrence order, with original option objects. + */ +export function groupOptions(options: readonly SelectOption[]): OptionGroup[] { + const groups = new Map() + for (const option of options) { + const name = option.group?.name + const existing = groups.get(name) + if (existing !== undefined) existing.rows.push(option) + else groups.set(name, { group: option.group, rows: [option] }) + } + return [...groups.values()] +} diff --git a/packages/client/ui-commands/src/client/popup.ts b/packages/client/ui-commands/src/client/popup.ts index a6b7bb4c36..1446cc49d6 100644 --- a/packages/client/ui-commands/src/client/popup.ts +++ b/packages/client/ui-commands/src/client/popup.ts @@ -12,7 +12,9 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { PopupSearchLabels, PopupSelectSpec, SelectOption } from './contract.ts' +import type { PopupSearchLabels, PopupSearchMode, PopupSelectSpec, SelectOption } from './contract.ts' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' +import { groupOptions } from './option-groups.ts' /** * The command token segment snapshotted at shell-open time, replayed to the @@ -31,7 +33,7 @@ export type TokenSegment = * session wiring passes its session projection; the controller only carries * it from open() to the callbacks). */ -export interface PopupSpec extends Pick { +export interface PopupSpec extends Pick { /** Load the option rows once per open (retry after failure reuses the same signal). */ options(context: TCtx, signal: AbortSignal): Promise /** Settle the picked option against the open-time context. */ @@ -65,6 +67,8 @@ export interface PopupState { readonly search: string /** Command-owned copy for this opening; null uses generic shell labels. */ readonly searchLabels: PopupSearchLabels | null + /** Search policy resolved from the command spec at opening. */ + readonly searchMode: PopupSearchMode /** * Highlight index into the filtered row list: 0 until options land; afterwards * the row the loaded list marks as the current value @@ -83,21 +87,31 @@ export interface PopupState { } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', searchLabels: null, active: 0, + open: false, command: null, status: 'pending', options: [], search: '', searchLabels: null, searchMode: 'substring', active: 0, submitting: false, confirming: null, acknowledged: false, error: null, } /** - * Filter option rows against the shell's local search text (case-insensitive - * substring over label and detail; blank search keeps every row). + * Filter rows using substring matching, or rank labels fuzzily within each group. + * Blank search keeps every row; fuzzy matching preserves group order. * @param options - the loaded rows. * @param search - the shell's search text. - * @returns the rows the shell shows and highlights over. + * @param mode - the command's policy; defaults to substring over label and detail. + * @returns the original option objects in the order shown and used for selection. */ -export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] { +export function filterOptions( + options: readonly SelectOption[], search: string, mode: PopupSearchMode = 'substring', +): readonly SelectOption[] { const query = search.trim().toLowerCase() - if (query === '') return options - return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false)) + const groups = groupOptions(options) + const ordered = groups.some(group => group.group !== undefined) ? groups.flatMap(group => group.rows) : options + if (query === '') return ordered + if (mode === 'fuzzy-label') { + return groups.flatMap(group => rankByName( + group.rows.map(option => ({ name: option.label, option })), query, + ).map(row => row.option)) + } + return ordered.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false)) } /** @@ -107,10 +121,11 @@ export function filterOptions(options: readonly SelectOption[], search: string): * made without looking confirms that value instead of the topmost row. * @param options - the loaded rows. * @param search - the shell's live filter text (non-empty after a retry). + * @param mode - the command's search policy. * @returns index into the filtered rows. */ -function currentIndex(options: readonly SelectOption[], search: string): number { - const at = filterOptions(options, search).findIndex(option => option.active === true) +function currentIndex(options: readonly SelectOption[], search: string, mode: PopupSearchMode): number { + const at = filterOptions(options, search, mode).findIndex(option => option.active === true) return at === -1 ? 0 : at } @@ -158,7 +173,7 @@ export class PopupSelectController { this.binding?.abort.abort() const binding: OpenBinding = { command, spec, context, segment, abort: new AbortController() } this.binding = binding - this.state.set({ ...CLOSED, open: true, command, searchLabels }) + this.state.set({ ...CLOSED, open: true, command, searchLabels, searchMode: spec.searchMode ?? 'substring' }) this.load(binding) } @@ -168,7 +183,7 @@ export class PopupSelectController { (options) => { if (this.binding !== binding) return const current = this.state.getSnapshot() - this.state.set({ ...current, status: 'ready', options, active: currentIndex(options, current.search), error: null }) + this.state.set({ ...current, status: 'ready', options, active: currentIndex(options, current.search, current.searchMode), error: null }) }, (error: unknown) => { if (this.binding !== binding) return @@ -207,7 +222,7 @@ export class PopupSelectController { move(dir: 1 | -1): void { const s = this.state.getSnapshot() if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return - const rows = filterOptions(s.options, s.search) + const rows = filterOptions(s.options, s.search, s.searchMode) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length this.state.set({ ...s, active }) @@ -221,7 +236,7 @@ export class PopupSelectController { highlight(index: number): void { const s = this.state.getSnapshot() if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return - if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return + if (index < 0 || index >= filterOptions(s.options, s.search, s.searchMode).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -239,7 +254,7 @@ export class PopupSelectController { const binding = this.binding const s = this.state.getSnapshot() if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return - const option = filterOptions(s.options, s.search)[index] + const option = filterOptions(s.options, s.search, s.searchMode)[index] if (option === undefined) return if (option.confirmation !== undefined) { this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) diff --git a/packages/client/ui-commands/tests/popup-view.client.spec.tsx b/packages/client/ui-commands/tests/popup-view.client.spec.tsx index cf6fcdc044..3003a868d9 100644 --- a/packages/client/ui-commands/tests/popup-view.client.spec.tsx +++ b/packages/client/ui-commands/tests/popup-view.client.spec.tsx @@ -78,6 +78,30 @@ function rowLabels(): string[] { } describe('PopupSelectView', () => { + it('renders provider groups and selects the same fuzzy-ranked row the user sees', async () => { + const alpha = { name: 'alpha', label: 'Alpha provider' } + const beta = { name: 'beta', label: 'Beta provider' } + const options: SelectOption[] = [ + { id: 'a-deep', label: 'DeepSeek Flash', group: alpha }, + { id: 'b-flash', label: 'Flash Beta', group: beta }, + { id: 'a-prefix', label: 'Flash Lite', group: alpha, active: true }, + { id: 'other', label: 'Unmatched', group: { name: 'other', label: 'Other provider' } }, + ] + const onSelect = vi.fn() + const { search } = await mountOpen({ searchMode: 'fuzzy-label', options: () => Promise.resolve(options), onSelect }) + expect(screen.getAllByRole('group').map(group => group.querySelector('[data-menu-group-heading]')?.textContent)) + .toEqual(['Alpha provider', 'Beta provider', 'Other provider']) + expect(rowLabels()).toEqual(['DeepSeek Flash', 'Flash Lite', 'Flash Beta', 'Unmatched']) + fireEvent.change(search, { target: { value: 'flash' } }) + expect(rowLabels()).toEqual(['Flash Lite', 'DeepSeek Flash', 'Flash Beta']) + expect(screen.queryByRole('group', { name: 'Other provider' })).toBeNull() + expect(document.activeElement).toBe(search) + fireEvent.keyDown(search, { key: 'ArrowUp' }) + expect(screen.getByRole('option', { name: 'Flash Beta' }).getAttribute('aria-selected')).toBe('true') + await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }); await Promise.resolve() }) + expect(onSelect).toHaveBeenCalledWith(options[1], 'ctx-A') + }) + it('uses command-owned search copy and resets to generic copy for the next popup', async () => { const labels = { placeholder: '搜索模型…', empty: '没有可用的模型。', noResults: '没有匹配的模型。' } const searchLabels = vi.fn(() => labels) diff --git a/packages/client/ui-commands/tests/popup.client.spec.ts b/packages/client/ui-commands/tests/popup.client.spec.ts index 90aa599764..5561496639 100644 --- a/packages/client/ui-commands/tests/popup.client.spec.ts +++ b/packages/client/ui-commands/tests/popup.client.spec.ts @@ -48,6 +48,36 @@ async function readyPopup(overrides: Partial> = {}, deps = makeDe } describe('filterOptions', () => { + const alpha = { name: 'alpha', label: 'Alpha provider' } + const beta = { name: 'beta', label: 'Beta provider' } + const grouped: SelectOption[] = [ + { id: 'a-deep', label: 'DeepSeek Flash', group: alpha }, + { id: 'b-flash', label: 'Flash Beta', group: beta }, + { id: 'a-prefix', label: 'Flash Lite', group: alpha, active: true }, + ] + + it('keeps provider order while ranking fuzzy label matches within each group', () => { + expect(filterOptions(grouped, '')).toEqual([grouped[0], grouped[2], grouped[1]]) + expect(filterOptions(grouped, ' FLASH ', 'fuzzy-label')).toEqual([grouped[2], grouped[0], grouped[1]]) + expect(filterOptions(grouped, 'fslt', 'fuzzy-label')).toEqual([grouped[2]]) + expect(filterOptions(grouped, 'Alpha provider', 'fuzzy-label')).toEqual([]) + }) + + it('uses that same displayed order for highlighting and selection, then resets the mode on reopen', async () => { + const onSelect = vi.fn() + const { popup } = await readyPopup({ searchMode: 'fuzzy-label', options: () => Promise.resolve(grouped), onSelect }) + expect(popup.state.getSnapshot().active).toBe(1) + popup.setSearch('flash') + expect(popup.state.getSnapshot().active).toBe(0) + popup.move(-1) + expect(popup.state.getSnapshot().active).toBe(2) + await popup.select(2) + expect(onSelect).toHaveBeenCalledWith(grouped[1], CTX_A) + popup.open('theme', spec(), CTX_A, SEGMENT) + await Promise.resolve() + expect(popup.state.getSnapshot().searchMode).toBe('substring') + }) + it('matches case-insensitively over label and detail; blank keeps all', () => { expect(filterOptions(OPTIONS, '')).toBe(OPTIONS) expect(filterOptions(OPTIONS, ' ')).toBe(OPTIONS) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 988e9e48ff..804d66694f 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 62235f8c8c9e1e7e zh: c18dc9c38ef9d4f9 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 1abd83031efe3051 - zh: ac85bd0ff44a9505 + en: 5e2ec95807548cb4 + zh: b47754c9cdf2d068 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 @@ -27,8 +27,8 @@ en: 261bbf42296243af zh: 72779fb7b8fbc473 /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation: - en: 493088e8de6be91c - zh: 9ecee4605b4dbad4 + en: f3d38594439eab3d + zh: f255974e61696dca /deepseek-ai-dsh-client-ui-model-selection/further-exploration: en: 14acd4692e923480 zh: ff89aed852aa0da8 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index d795762ba1..77080c3a4d 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -37,11 +37,11 @@ Mouse selection uses native browser clicks, including their cancellation behavio ### Model and effort -The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. When shown, the button menu's search field matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query. +The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query. Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field. -Models stay grouped by provider. Provider headings stay transparent until scrolling pins them at the top of the list; only then do they use the theme's 94%-opaque group-header fill. Scrolling back restores transparency. This applies in both palettes and on every platform, while the menu retains its translucent material. Outside macOS Desktop, provider headings share the option rows' corner radius. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows model and provider names without catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared sticky-heading presentation from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. @@ -63,6 +63,8 @@ Menus use the shared `MenuSurface` material, including the macOS backing for bac
Implementation internals — click to expand +The composer `ModelSelect` and `/model` option builder share [provider ordering](src/client/provider-order.ts), while both searches use `rankByName` within each provider. The command supplies optional groups and `searchMode: 'fuzzy-label'` through the [popupSelect API](../ui-commands/README.md#use-this-package); both entries use `MenuGroup` and rebuild its sticky observer when the rendered groups change. The command popup fills the composer overlay, while the button retains its compact menu. + Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one. The directory publishes the latest submitted selection as `pending` until it settles or a connection reset invalidates it; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index c8c371430c..9894348717 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -37,11 +37,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。按钮菜单显示搜索框时,按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。 +按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。 composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 -模型按提供方分组。提供方标题默认透明,只有滚动使其固定在列表顶部时才使用主题提供的 94% 不透明分组标题填充;滚回原位后恢复透明。这一行为适用于浅/深色主题和所有平台,菜单仍保留半透明材质。除 macOS 桌面端外,提供方标题与选项行使用相同圆角。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示模型与提供方名称,不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 @@ -63,6 +63,8 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
实现细节——点击展开 +composer 的 `ModelSelect` 与 `/model` 选项构建器共用[提供方排序](src/client/provider-order.ts),两个搜索框均在每个提供方内使用 `rankByName`。命令通过 [popupSelect API](../ui-commands/README.zh.md#use-this-package) 提供可选分组与 `searchMode: 'fuzzy-label'`;两个入口均使用 `MenuGroup`,并在渲染分组变化时重建其吸顶观察器。命令弹窗撑满 composer 浮层,按钮则保留紧凑菜单。 + 两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果。目录把最近一次提交的选择发布为 `pending`,直到它完成或被连接重置作废;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 74ec5730f5..f4f42cb9cc 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -218,30 +218,6 @@ overflow-y: auto; } -.group + .group { - margin-top: 3px; -} - -.groupTitle { - position: sticky; - top: 0; - z-index: 1; - padding: 4px 7px 2px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; - font-weight: 500; -} - -.groupTitle[data-stuck] { - background: var(--dsw-alias-menu-group-header-fill); -} - -:global(html:not([data-platform='darwin'])) .groupTitle { - border-radius: var(--dsw-radius-md); -} - .option { box-sizing: border-box; display: flex; diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 2b6ba60c15..f3524df753 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -25,7 +25,7 @@ * chevron, and each row whose value that selection carries shows one in place * of its check mark. */ -import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' +import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type FocusEvent, @@ -40,6 +40,7 @@ import { import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' +import { orderModelProviders } from './provider-order.ts' /** Which pane the dropdown shows: the two-row root or one drilled-in list. */ type Pane = 'root' | 'model' | 'effort' @@ -89,9 +90,7 @@ export function ModelSelect( const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() - const groups = useMemo(() => state.groups.toSorted((left, right) => - (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) - - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups]) + const groups = useMemo(() => orderModelProviders(state.groups), [state.groups]) const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, @@ -195,30 +194,7 @@ export function ModelSelect( useLayoutEffect(() => { const viewport = groupsRef.current if (viewport === null) return - const entries = [...viewport.querySelectorAll(':scope > section')].map(section => ({ - section, heading: section.firstElementChild as HTMLElement, - })) - // Section boxes retain their normal positions while their headings stick. - const update = (): void => { - const top = viewport.getBoundingClientRect().top - const stuck = entries.map(({ section }) => { - const bounds = section.getBoundingClientRect() - return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top - }) - entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) - } - update() - viewport.addEventListener('scroll', update, { passive: true }) - window.addEventListener('resize', update) - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) - observer?.observe(viewport) - for (const { section } of entries) observer?.observe(section) - return () => { - viewport.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - observer?.disconnect() - for (const { heading } of entries) heading.removeAttribute('data-stuck') - } + return observeStickyMenuGroups(viewport) }, [available, open, pane, filteredGroups]) useLayoutEffect(() => { @@ -581,10 +557,8 @@ export function ModelSelect( hidden={filteredGroups.length === 0} > {filteredGroups.map((group) => { - const headingId = `${id}-${group.id}` return ( -
-
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
+ {group.models.map((model) => { const index = modelIndex++ const selected = state.current?.provider === group.id && state.current.model === model.id @@ -621,7 +595,7 @@ export function ModelSelect( ) })} -
+ ) })}
diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 1ae9d089ad..ca30921493 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -28,6 +28,7 @@ import { ModelDirectoryResolver } from './service.ts' import type { ModelSelectInjected } from './slots.ts' import { ModelSelect } from './ModelSelect.tsx' import { en, zh, type ModelKey } from './locales.ts' +import { orderModelProviders } from './provider-order.ts' export { ModelDirectory } from './directory.ts' export type { ModelDirectoryState } from './directory.ts' @@ -50,13 +51,13 @@ function rowId(providerId: string, modelId: string): string { /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] - for (const group of directory.groups) { + for (const group of orderModelProviders(directory.groups)) { const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: name, + group: { name: group.id, label: name }, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -133,6 +134,7 @@ export function apply(ctx: ClientContext): void { available: session => sessions.subagentAddress(session.sessionId) === undefined, ui: { kind: 'popupSelect', + searchMode: 'fuzzy-label', searchLabels: () => ({ placeholder: t('search.placeholder'), empty: t('empty.models'), diff --git a/packages/client/ui-model-selection/src/client/provider-order.ts b/packages/client/ui-model-selection/src/client/provider-order.ts new file mode 100644 index 0000000000..e17262bc00 --- /dev/null +++ b/packages/client/ui-model-selection/src/client/provider-order.ts @@ -0,0 +1,12 @@ +/** Shared provider display order for the composer and command model pickers. */ + +/** + * Put the account and official providers first, preserving every other relative order. + * @param groups - Provider groups in catalog order. + * @returns a sorted copy; model order within each group is unchanged. + */ +export function orderModelProviders(groups: readonly T[]): T[] { + return groups.toSorted((left, right) => + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)) +} diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 8b7a21f7ed..b27163fb16 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -232,7 +232,9 @@ describe('ui-model-selection dual entry', () => { expect(options.map((o: SelectOption) => o.label)).toEqual([ 'DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'External Flash', ]) - expect(options.map(option => option.detail)).toEqual(['DeepSeek', 'DeepSeek', 'External Provider']) + expect(options.map(option => option.group?.label)).toEqual(['DeepSeek', 'DeepSeek', 'External Provider']) + expect(options.every(option => option.detail === undefined)).toBe(true) + expect(b.popup().searchMode).toBe('fuzzy-label') expect(options[0]?.active).toBe(true) expect(options[1]?.active).toBeUndefined() expect(b.popup().searchLabels?.()).toEqual(locale === 'zh' @@ -240,6 +242,23 @@ describe('ui-model-selection dual entry', () => { : { placeholder: 'Search models…', empty: 'No models available.', noResults: 'No matching models.' }) }) + it('orders popup provider groups account-first while retaining third-party catalog order', async () => { + const b = await bench() + try { + b.setGroups([ + GROUPS[1]!, GROUPS[0]!, { ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }, + { ...GROUPS[1]!, id: 'last-provider', name: 'Last Provider' }, + ]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect([...new Set(options.map(option => option.group?.name))]) + .toEqual(['deepseek-account', 'deepseek-official', 'external', 'last-provider']) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('a seat selection is the current the popup marks active next — one shared state', async () => { const b = await bench() b.mint('s1') @@ -279,14 +298,14 @@ describe('ui-model-selection dual entry', () => { }) }) - it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + it.each(['en', 'zh'] as const)('localizes account provider headings in the %s model popup', async (locale) => { const b = await bench(locale) try { b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) b.remote.emit('llm/adapters-updated', []) b.mint('s1') const options = await b.popup().options(projection('s1'), new AbortController().signal) - expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + expect(options[0]?.group?.label).toBe(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') } finally { await b.ctx.fiber.dispose() } @@ -299,14 +318,14 @@ describe('ui-model-selection dual entry', () => { b.remote.emit('llm/adapters-updated', []) b.mint('s1') const before = await b.popup().options(projection('s1'), new AbortController().signal) - expect(before.some(option => option.detail?.includes('DeepSeek Account'))).toBe(true) + expect(before.some(option => option.group?.label === 'DeepSeek Account')).toBe(true) b.setGroups(GROUPS) b.remote.emit('credentials/record-updated', ['deepseek-account-platform']) await vi.waitFor(() => { expect(b.ctx.modelDirectories.directoryFor(sid('s1')).store.getSnapshot().groups).toEqual(GROUPS) }) const after = await b.popup().options(projection('s1'), new AbortController().signal) - expect(after.some(option => option.detail?.includes('DeepSeek Account'))).toBe(false) + expect(after.some(option => option.group?.label === 'DeepSeek Account')).toBe(false) expect(after.length).toBeGreaterThan(0) } finally { await b.ctx.fiber.dispose() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6b157578fb..517377a20c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: c4d22d4642ec6fe0 - zh: 27c4dcae204d1f8f + en: 91ab0b7b84d060be + zh: 6d6e2882f58e71f6 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d @@ -30,8 +30,8 @@ en: 4aef65dea11de083 zh: abc22cd7c65cdf38 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 464527753be6a4f6 - zh: d9df7442503bd58f + en: 6d228f9141c804aa + zh: 04bdfc8f3673fcd0 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: 44eb32370cbeafe8 zh: 728009c66375c6bb diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f69307078a..1e3116dc13 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,6 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and scroll/resize observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | @@ -118,6 +119,10 @@ The atoms cannot read the application locale, so every piece of user-facing copy `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. +`MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. + +Call `observeStickyMenuGroups(viewport)` after rendering `MenuGroup` sections as direct children of the scroll container. It updates synchronously, then on scrolling, window resize, and viewport or section resizing when `ResizeObserver` is available. A heading is stuck only while its section straddles the viewport top and `scrollTop` is positive. Group membership is captured at setup: call the returned cleanup and observe again whenever rendered groups change, including filtering, and clean up on unmount. Cleanup removes listeners, disconnects size observation, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no listeners or observer. +
Implementation internals — click to expand diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index cfc7653184..9dc080c933 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,6 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与滚动/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | @@ -118,6 +119,10 @@ kind: "package-library" `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 +`MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 + +将 `MenuGroup` 区段渲染为滚动容器的直接子节点后,调用 `observeStickyMenuGroups(viewport)`。它同步更新状态,随后响应滚动、窗口尺寸变化,以及可用 `ResizeObserver` 观察到的滚动区或区段尺寸变化。只有区段跨过滚动区顶部且 `scrollTop` 为正时,标题才处于吸顶状态。分组成员在初始化时确定:渲染分组发生变化时(包括筛选),必须调用返回的清理函数并重新观察,卸载时也必须清理。清理会移除监听器、断开尺寸观察并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建监听器或观察器。 +
实现细节——点击展开 diff --git a/packages/client/ui-primitives/src/MenuGroup.module.css b/packages/client/ui-primitives/src/MenuGroup.module.css new file mode 100644 index 0000000000..3065fdc27c --- /dev/null +++ b/packages/client/ui-primitives/src/MenuGroup.module.css @@ -0,0 +1,23 @@ +.group + .group { + margin-top: 3px; +} + +.heading { + position: sticky; + top: 0; + z-index: 1; + padding: 4px 7px 2px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + font-weight: 500; +} + +.heading[data-stuck] { + background: var(--dsw-alias-menu-group-header-fill); +} + +:global(html:not([data-platform='darwin'])) .heading { + border-radius: var(--dsw-radius-md); +} diff --git a/packages/client/ui-primitives/src/MenuGroup.tsx b/packages/client/ui-primitives/src/MenuGroup.tsx new file mode 100644 index 0000000000..ce77a4eab6 --- /dev/null +++ b/packages/client/ui-primitives/src/MenuGroup.tsx @@ -0,0 +1,56 @@ +/** Accessible menu groups with shared sticky-heading presentation and viewport observation. */ +import { useId, type ReactNode } from 'react' +import css from './MenuGroup.module.css' + +/** + * Render a named group with an instance-owned heading id. + * @param props - Caller-localized label and optional menu rows. + * @returns A section named by its direct heading, followed by the supplied children. + */ +export function MenuGroup({ label, children }: { label: string; children?: ReactNode }) { + const headingId = useId() + return ( +
+
{label}
+ {children} +
+ ) +} + +/** + * Update direct menu-group headings synchronously, then on scroll and resize. + * A heading is stuck only while its section straddles the viewport top and scrollTop is positive. + * Group membership is captured at setup; dispose and observe again when the rendered groups change. + * @param viewport - Scroll container whose direct groups contain direct data-menu-group-heading children. + * @returns Cleanup that removes listeners, disconnects size observation, and clears every managed + * heading's data-stuck attribute. A viewport without groups acquires no listeners or observer. + */ +export function observeStickyMenuGroups(viewport: HTMLElement): () => void { + const entries = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { + const heading = section.querySelector(':scope > [data-menu-group-heading]') + return heading === null ? [] : [{ section, heading }] + }) + if (entries.length === 0) return () => {} + + const update = (): void => { + const top = viewport.getBoundingClientRect().top + // Section boxes retain their normal positions while their headings stick. + const stuck = entries.map(({ section }) => { + const bounds = section.getBoundingClientRect() + return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top + }) + entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) + } + update() + viewport.addEventListener('scroll', update, { passive: true }) + window.addEventListener('resize', update) + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) + observer?.observe(viewport) + for (const { section } of entries) observer?.observe(section) + return () => { + viewport.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + observer?.disconnect() + for (const { heading } of entries) heading.removeAttribute('data-stuck') + } +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 57511f4dab..c6127472b7 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -25,6 +25,7 @@ export { Input } from './Input.tsx' export { Menu, MenuItemButton } from './Menu.tsx' export { MenuSurface } from './MenuSurface.tsx' export type { MenuSurfaceProps } from './MenuSurface.tsx' +export { MenuGroup, observeStickyMenuGroups } from './MenuGroup.tsx' export type { MenuItemButtonProps, MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx' export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts' export { useAnchoredPosition } from './useAnchoredPosition.ts' diff --git a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx new file mode 100644 index 0000000000..c444fb01ac --- /dev/null +++ b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +/** Menu-group naming, sticky transitions, and viewport-observer ownership. */ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MenuGroup, observeStickyMenuGroups } from '../src/MenuGroup.tsx' + +const disposers: (() => void)[] = [] + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', undefined) +}) + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function observe(viewport: HTMLElement): () => void { + const dispose = observeStickyMenuGroups(viewport) + disposers.push(dispose) + return dispose +} + +function fixture() { + render( +
+ + +
, + ) + const viewport = screen.getByTestId('viewport') + const first = screen.getByRole('group', { name: 'Provider A' }) + const second = screen.getByRole('group', { name: 'Provider B' }) + const firstHeading = within(first).getByText('Provider A') + const secondHeading = within(second).getByText('Provider B') + const viewportRect = vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) + const firstRect = vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 100)) + const secondRect = vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 203, 200, 100)) + return { viewport, first, second, firstHeading, secondHeading, viewportRect, firstRect, secondRect } +} + +describe('MenuGroup', () => { + it('names each section with a unique direct heading and preserves child order', () => { + const { first, second, firstHeading, secondHeading } = fixture() + expect(first.tagName).toBe('SECTION') + expect(first.hasAttribute('data-menu-group')).toBe(true) + expect(firstHeading.hasAttribute('data-menu-group-heading')).toBe(true) + expect(first.getAttribute('aria-labelledby')).toBe(firstHeading.id) + expect(second.getAttribute('aria-labelledby')).toBe(secondHeading.id) + expect(firstHeading.id).not.toBe('') + expect(firstHeading.id).not.toBe(secondHeading.id) + expect(first.firstElementChild).toBe(firstHeading) + expect(firstHeading.nextElementSibling).toBe(within(first).getByRole('button', { name: 'Model A' })) + }) + + it('renders a named group without rows', () => { + render() + const group = screen.getByRole('group', { name: 'Empty provider' }) + expect(group.children).toHaveLength(1) + expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id) + }) +}) + +describe('observeStickyMenuGroups', () => { + it('handles normal headings, sticking, handoff, and scrolling back without ResizeObserver', () => { + const { viewport, firstHeading, secondHeading, firstRect, secondRect } = fixture() + observe(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 183, 200, 100)) + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 100 + firstRect.mockReturnValue(new DOMRect(0, 0, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 103, 200, 100)) + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 103 + secondRect.mockReturnValue(new DOMRect(0, 100, 200, 100)) + fireEvent.scroll(viewport) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 104 + firstRect.mockReturnValue(new DOMRect(0, -4, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 99, 200, 100)) + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(true) + + viewport.scrollTop = 0 + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + }) + + it('synchronously updates an already-scrolled viewport and reads all boxes before writing attributes', () => { + const { viewport, firstHeading, secondHeading, viewportRect, firstRect, secondRect } = fixture() + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + const firstWrite = vi.spyOn(firstHeading, 'toggleAttribute') + const secondWrite = vi.spyOn(secondHeading, 'toggleAttribute') + observe(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + const reads = [...viewportRect.mock.invocationCallOrder, ...firstRect.mock.invocationCallOrder, ...secondRect.mock.invocationCallOrder] + const writes = [...firstWrite.mock.invocationCallOrder, ...secondWrite.mock.invocationCallOrder] + expect(Math.max(...reads)).toBeLessThan(Math.min(...writes)) + }) + + it('updates on window resize and removes scroll and resize listeners on cleanup', () => { + const { viewport, firstHeading, firstRect, viewportRect } = fixture() + const addScroll = vi.spyOn(viewport, 'addEventListener') + const removeScroll = vi.spyOn(viewport, 'removeEventListener') + const removeResize = vi.spyOn(window, 'removeEventListener') + const dispose = observe(viewport) + expect(addScroll).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) + + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + fireEvent.resize(window) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + viewportRect.mockReturnValue(new DOMRect(0, 70, 200, 200)) + fireEvent.resize(window) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + viewportRect.mockReturnValue(new DOMRect(0, 100, 200, 200)) + fireEvent.resize(window) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + + dispose() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) + expect(removeResize).toHaveBeenCalledWith('resize', expect.any(Function)) + firstRect.mockClear() + fireEvent.scroll(viewport) + fireEvent.resize(window) + expect(firstRect).not.toHaveBeenCalled() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + }) + + it('observes the viewport and every section when ResizeObserver is available, then disconnects', () => { + const observeSize = vi.fn() + const disconnect = vi.fn() + let notify: () => void = () => { throw new Error('ResizeObserver has not been constructed') } + class StubResizeObserver implements ResizeObserver { + observe = observeSize + unobserve = vi.fn() + disconnect = disconnect + constructor(callback: ResizeObserverCallback) { + notify = () => { callback([], this) } + } + } + vi.stubGlobal('ResizeObserver', StubResizeObserver) + const { viewport, first, second, firstHeading, secondHeading, firstRect, secondRect } = fixture() + const dispose = observe(viewport) + expect(observeSize.mock.calls).toEqual([[viewport], [first], [second]]) + + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + notify() + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + firstRect.mockReturnValue(new DOMRect(0, -20, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 83, 200, 100)) + notify() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(true) + + dispose() + expect(disconnect).toHaveBeenCalledOnce() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + }) + + it('ignores nested groups, unmarked sections, and headings that are not direct children', () => { + render( +
+ + + +
Indirect
+
Unmarked
+
, + ) + const viewport = screen.getByTestId('viewport') + const direct = screen.getByRole('group', { name: 'Direct' }) + const nested = screen.getByRole('group', { name: 'Nested' }) + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) + vi.spyOn(direct, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 80, 200, 100)) + const nestedRect = vi.spyOn(nested, 'getBoundingClientRect') + viewport.scrollTop = 20 + observe(viewport) + expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) + expect(screen.getByText('Nested').hasAttribute('data-stuck')).toBe(false) + expect(screen.getByText('Indirect').hasAttribute('data-stuck')).toBe(false) + expect(screen.getByText('Unmarked').hasAttribute('data-stuck')).toBe(false) + expect(nestedRect).not.toHaveBeenCalled() + }) + + it('acquires no listeners or ResizeObserver for a viewport without direct groups', () => { + render(
) + const viewport = screen.getByTestId('viewport') + const addScroll = vi.spyOn(viewport, 'addEventListener') + const addResize = vi.spyOn(window, 'addEventListener') + const ResizeObserverStub = vi.fn() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + const dispose = observe(viewport) + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() + expect(ResizeObserverStub).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) +}) From 3ec8cd925d59b9fc75f69faa7777fb030bf61198 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 28 Sep 2026 18:12:48 +0800 Subject: [PATCH 11/12] perf(web): observe model menu stickiness without layout reads --- apps/web/tests/model-menu-observer.e2e.ts | 289 +++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 +- packages/client/ui-commands/README.zh.md | 2 +- .../src/client/PopupSelectView.tsx | 4 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 2 +- .../tests/model-select.client.spec.tsx | 40 -- .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/MenuGroup.module.css | 14 + .../client/ui-primitives/src/MenuGroup.tsx | 105 ++-- .../tests/menu-group.client.spec.tsx | 481 ++++++++++++------ tsconfig.host.json | 1 + 18 files changed, 729 insertions(+), 244 deletions(-) create mode 100644 apps/web/tests/model-menu-observer.e2e.ts diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts new file mode 100644 index 0000000000..5da1f9e2f2 --- /dev/null +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -0,0 +1,289 @@ +/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */ +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium, webkit } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +declare global { + interface Window { + /** Test-private count for MenuGroup sections and their direct scroll viewport. */ + __modelMenuRectReads: number + } +} + +const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url)) +const PROVIDERS = [ + { id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' }, + { id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' }, + { id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' }, + { id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' }, +].map((provider, providerIndex) => ({ + ...provider, + models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0 + ? { id: 'origin-large', name: 'Origin Large' } + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), +})) +const EXPECTED_GROUPS = [ + { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, + ...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })), +] + +// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps. +async function frames(page: Page): Promise { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) +} + +function readGroups(viewport: Locator) { + return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent), + }))) +} + +function readPinned(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + headings.map(heading => heading.hasAttribute('data-stuck'))) +} + +async function assertNoRectReads(page: Page): Promise { + await frames(page) + expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0) +} + +async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise { + const expected = Array.from({ length: count }, (_, i) => i === index) + await expect.poll(() => readPinned(viewport)).toEqual(expected) + await assertNoRectReads(page) + expect(await readPinned(viewport)).toEqual(expected) +} + +async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise { + // Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation. + await viewport.evaluate((node, target) => new Promise((resolve) => { + requestAnimationFrame(() => { + const groups = node.querySelectorAll(':scope > [data-menu-group]') + node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside + resolve() + }) + }), { index, inside }) +} + +async function headingMaterial(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => { + const body = heading.ownerDocument.body + const theme = body.getAttribute('data-ds-dark-theme') + try { + return [false, true].map((dark) => { + body.toggleAttribute('data-ds-dark-theme', dark) + const style = getComputedStyle(heading) + return { + fill: style.backgroundColor, radius: style.borderRadius, position: style.position, + font: style.fontSize, weight: style.fontWeight, padding: style.padding, + } + }) + } finally { + if (theme === null) body.removeAttribute('data-ds-dark-theme') + else body.setAttribute('data-ds-dark-theme', theme) + } + }) +} + +describe.skipIf(webSnapshotMode() === 'record').each([ + { name: 'Chromium', engine: chromium }, + { name: 'WebKit', engine: webkit }, +])('web e2e: model menu observers ($name)', ({ engine }) => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let crashed = false + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // Catalog-only settings traffic: no prompt, selection, replay fixture, or model request. + await scaffold.ctx.settings.update('llm-pi-ai', { + providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, { + displayName: provider.name, + api: 'openai-completions', + baseURL: `https://${provider.id}.example/v1`, + models: provider.models, + }])), + }) + browser = await engine.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.addInitScript(() => { + window.__modelMenuRectReads = 0 + // oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below. + const original = Element.prototype.getBoundingClientRect + Element.prototype.getBoundingClientRect = function (): DOMRect { + if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) { + window.__modelMenuRectReads++ + } + return original.call(this) + } + }) + page.on('crash', () => { crashed = true; console.error('observer test page crashed') }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + it('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { + onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`)) + let buttonMaterial: Awaited> | undefined + for (const entry of ['button', 'command'] as const) { + const composer = page.locator('[data-composer-input]').first() + const surface = entry === 'button' + ? page.getByRole('group', { name: '模型与推理等级', exact: true }) + : page.locator('[aria-label="/model 选项"]') + const viewport = entry === 'button' + ? surface.getByRole('menu', { name: '模型', exact: true }) + : surface.getByRole('listbox') + const search = entry === 'button' + ? page.getByRole('searchbox', { name: '搜索模型…', exact: true }) + : page.getByRole('textbox', { name: '筛选选项', exact: true }) + const open = async (): Promise => { + if (entry === 'button') { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + } else { + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + } + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + await page.mouse.move(0, 0) + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + await assertPinned(page, viewport, -1) + } + const close = async (): Promise => { + if (crashed || page.isClosed() || await surface.count() === 0) return + await search.press('Escape') + if (entry === 'button') await page.keyboard.press('Escape') + await surface.waitFor({ state: 'detached' }) + await composer.fill('') + } + + await open() + try { + expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))])) + .toEqual(['rgba(0, 0, 0, 0)']) + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + const material = await headingMaterial(viewport) + expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)']) + for (const style of material) { + expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' }) + } + if (entry === 'button') buttonMaterial = material + else expect(material).toEqual(buttonMaterial) + + for (const index of [2, 4, 1]) { + await scrollToGroup(viewport, index) + await assertPinned(page, viewport, index) + const height = await viewport.evaluate(node => node.clientHeight) + expect(height).toBeGreaterThan(0) + expect(height).toBeLessThanOrEqual(80) + expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight)) + .toBeGreaterThan(height * 2) + await scrollToGroup(viewport, index, height + 1) + await assertPinned(page, viewport, index) + expect(await viewport.evaluate((node, groupIndex) => { + const headings = node.querySelectorAll('[data-menu-group-heading]') + const firstStart = node.querySelector('[data-menu-group-start]')! + const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop + return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop) + }, index)).toBeLessThan(1) + } + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + + for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) { + const visited = new Set() + let observedPinnedHighlight = false + for (let step = 0; step < steps; step++) { + const rows = viewport.locator('[role="option"], [role="menuitemradio"]') + const before = await rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true')) + await search.press(key) + const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30 + await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected) + await frames(page) + await expect.poll(() => viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const base = groups[0]!.offsetTop + return groups.every((group) => { + const top = group.offsetTop - base + const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop + return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick + }) + })).toBe(true) + const highlight = await viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null) + const group = groups[index]! + return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop, + stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') } + }) + visited.add(highlight.index) + if (highlight.above) { + expect(highlight.stuck).toBe(true) + observedPinnedHighlight = true + } + expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + } + expect(visited.size).toBeGreaterThan(1) + expect(observedPinnedHighlight).toBe(true) + } + + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + await search.fill('Quartz Model 1') + await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }]) + await assertPinned(page, viewport, -1, 1) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + await search.fill('') + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await assertPinned(page, viewport, -1) + await close() + await open() + expect(await search.inputValue()).toBe('') + + await scrollToGroup(viewport, 3, 90) + await assertPinned(page, viewport, 3) + await viewport.evaluate((node) => { node.style.maxHeight = '64px' }) + await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64) + await assertPinned(page, viewport, 3) + await scrollToGroup(viewport, 4, 100) + await assertPinned(page, viewport, 4) + await scrollToGroup(viewport, 1, 100) + await assertPinned(page, viewport, 1) + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + } finally { + await close() + } + } + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f5f0300f04..e213a2ab14 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -87,6 +87,7 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/model-menu-observer.e2e.ts", "tests/default-workspace.e2e.ts", "tests/new-session-refused.e2e.ts", "tests/github-ready-review.e2e.ts", diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 0b344dbeac..ba51e078fb 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: 9fffb036b0346871 - zh: 1e0364e9b4f5a24b + en: 3aeeba6c91c3ef19 + zh: d3afb3df6d15e17b /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 0cba9477f4..eb354816d2 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -50,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl ## Understand the implementation -Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; the view cleans up and rebuilds the observer when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). +Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; an ordinary effect owns the asynchronous observation and rebuilds it when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
Implementation internals — click to expand diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index bfa03d9093..de78e36758 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -50,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen ## 理解实现 -弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;渲染分组变化时,视图清理并重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。 +弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;普通 effect 负责异步观察,并在渲染分组变化时清理和重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
实现细节——点击展开 diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 355b913502..4b0b4bf418 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -11,7 +11,7 @@ * above the composer. */ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' -import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { Fragment, useEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' @@ -50,7 +50,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { const rows = useMemo(() => filterOptions(state.options, state.search, state.searchMode), [state.options, state.search, state.searchMode]) const groups = useMemo(() => groupOptions(rows), [rows]) - useLayoutEffect(() => { + useEffect(() => { const viewport = viewportRef.current if (viewport === null) return return observeStickyMenuGroups(viewport) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 804d66694f..9c22fdfbdf 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 62235f8c8c9e1e7e zh: c18dc9c38ef9d4f9 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 5e2ec95807548cb4 - zh: b47754c9cdf2d068 + en: 48f8157ffdfe6e69 + zh: 64ec9a533e2aa8f4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 77080c3a4d..09b016ef3a 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -41,7 +41,7 @@ The button's model menu shows search only when its full catalog contains more th Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field. -Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared sticky-heading presentation from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared, asynchronously observed sticky headings from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 9894348717..12df7a98ed 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -41,7 +41,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 -两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 异步观察的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index f3524df753..fbeda5d31e 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -191,7 +191,7 @@ export function ModelSelect( ;(cell !== null && cell !== undefined && !cell.disabled ? cell : triggerRef.current)?.focus() }, [open, pane, showSearch]) - useLayoutEffect(() => { + useEffect(() => { const viewport = groupsRef.current if (viewport === null) return return observeStickyMenuGroups(viewport) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 0341daa436..470c852ec8 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -852,46 +852,6 @@ describe('ModelSelect search', () => { }) }) -it('paints only a pinned provider heading and clears its state when the pane closes', () => { - const directory = createSnapshotStore(state({ groups: [ - ...state().groups, - { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, - ] })) - render() - const trigger = screen.getByRole('button', { name: /选择模型/ }) - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - const viewport = screen.getByRole('menu', { name: '模型' }) - const sections = [...viewport.querySelectorAll('section')] - const headings = sections.map(section => section.querySelector(':scope > div')!) - let firstHeight = 90 - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 240, 80)) - vi.spyOn(sections[0]!, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 100 - viewport.scrollTop, 240, firstHeight)) - vi.spyOn(sections[1]!, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 103 + firstHeight - viewport.scrollTop, 240, 56)) - const pinned = (): boolean[] => headings.map(heading => heading.hasAttribute('data-stuck')) - fireEvent.scroll(viewport, { target: { scrollTop: 0 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 24 } }) - expect(pinned()).toEqual([true, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 93 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 94 } }) - expect(pinned()).toEqual([false, true]) - firstHeight = 130 - fireEvent.resize(window) - expect(pinned()).toEqual([true, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 0 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 24 } }) - const removeScroll = vi.spyOn(viewport, 'removeEventListener') - fireEvent.click(trigger) - expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) - expect(pinned()).toEqual([false, false]) - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - expect(screen.getByRole('menu', { name: '模型' }).querySelector('[data-stuck]')).toBeNull() -}) - it('shows the unselected model control with the inherited effort', async () => { const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 517377a20c..cff51aed21 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 91ab0b7b84d060be - zh: 6d6e2882f58e71f6 + en: 86ebc31d3af22478 + zh: 6f9f75d43e67049e /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d @@ -30,8 +30,8 @@ en: 4aef65dea11de083 zh: abc22cd7c65cdf38 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 6d228f9141c804aa - zh: 04bdfc8f3673fcd0 + en: 0b4082dbb778476f + zh: b6daaaf9de675b57 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: 44eb32370cbeafe8 zh: 728009c66375c6bb diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1e3116dc13..2ccc2b61e8 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | -| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and scroll/resize observation; the caller owns the observer's lifetime. | +| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | @@ -121,7 +121,9 @@ The atoms cannot read the application locale, so every piece of user-facing copy `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. -Call `observeStickyMenuGroups(viewport)` after rendering `MenuGroup` sections as direct children of the scroll container. It updates synchronously, then on scrolling, window resize, and viewport or section resizing when `ResizeObserver` is available. A heading is stuck only while its section straddles the viewport top and `scrollTop` is positive. Group membership is captured at setup: call the returned cleanup and observe again whenever rendered groups change, including filtering, and clean up on unmount. Cleanup removes listeners, disconnects size observation, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no listeners or observer. +Call `observeStickyMenuGroups(viewport)` from an ordinary effect after rendering `MenuGroup` sections as direct children of an unpadded, borderless scroll container. Native intersection and viewport-size observations update the background asynchronously, without synchronous layout reads or scroll listeners. Headings remain transparent until observations identify a group crossing the viewport top; CSS owns their sticky positioning. + +Group membership is captured at setup. Dispose before observing changed groups, including after filtering, and clean up on unmount. Cleanup disconnects the observers, ignores queued callbacks, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no observers. Without `IntersectionObserver` or `ResizeObserver`, headings keep CSS sticky positioning but remain transparent.
Implementation internals — click to expand diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9dc080c933..588fb26041 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | -| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与滚动/尺寸观察;调用方负责观察器的生命周期。 | +| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | @@ -121,7 +121,9 @@ kind: "package-library" `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 -将 `MenuGroup` 区段渲染为滚动容器的直接子节点后,调用 `observeStickyMenuGroups(viewport)`。它同步更新状态,随后响应滚动、窗口尺寸变化,以及可用 `ResizeObserver` 观察到的滚动区或区段尺寸变化。只有区段跨过滚动区顶部且 `scrollTop` 为正时,标题才处于吸顶状态。分组成员在初始化时确定:渲染分组发生变化时(包括筛选),必须调用返回的清理函数并重新观察,卸载时也必须清理。清理会移除监听器、断开尺寸观察并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建监听器或观察器。 +将 `MenuGroup` 区段渲染为无内边距、无边框的滚动容器的直接子节点后,在普通 effect 中调用 `observeStickyMenuGroups(viewport)`。原生交叉观察与滚动区尺寸观察异步更新背景,不同步读取布局,也不注册滚动监听器。标题保持透明,直到观察结果确认分组跨过滚动区顶部;吸顶定位由 CSS 负责。 + +分组成员在初始化时确定。分组变化后(包括筛选)重新观察前必须清理,卸载时也必须清理。清理会断开观察器、忽略排队回调并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建观察器。缺少 `IntersectionObserver` 或 `ResizeObserver` 时,标题仍通过 CSS 吸顶,但保持透明。
实现细节——点击展开 diff --git a/packages/client/ui-primitives/src/MenuGroup.module.css b/packages/client/ui-primitives/src/MenuGroup.module.css index 3065fdc27c..f4b054e9bd 100644 --- a/packages/client/ui-primitives/src/MenuGroup.module.css +++ b/packages/client/ui-primitives/src/MenuGroup.module.css @@ -1,3 +1,17 @@ +.group { + position: relative; +} + +.start { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + .group + .group { margin-top: 3px; } diff --git a/packages/client/ui-primitives/src/MenuGroup.tsx b/packages/client/ui-primitives/src/MenuGroup.tsx index ce77a4eab6..bd26bc0249 100644 --- a/packages/client/ui-primitives/src/MenuGroup.tsx +++ b/packages/client/ui-primitives/src/MenuGroup.tsx @@ -3,7 +3,7 @@ import { useId, type ReactNode } from 'react' import css from './MenuGroup.module.css' /** - * Render a named group with an instance-owned heading id. + * Render a named group with an instance-owned heading id and an inaccessible position sentinel. * @param props - Caller-localized label and optional menu rows. * @returns A section named by its direct heading, followed by the supplied children. */ @@ -11,6 +11,7 @@ export function MenuGroup({ label, children }: { label: string; children?: React const headingId = useId() return (
+
@@ -18,39 +19,85 @@ export function MenuGroup({ label, children }: { label: string; children?: React } /** - * Update direct menu-group headings synchronously, then on scroll and resize. - * A heading is stuck only while its section straddles the viewport top and scrollTop is positive. - * Group membership is captured at setup; dispose and observe again when the rendered groups change. - * @param viewport - Scroll container whose direct groups contain direct data-menu-group-heading children. - * @returns Cleanup that removes listeners, disconnects size observation, and clears every managed - * heading's data-stuck attribute. A viewport without groups acquires no listeners or observer. + * Update heading backgrounds asynchronously from native intersection and viewport-size observations. + * Headings remain transparent until observations identify a section crossing the viewport top. + * Without IntersectionObserver or ResizeObserver, CSS sticky headings remain transparent. + * Group membership is captured at setup; dispose before observing changed groups or the same viewport again. + * @param viewport - Unpadded, borderless scroll container with direct MenuGroup children. + * @returns Cleanup owning both intersection observers and the viewport resize observer; disconnects + * them, ignores queued callbacks, and clears managed data-stuck attributes. No valid groups acquires nothing. */ export function observeStickyMenuGroups(viewport: HTMLElement): () => void { - const entries = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { + const groups = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { const heading = section.querySelector(':scope > [data-menu-group-heading]') - return heading === null ? [] : [{ section, heading }] + const start = section.querySelector(':scope > [data-menu-group-start]') + return heading === null || start === null ? [] : [{ + section, heading, start, atTop: false, above: false, topTime: -Infinity, aboveTime: -Infinity, + }] }) - if (entries.length === 0) return () => {} - - const update = (): void => { - const top = viewport.getBoundingClientRect().top - // Section boxes retain their normal positions while their headings stick. - const stuck = entries.map(({ section }) => { - const bounds = section.getBoundingClientRect() - return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top - }) - entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) + if (groups.length === 0 || typeof IntersectionObserver === 'undefined' || typeof ResizeObserver === 'undefined') { + return () => {} } - update() - viewport.addEventListener('scroll', update, { passive: true }) - window.addEventListener('resize', update) - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) - observer?.observe(viewport) - for (const { section } of entries) observer?.observe(section) + + const sections = new Map(groups.map(group => [group.section, group])) + const starts = new Map(groups.map(group => [group.start, group])) + let disposed = false + let stripObserver: IntersectionObserver | undefined + let viewportHeight: number | undefined + const render = (group: typeof groups[number]): void => { + const stuck = group.atTop && group.above + if (group.heading.hasAttribute('data-stuck') !== stuck) group.heading.toggleAttribute('data-stuck', stuck) + } + const startObserver = new IntersectionObserver((entries) => { + if (disposed) return + for (const entry of entries) { + const group = starts.get(entry.target) + if (!group || entry.rootBounds === null || entry.time < group.aboveTime) continue + group.above = entry.boundingClientRect.top < entry.rootBounds.top + group.aboveTime = entry.time + render(group) + } + }, { root: viewport, threshold: [0, 1] }) + for (const { start } of groups) startObserver.observe(start) + + const sizeObserver = new ResizeObserver((entries) => { + if (disposed) return + for (const entry of entries) { + if (entry.target !== viewport || entry.contentRect.height === viewportHeight) continue + viewportHeight = entry.contentRect.height + stripObserver?.disconnect() + // A full section crossing this strip is observable even when scrolling skips its start sentinel. + const observer = new IntersectionObserver((intersections) => { + if (disposed || stripObserver !== observer) return + for (const intersection of intersections) { + const group = sections.get(intersection.target) + if (!group || intersection.rootBounds === null) continue + const top = intersection.rootBounds.top + if (intersection.time >= group.topTime) { + group.atTop = intersection.isIntersecting && intersection.boundingClientRect.bottom > top + group.topTime = intersection.time + } + // Both observers describe the section's normal-flow start; the newest sample wins. + if (intersection.time >= group.aboveTime) { + group.above = intersection.boundingClientRect.top < top + group.aboveTime = intersection.time + } + render(group) + } + }, { root: viewport, rootMargin: `0px 0px ${Math.min(1, viewportHeight) - viewportHeight}px 0px`, threshold: 0 }) + stripObserver = observer + for (const { section } of groups) observer.observe(section) + } + }) + sizeObserver.observe(viewport) return () => { - viewport.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - observer?.disconnect() - for (const { heading } of entries) heading.removeAttribute('data-stuck') + if (disposed) return + disposed = true + startObserver.disconnect() + stripObserver?.disconnect() + sizeObserver.disconnect() + for (const { heading } of groups) { + if (heading.hasAttribute('data-stuck')) heading.removeAttribute('data-stuck') + } } } diff --git a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx index c444fb01ac..52ff44099c 100644 --- a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx @@ -1,13 +1,50 @@ // @vitest-environment jsdom /** Menu-group naming, sticky transitions, and viewport-observer ownership. */ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, isInaccessible, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MenuGroup, observeStickyMenuGroups } from '../src/MenuGroup.tsx' const disposers: (() => void)[] = [] +const intersections: StubIntersectionObserver[] = [] +const sizes: StubResizeObserver[] = [] + +class StubIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '0px' + readonly thresholds: readonly number[] + observe = vi.fn<(target: Element) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => []) + constructor(private readonly callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) { + this.root = options.root ?? null + this.rootMargin = options.rootMargin ?? '0px' + this.thresholds = typeof options.threshold === 'number' ? [options.threshold] : options.threshold ?? [0] + intersections.push(this) + } + notify(...entries: IntersectionObserverEntry[]): void { this.callback(entries, this) } +} + +class StubResizeObserver implements ResizeObserver { + observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + constructor(private readonly callback: ResizeObserverCallback) { sizes.push(this) } + notify(target: Element, height = 200): void { + const size: ResizeObserverSize = { blockSize: height, inlineSize: 200 } + this.callback([{ + target, contentRect: new DOMRect(0, 0, 200, height), + borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], + }], this) + } +} beforeEach(() => { - vi.stubGlobal('ResizeObserver', undefined) + intersections.length = 0 + sizes.length = 0 + vi.stubGlobal('IntersectionObserver', StubIntersectionObserver) + vi.stubGlobal('ResizeObserver', StubResizeObserver) }) afterEach(() => { @@ -23,197 +60,329 @@ function observe(viewport: HTMLElement): () => void { return dispose } +function directChild(section: HTMLElement, attribute: string): HTMLElement { + const child = section.querySelector(`:scope > [${attribute}]`) + if (!child) throw new Error(`Missing ${attribute}`) + return child +} + function fixture() { - render( + const view = render(
+
, ) const viewport = screen.getByTestId('viewport') - const first = screen.getByRole('group', { name: 'Provider A' }) - const second = screen.getByRole('group', { name: 'Provider B' }) - const firstHeading = within(first).getByText('Provider A') - const secondHeading = within(second).getByText('Provider B') - const viewportRect = vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) - const firstRect = vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 100)) - const secondRect = vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 203, 200, 100)) - return { viewport, first, second, firstHeading, secondHeading, viewportRect, firstRect, secondRect } + const groups = screen.getAllByRole('group').map(section => ({ + section, heading: directChild(section, 'data-menu-group-heading'), start: directChild(section, 'data-menu-group-start'), + })) + return { ...view, viewport, groups } +} + +function entry( + target: Element, top: number, height: number, time: number, rootHeight = 1, + rootBounds: DOMRectReadOnly | null = new DOMRect(0, 100, 200, rootHeight), +): IntersectionObserverEntry { + const bottom = top + height + const intersectionTop = Math.max(top, 100) + const intersectionBottom = Math.min(bottom, 100 + rootHeight) + const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop) + return { + target, time, boundingClientRect: new DOMRect(0, top, 200, height), + rootBounds, + intersectionRect: new DOMRect(0, intersectionTop, 200, intersectionHeight), + intersectionRatio: intersectionHeight / height, isIntersecting: bottom >= 100 && top <= 100 + rootHeight, + } +} + +function startEntry(target: Element, top: number, time: number): IntersectionObserverEntry { + return entry(target, top, 1, time, 200) +} + +function startObserving(viewport: HTMLElement) { + const dispose = observe(viewport) + const start = intersections[0]! + const size = sizes[0]! + size.notify(viewport) + const strip = intersections[1]! + return { dispose, start, size, strip } } describe('MenuGroup', () => { - it('names each section with a unique direct heading and preserves child order', () => { - const { first, second, firstHeading, secondHeading } = fixture() - expect(first.tagName).toBe('SECTION') - expect(first.hasAttribute('data-menu-group')).toBe(true) - expect(firstHeading.hasAttribute('data-menu-group-heading')).toBe(true) - expect(first.getAttribute('aria-labelledby')).toBe(firstHeading.id) - expect(second.getAttribute('aria-labelledby')).toBe(secondHeading.id) - expect(firstHeading.id).not.toBe('') - expect(firstHeading.id).not.toBe(secondHeading.id) - expect(first.firstElementChild).toBe(firstHeading) - expect(firstHeading.nextElementSibling).toBe(within(first).getByRole('button', { name: 'Model A' })) + it('names each section with a unique direct heading and keeps the sentinel out of the accessible tree', () => { + const { groups: [first, second] } = fixture() + expect(first!.section.tagName).toBe('SECTION') + expect(first!.section.hasAttribute('data-menu-group')).toBe(true) + expect(first!.section.getAttribute('aria-labelledby')).toBe(first!.heading.id) + expect(second!.section.getAttribute('aria-labelledby')).toBe(second!.heading.id) + expect(first!.heading.id).not.toBe('') + expect(first!.heading.id).not.toBe(second!.heading.id) + expect(first!.start.getAttribute('aria-hidden')).toBe('true') + expect(isInaccessible(first!.start)).toBe(true) + expect(first!.start.textContent).toBe('') + expect(first!.start.hasAttribute('tabindex')).toBe(false) + expect(first!.heading.nextElementSibling).toBe(within(first!.section).getByRole('button', { name: 'Model A' })) }) it('renders a named group without rows', () => { render() const group = screen.getByRole('group', { name: 'Empty provider' }) - expect(group.children).toHaveLength(1) + expect(group.children).toHaveLength(2) expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id) }) }) describe('observeStickyMenuGroups', () => { - it('handles normal headings, sticking, handoff, and scrolling back without ResizeObserver', () => { - const { viewport, firstHeading, secondHeading, firstRect, secondRect } = fixture() - observe(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 183, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 100 - firstRect.mockReturnValue(new DOMRect(0, 0, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 103, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 103 - secondRect.mockReturnValue(new DOMRect(0, 100, 200, 100)) - fireEvent.scroll(viewport) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 104 - firstRect.mockReturnValue(new DOMRect(0, -4, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 99, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(true) - - viewport.scrollTop = 0 - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - }) - - it('synchronously updates an already-scrolled viewport and reads all boxes before writing attributes', () => { - const { viewport, firstHeading, secondHeading, viewportRect, firstRect, secondRect } = fixture() - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - const firstWrite = vi.spyOn(firstHeading, 'toggleAttribute') - const secondWrite = vi.spyOn(secondHeading, 'toggleAttribute') - observe(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - const reads = [...viewportRect.mock.invocationCallOrder, ...firstRect.mock.invocationCallOrder, ...secondRect.mock.invocationCallOrder] - const writes = [...firstWrite.mock.invocationCallOrder, ...secondWrite.mock.invocationCallOrder] - expect(Math.max(...reads)).toBeLessThan(Math.min(...writes)) - }) - - it('updates on window resize and removes scroll and resize listeners on cleanup', () => { - const { viewport, firstHeading, firstRect, viewportRect } = fixture() + it('does not synchronously read layout when observing a 30-model menu', () => { + render(
{Array.from({ length: 6 }, (_, index) => + + {Array.from({ length: 5 }, (_, row) => )} + )}
) + const viewport = screen.getByTestId('large-viewport') + const groups = screen.getAllByRole('group') + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const clientRects = vi.spyOn(Element.prototype, 'getClientRects') + const computedStyle = vi.spyOn(window, 'getComputedStyle') + const offsetTop = vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get') + const offsetHeight = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get') const addScroll = vi.spyOn(viewport, 'addEventListener') - const removeScroll = vi.spyOn(viewport, 'removeEventListener') - const removeResize = vi.spyOn(window, 'removeEventListener') + const addResize = vi.spyOn(window, 'addEventListener') const dispose = observe(viewport) - expect(addScroll).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - viewportRect.mockReturnValue(new DOMRect(0, 70, 200, 200)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - viewportRect.mockReturnValue(new DOMRect(0, 100, 200, 200)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - - dispose() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) - expect(removeResize).toHaveBeenCalledWith('resize', expect.any(Function)) - firstRect.mockClear() + expect(read).not.toHaveBeenCalled() + expect(intersections).toHaveLength(1) + expect(sizes).toHaveLength(1) + const start = intersections[0]! + const size = sizes[0]! + expect(start.observe).toHaveBeenCalledTimes(6) + expect(size.observe.mock.calls).toEqual([[viewport]]) + size.notify(viewport) + const strip = intersections[1]! + expect(strip.observe.mock.calls).toEqual(groups.map(group => [group])) + strip.notify(...groups.map((group, index) => entry(group, 100 + index * 203, 200, 1))) + start.notify(...groups.map((group, index) => startEntry(directChild(group, 'data-menu-group-start'), 100 + index * 203, 1))) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(false) + viewport.scrollTop = 0.25 fireEvent.scroll(viewport) + start.notify(startEntry(directChild(groups[0]!, 'data-menu-group-start'), 99.75, 2)) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(true) fireEvent.resize(window) - expect(firstRect).not.toHaveBeenCalled() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - }) - - it('observes the viewport and every section when ResizeObserver is available, then disconnects', () => { - const observeSize = vi.fn() - const disconnect = vi.fn() - let notify: () => void = () => { throw new Error('ResizeObserver has not been constructed') } - class StubResizeObserver implements ResizeObserver { - observe = observeSize - unobserve = vi.fn() - disconnect = disconnect - constructor(callback: ResizeObserverCallback) { - notify = () => { callback([], this) } - } - } - vi.stubGlobal('ResizeObserver', StubResizeObserver) - const { viewport, first, second, firstHeading, secondHeading, firstRect, secondRect } = fixture() - const dispose = observe(viewport) - expect(observeSize.mock.calls).toEqual([[viewport], [first], [second]]) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - notify() - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - firstRect.mockReturnValue(new DOMRect(0, -20, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 83, 200, 100)) - notify() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(true) - + size.notify(viewport, 180) + intersections[2]!.notify(entry(groups[0]!, 99.75, 200, 3)) dispose() - expect(disconnect).toHaveBeenCalledOnce() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + for (const spy of [read, clientRects, computedStyle, offsetTop, offsetHeight, clientHeight]) { + expect(spy).not.toHaveBeenCalled() + } + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() }) - it('ignores nested groups, unmarked sections, and headings that are not direct children', () => { - render( -
- - - -
Indirect
-
Unmarked
-
, - ) - const viewport = screen.getByTestId('viewport') - const direct = screen.getByRole('group', { name: 'Direct' }) - const nested = screen.getByRole('group', { name: 'Nested' }) - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) - vi.spyOn(direct, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 80, 200, 100)) - const nestedRect = vi.spyOn(nested, 'getBoundingClientRect') + it('waits for asynchronous size and intersection delivery, including an already-scrolled viewport', () => { + const { viewport, groups: [first] } = fixture() viewport.scrollTop = 20 observe(viewport) - expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) - expect(screen.getByText('Nested').hasAttribute('data-stuck')).toBe(false) - expect(screen.getByText('Indirect').hasAttribute('data-stuck')).toBe(false) - expect(screen.getByText('Unmarked').hasAttribute('data-stuck')).toBe(false) - expect(nestedRect).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(1) + intersections[0]!.notify(startEntry(first!.start, 80, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + sizes[0]!.notify(viewport) + intersections[1]!.notify(entry(first!.section, 80, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) }) - it('acquires no listeners or ResizeObserver for a viewport without direct groups', () => { - render(
) + it('keeps the initial top transparent, handles fractional crossing, and clears on upward return to zero', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + expect(start.root).toBe(viewport) + expect(start.thresholds).toEqual([0, 1]) + expect(strip.root).toBe(viewport) + expect(strip.rootMargin).toBe('0px 0px -199px 0px') + expect(strip.thresholds).toEqual([0]) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 100, 100, 1)) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).not.toHaveBeenCalled() + // The section still intersects the strip, so only the sentinel crosses a threshold. + start.notify(startEntry(first!.start, 99.75, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 99, 3)) + expect(write).toHaveBeenCalledTimes(1) + start.notify(startEntry(first!.start, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).toHaveBeenCalledTimes(2) + }) + + it('clears at the section tail and hands off only after the following group crosses the top', () => { + const { viewport, groups: [first, second] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1), entry(second!.section, 183, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 0, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, -3, 100, 3), entry(second!.section, 100, 100, 3)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + start.notify(startEntry(second!.start, 99.75, 4)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('finds the middle group after a fast jump even when its sentinel reports no new intersection', () => { + const { viewport, groups: [first, second, third] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 100, 1), startEntry(second!.start, 503, 1), startEntry(third!.start, 906, 1)) + strip.notify(entry(first!.section, 100, 400, 1), entry(second!.section, 503, 400, 1), entry(third!.section, 906, 400, 1)) + strip.notify(entry(first!.section, -450, 400, 2), entry(second!.section, -47, 400, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(third!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('orders samples from both observers by entry time rather than callback delivery', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 99.75, 3)) + strip.notify(entry(first!.section, 100, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + strip.notify(entry(first!.section, -1, 100, 5)) + strip.notify(entry(first!.section, 80, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 80, 100, 6)) + start.notify(startEntry(first!.start, 100, 5)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('rebuilds only the strip on viewport height changes and invalidates its old callback', () => { + const { viewport, groups: [first] } = fixture() + const { size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + size.notify(viewport) + size.notify(first!.section, 400) + expect(intersections).toHaveLength(2) + size.notify(viewport, 400) + expect(strip.disconnect).toHaveBeenCalledOnce() + const replacement = intersections[2]! + expect(replacement.rootMargin).toBe('0px 0px -399px 0px') + replacement.notify(entry(first!.section, 100, 100, 2)) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 80, 100, 100)) + expect(write).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + size.notify(viewport, 0.5) + expect(intersections[3]!.rootMargin).toBe('0px 0px 0px 0px') + size.notify(viewport, 0) + expect(intersections[4]!.rootMargin).toBe('0px 0px 0px 0px') + }) + + it('ignores unavailable root bounds and unrelated intersection targets', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + strip.notify(entry(first!.section, 100, 100, 2, 1, null), entry(viewport, 100, 100, 2)) + start.notify(entry(first!.start, 100, 1, 2, 200, null), startEntry(viewport, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('disconnects every observer, clears only stuck headings, and prevents all late writes', () => { + const { viewport, groups: [first] } = fixture() + const { dispose, start, size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + const remove = vi.spyOn(first!.heading, 'removeAttribute') + dispose() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(remove).toHaveBeenCalledOnce() + const write = vi.spyOn(first!.heading, 'toggleAttribute') + start.notify(startEntry(first!.start, 80, 2)) + strip.notify(entry(first!.section, 80, 100, 2)) + size.notify(viewport, 400) + dispose() + expect(intersections).toHaveLength(2) + expect(write).not.toHaveBeenCalled() + expect(remove).toHaveBeenCalledOnce() + for (const observer of [start, size, strip]) expect(observer.disconnect).toHaveBeenCalledOnce() + }) + + it('can dispose before the first size delivery without constructing a strip observer', () => { + const { viewport } = fixture() + const dispose = observe(viewport) + dispose() + sizes[0]!.notify(viewport) + expect(intersections).toHaveLength(1) + expect(sizes[0]!.disconnect).toHaveBeenCalledOnce() + }) + + it('recaptures filtered groups and rejects callbacks from the previous setup', () => { + const { viewport, rerender, groups: [first] } = fixture() + const previous = startObserving(viewport) + previous.strip.notify(entry(first!.section, 80, 100, 1)) + previous.dispose() + rerender(
) + const group = screen.getByRole('group', { name: 'Filtered' }) + const heading = directChild(group, 'data-menu-group-heading') + observe(viewport) + sizes[1]!.notify(viewport) + const current = intersections[3]! + expect(current.observe.mock.calls).toEqual([[group]]) + current.notify(entry(group, 80, 100, 2)) + expect(heading.hasAttribute('data-stuck')).toBe(true) + const write = vi.spyOn(heading, 'toggleAttribute') + previous.start.notify(startEntry(first!.start, 100, 3)) + previous.strip.notify(entry(first!.section, 100, 100, 3)) + previous.size.notify(viewport, 400) + expect(write).not.toHaveBeenCalled() + expect(heading.hasAttribute('data-stuck')).toBe(true) + expect(intersections).toHaveLength(4) + }) + + it('observes only direct groups with both direct headings and start sentinels', () => { + render(
+ +
Indirect
+
No start
+
Unmarked
+
) + const viewport = screen.getByTestId('viewport') + const group = screen.getByRole('group', { name: 'Direct' }) + const { start, strip } = startObserving(viewport) + expect(start.observe.mock.calls).toEqual([[directChild(group, 'data-menu-group-start')]]) + expect(strip.observe.mock.calls).toEqual([[group]]) + strip.notify(entry(group, 80, 100, 1)) + expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) + for (const label of ['Nested', 'Indirect', 'No start', 'Unmarked']) { + expect(screen.getByText(label).hasAttribute('data-stuck')).toBe(false) + } + }) + + it('acquires no observers or listeners without valid direct groups', () => { + render(
) const viewport = screen.getByTestId('viewport') const addScroll = vi.spyOn(viewport, 'addEventListener') const addResize = vi.spyOn(window, 'addEventListener') - const ResizeObserverStub = vi.fn() - vi.stubGlobal('ResizeObserver', ResizeObserverStub) const dispose = observe(viewport) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) expect(addScroll).not.toHaveBeenCalled() expect(addResize).not.toHaveBeenCalled() - expect(ResizeObserverStub).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) + + it.each(['IntersectionObserver', 'ResizeObserver'])('keeps CSS sticky headings transparent without %s', (api) => { + vi.stubGlobal(api, undefined) + const { viewport, groups: [first] } = fixture() + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const dispose = observe(viewport) + viewport.scrollTop = 20 + fireEvent.scroll(viewport) + fireEvent.resize(window) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) + expect(read).not.toHaveBeenCalled() expect(dispose).not.toThrow() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 3942b72b1b..5c5c81b7e0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -31,6 +31,7 @@ "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/model-menu-observer.e2e.ts", "apps/web/tests/github-ready-review.e2e.ts", "apps/web/tests/streaming-fence-highlight.e2e.ts", "apps/web/tests/declared-reasoning.e2e.ts", From 11925ce3453a4711784246c0f284bb3d4dbdad9d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:29:28 +0800 Subject: [PATCH 12/12] fix(web): stabilize e2e and settings placement wait for SVG changed-file card update performance settings placement skip crashing WebKit model-menu observer case --- apps/web/tests/expected/desktop-locale/automatic.expected.md | 4 ++-- apps/web/tests/expected/desktop-locale/selected.expected.md | 4 ++-- apps/web/tests/expected/settings-chrome/dialog-en.expected.md | 4 ++-- .../expected/settings-chrome/dialog-no-browser.expected.md | 4 ++-- apps/web/tests/expected/settings-chrome/dialog.expected.md | 4 ++-- apps/web/tests/model-menu-observer.e2e.ts | 3 ++- apps/web/tests/present-svg.e2e.ts | 2 ++ packages/client/ui-chat/README.i18n.yaml | 4 ++-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- packages/client/ui-chat/src/client/apply.ts | 2 +- 11 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 77b4593b90..9bc10efc51 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -27,8 +27,8 @@ - button "编辑快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 - - switch "在使用官方模型 API 时上传 Session Log" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" + - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 + - switch "在使用官方模型 API 时上传 Session Log" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index c4fd5e5eff..7d839e4a25 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index ad3348ea5a..0883c044fb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index ad3348ea5a..0883c044fb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 0f17fa643f..562592ab7d 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -27,8 +27,8 @@ - button "编辑快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 - - switch "在使用官方模型 API 时上传 Session Log" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" + - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 + - switch "在使用官方模型 API 时上传 Session Log" - text: 当前版本:{{version}} diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts index 5da1f9e2f2..4c58bc9db9 100644 --- a/apps/web/tests/model-menu-observer.e2e.ts +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -141,7 +141,8 @@ describe.skipIf(webSnapshotMode() === 'record').each([ } }) - it('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { + // FIXME: WebKit 26.5 crashes while filtering the grouped model rows in this scenario. + it.skipIf(engine === webkit)('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`)) let buttonMaterial: Awaited> | undefined for (const entry of ['button', 'command'] as const) { diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index cd44f0ec67..c275f73c7a 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -122,6 +122,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => { expect(await page.getByText('产物', { exact: true }).count()).toBe(0) // The scaffold workspace is not a git repository; the write call supplies its single changed file. const changes = page.locator('[data-changed-files]') + // Changed-file cards wait for a separate summary request after the presentation events arrive. + await changes.waitFor({ state: 'visible' }) expect(await changes.count()).toBe(1) expect(await changes.getByText(`已编辑 ${FILE}`, { exact: true }).count()).toBe(1) expect(await changes.getByRole('list').count()).toBe(0) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4d0cfda86a..264a09cf05 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2157fcd6b0d100b5 zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: e1dd055d68ab25ea - zh: c5b62a22c9e457cb + en: 11436f75c377ed1b + zh: 42707b8efc201dad /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index ddbd9c7c6e..58428e7773 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -58,7 +58,7 @@ Generic command rows retain the ordinary command glyph in every lifecycle state; A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. -Settings → General → Performance & usage appears immediately before Current version and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. +Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 490a44f6d8..28455ece27 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -58,7 +58,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 -设置 → 通用设置 → 性能与用量位于当前版本之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 +设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 9608c2b74a..e5807726ac 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -156,7 +156,7 @@ export function apply(ctx: Context): void { ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', id: 'performance-usage', - order: 95, + order: 30, locale: NS, inject: (): PerformanceUsageRowInjected => ({ hooks: { performanceUsage },