Merge pull request #4787 from deepseek-harness/xx/model-search
feat(web): add search to composer model picker & unify 2 model picker UI and interaction
This commit is contained in:
commit
5a9b56a8be
55 files changed
+2161
-243
No files matched your search
@@ -115,7 +115,8 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
|
||||
expect(slashPickerBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(slashPickerBox!.width).toBeGreaterThanOrEqual(220)
|
||||
expect(composerBox!.width - slashPickerBox!.width).toBeGreaterThan(1)
|
||||
expect(slashPickerBox!.width).toBeCloseTo(composerBox!.width)
|
||||
expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x)
|
||||
expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1)
|
||||
|
||||
await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click()
|
||||
|
||||
@@ -46,6 +46,7 @@ describe.skipIf(MODE === 'record').each([
|
||||
models: [
|
||||
{ id: 'acme-think', name: 'Acme Think' },
|
||||
{ id: 'acme-swift', name: 'Acme Swift' },
|
||||
{ id: 'acme-lite', name: 'Acme Lite' },
|
||||
].map(model => ({
|
||||
...model,
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
@@ -148,8 +149,10 @@ describe.skipIf(MODE === 'record').each([
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
} else {
|
||||
await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click()
|
||||
await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const focused = pane === 'model'
|
||||
? page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
: page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
await expect.poll(() => focused.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
@@ -172,22 +175,27 @@ describe.skipIf(MODE === 'record').each([
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true })
|
||||
const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true })
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
|
||||
// Native mousedown must not blur the focused row and unmount the menu before click in WebKit.
|
||||
// Native mousedown must not blur search and unmount the menu before click in WebKit.
|
||||
await target.getByText('Acme Swift', { exact: true }).hover()
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await expect.poll(() => menu.count()).toBe(1)
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.getByText('Acme Gateway', { exact: true }).hover()
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
// Later selection counts also include any request from this cancelled press.
|
||||
expect(selections).toBe(0)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
await search.press('ArrowDown')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
@@ -222,16 +230,20 @@ describe.skipIf(MODE === 'record').each([
|
||||
}, { times: 1 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await current.click()
|
||||
await page.getByRole('alert').waitFor()
|
||||
expect(selections).toBe(3)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED,
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), MODE)
|
||||
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
expect(await search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
|
||||
@@ -80,7 +80,13 @@ 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())
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
const effortWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row =>
|
||||
getComputedStyle(row.querySelector('span span')!).fontWeight,
|
||||
))
|
||||
expect(new Set(effortWeights)).toEqual(new Set(['400']))
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -73,7 +73,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
models: [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -92,6 +92,223 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps command popup search borders transparent in both palettes', async () => {
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
const commandMenuBounds = await page.locator('[data-trigger-menu]').boundingBox()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const search = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await search.waitFor()
|
||||
try {
|
||||
const popupBounds = await page.locator('[aria-label="/model 选项"]').boundingBox()
|
||||
expect(popupBounds).not.toBeNull()
|
||||
expect(commandMenuBounds).not.toBeNull()
|
||||
expect(popupBounds!.width).toBeCloseTo(commandMenuBounds!.width)
|
||||
expect(popupBounds!.x).toBeCloseTo(commandMenuBounds!.x)
|
||||
expect(await search.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/command-picker.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[aria-label="/model 选项"]', scaffold.workspaceCwd),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
await search.fill('no-model-matches')
|
||||
await page.getByText('没有匹配的模型。', { exact: true }).waitFor()
|
||||
await search.fill('')
|
||||
const borders = await search.evaluate((input) => {
|
||||
const body = input.ownerDocument.body
|
||||
const previousTheme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return [false, true].map((dark) => {
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
return getComputedStyle(input).borderColor
|
||||
})
|
||||
} finally {
|
||||
if (previousTheme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', previousTheme)
|
||||
}
|
||||
})
|
||||
expect(borders).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'])
|
||||
} finally {
|
||||
await search.press('Escape')
|
||||
await composer.fill('')
|
||||
}
|
||||
})
|
||||
|
||||
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: '搜索模型…' })
|
||||
const setModels = (expanded: boolean) => scaffold.ctx.settings.update('llm-pi-ai', {
|
||||
providers: { [ROUTE]: {
|
||||
displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1',
|
||||
models: expanded ? [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }]
|
||||
: [{ id: MODEL, name: 'Acme Large' }],
|
||||
} },
|
||||
})
|
||||
try {
|
||||
await setModels(false)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(4)
|
||||
expect(await search.count()).toBe(0)
|
||||
const current = page.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
|
||||
await expect.poll(() => current.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
const rows = page.getByRole('menuitemradio')
|
||||
const currentId = await current.getAttribute('id')
|
||||
for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown')
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe(currentId)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none')
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const commandSearch = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await commandSearch.waitFor()
|
||||
expect(await commandSearch.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await commandSearch.press('Escape')
|
||||
await page.locator('[data-composer-input]').first().fill('')
|
||||
expect(await rows.count()).toBe(0)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await setModels(true)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(5)
|
||||
await search.waitFor()
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
it('paints only pinned provider headings across themes, filtering, and reopening', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-sticky-headings'))
|
||||
const originalViewport = page.viewportSize()!
|
||||
const attributes = await page.evaluate(() => ({
|
||||
platform: document.documentElement.getAttribute('data-platform'),
|
||||
theme: document.body.getAttribute('data-ds-dark-theme'),
|
||||
}))
|
||||
const surface = page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
try {
|
||||
await page.setViewportSize({ width: 1680, height: 220 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
const scroller = page.getByRole('menu', { name: '模型', exact: true })
|
||||
const headings = surface.locator('section[role="group"] > div')
|
||||
const readPinned = () => headings.evaluateAll(nodes => nodes.map(node => node.hasAttribute('data-stuck')))
|
||||
const resetScroll = async (): Promise<void> => {
|
||||
await scroller.evaluate((node) => { node.scrollTop = 0 })
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
}
|
||||
for (const platform of ['web', 'win32', 'darwin']) {
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate(({ platform, dark }) => {
|
||||
if (platform === 'web') document.documentElement.removeAttribute('data-platform')
|
||||
else document.documentElement.setAttribute('data-platform', platform)
|
||||
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
}, { platform, dark })
|
||||
await resetScroll()
|
||||
expect(await headings.evaluateAll(nodes => [...new Set(nodes.map(node => getComputedStyle(node).backgroundColor))]))
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
const secondTop = await scroller.evaluate((node) => {
|
||||
const second = node.querySelectorAll('section')[1]!
|
||||
return Math.ceil(second.getBoundingClientRect().top - node.getBoundingClientRect().top) + 1
|
||||
})
|
||||
expect(await scroller.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThanOrEqual(secondTop)
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
expect(await headings.first().evaluate(node => getComputedStyle(node).backgroundColor))
|
||||
.toBe(dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)')
|
||||
await scroller.evaluate((node, top) => { node.scrollTop = top }, secondTop)
|
||||
await expect.poll(readPinned).toEqual([false, true, false])
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
await resetScroll()
|
||||
}
|
||||
}
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
await search.fill('Origin Large')
|
||||
await expect.poll(() => headings.count()).toBe(1)
|
||||
await expect.poll(readPinned).toEqual([false])
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
await search.fill('zzzz')
|
||||
await expect.poll(() => surface.locator('[data-stuck]').count()).toBe(0)
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
await resetScroll()
|
||||
} finally {
|
||||
if (await surface.count()) await search.press('Escape')
|
||||
if (await trigger.getAttribute('aria-expanded') === 'true') await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(originalViewport)
|
||||
await page.evaluate(({ platform, theme }) => {
|
||||
if (platform === null) document.documentElement.removeAttribute('data-platform')
|
||||
else document.documentElement.setAttribute('data-platform', platform)
|
||||
if (theme === null) document.body.removeAttribute('data-ds-dark-theme')
|
||||
else document.body.setAttribute('data-ds-dark-theme', theme)
|
||||
}, attributes)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
||||
// A session that has already run a turn, spelled as the fact a turn
|
||||
@@ -106,7 +323,109 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
const modelMenuBounds = await page.getByRole('menu', { name: '模型与推理等级', exact: true }).boundingBox()
|
||||
const composerBounds = await page.locator('[data-composer-card]').first().boundingBox()
|
||||
expect(modelMenuBounds!.width).toBeLessThan(composerBounds!.width)
|
||||
const modelCell = page.getByRole('menuitem', { name: /模型/ })
|
||||
await trigger.press('ArrowDown')
|
||||
expect(await modelCell.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await modelCell.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
|
||||
const focusedBackground = await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await trigger.focus()
|
||||
await modelCell.hover()
|
||||
expect(await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(focusedBackground)
|
||||
await modelCell.click()
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const rowIds = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row => row.id))
|
||||
const initialHighlight = rowIds.indexOf(await search.getAttribute('aria-activedescendant') ?? '')
|
||||
expect(initialHighlight).toBeGreaterThanOrEqual(0)
|
||||
for (let step = 1; step <= rowIds.length + 1; step++) {
|
||||
await search.press('ArrowDown')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[(initialHighlight + step) % rowIds.length])
|
||||
expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
}
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[initialHighlight])
|
||||
const searchStyle = await search.evaluate((input) => {
|
||||
const wrapper = input.parentElement!
|
||||
const caption = input.ownerDocument.createElement('span')
|
||||
caption.style.color = 'var(--dsw-alias-label-caption)'
|
||||
wrapper.append(caption)
|
||||
try {
|
||||
return {
|
||||
placeholderColor: getComputedStyle(input, '::placeholder').color,
|
||||
captionColor: getComputedStyle(caption).color,
|
||||
iconCount: wrapper.querySelectorAll('svg').length,
|
||||
background: getComputedStyle(wrapper).backgroundColor,
|
||||
radius: getComputedStyle(wrapper).borderRadius,
|
||||
padding: getComputedStyle(wrapper).padding,
|
||||
fontSize: getComputedStyle(input).fontSize,
|
||||
}
|
||||
} finally {
|
||||
caption.remove()
|
||||
}
|
||||
})
|
||||
expect(searchStyle.placeholderColor).toBe(searchStyle.captionColor)
|
||||
expect(searchStyle.iconCount).toBe(0)
|
||||
expect(searchStyle.background).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(searchStyle.radius).toBe('12px')
|
||||
expect(searchStyle.padding).toBe('5px 7px')
|
||||
expect(searchStyle.fontSize).toBe('12px')
|
||||
const modelWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row =>
|
||||
getComputedStyle(row.querySelector('span span')!).fontWeight,
|
||||
))
|
||||
expect(new Set(modelWeights)).toEqual(new Set(['400']))
|
||||
const headingPalettes = await page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((menu) => {
|
||||
const html = menu.ownerDocument.documentElement
|
||||
const body = menu.ownerDocument.body
|
||||
const previousPlatform = html.getAttribute('data-platform')
|
||||
const previousTheme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return ['web', 'win32', 'darwin'].flatMap(platform => [false, true].map((dark) => {
|
||||
if (platform === 'web') html.removeAttribute('data-platform')
|
||||
else html.setAttribute('data-platform', platform)
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
const headings = [...menu.querySelectorAll('section[role="group"] > div')]
|
||||
return {
|
||||
platform, dark,
|
||||
headingFills: [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))],
|
||||
positions: [...new Set(headings.map(heading => getComputedStyle(heading).position))],
|
||||
headingRadii: [...new Set(headings.map(heading => getComputedStyle(heading).borderRadius))],
|
||||
optionRadius: getComputedStyle(menu.querySelector('[role="menuitemradio"]')!).borderRadius,
|
||||
menuFill: getComputedStyle(menu.querySelector(':scope > [aria-hidden="true"]')!).backgroundColor,
|
||||
searchBorder: getComputedStyle(menu.querySelector('input')!.parentElement!).borderColor,
|
||||
}
|
||||
}))
|
||||
} finally {
|
||||
if (previousPlatform === null) html.removeAttribute('data-platform')
|
||||
else html.setAttribute('data-platform', previousPlatform)
|
||||
if (previousTheme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', previousTheme)
|
||||
}
|
||||
})
|
||||
for (const palette of headingPalettes) {
|
||||
expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`)
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)')
|
||||
expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(palette.positions).toEqual(['sticky'])
|
||||
expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius])
|
||||
}
|
||||
await search.fill('zzzz')
|
||||
await page.getByText('没有匹配的模型。', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await page.getByRole('menuitemradio').count()).toBe(modelWeights.length)
|
||||
expect(await page.getByRole('button', { name: '清除搜索', exact: true }).count()).toBe(0)
|
||||
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="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const blocked = scaffold.ctx.hmr.runExclusive(async () => {
|
||||
@@ -115,9 +434,15 @@ 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 search.press('ArrowDown')
|
||||
await expect.poll(() => search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant'))
|
||||
.toBe(await page.getByRole('menuitemradio', { name: 'Acme Large' }).getAttribute('id'))
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
|
||||
await expect.poll(() => trigger.textContent()).toContain('Acme Large')
|
||||
await expect.poll(() => trigger.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).toBe('none')
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
|
||||
@@ -143,6 +468,10 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
await trigger.press('Tab')
|
||||
await trigger.focus()
|
||||
expect(await trigger.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…"
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- menuitemradio "Acme Lite"
|
||||
@@ -1,5 +1,6 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
- group "模型与推理等级":
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
@@ -0,0 +1,14 @@
|
||||
- textbox "筛选选项":
|
||||
- /placeholder: 搜索模型…
|
||||
- listbox "/model 匹配项":
|
||||
- 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"
|
||||
@@ -0,0 +1,7 @@
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…": ACMLG
|
||||
- button "清除搜索"
|
||||
- menu "模型":
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Large"
|
||||
@@ -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}}
|
||||
@@ -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}}"
|
||||
@@ -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}}"
|
||||
@@ -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}}"
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,290 @@
|
||||
/** 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<void> {
|
||||
await page.evaluate(() => new Promise<void>((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<void> {
|
||||
await frames(page)
|
||||
expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0)
|
||||
}
|
||||
|
||||
async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise<void> {
|
||||
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<void> {
|
||||
// Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation.
|
||||
await viewport.evaluate((node, target) => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const groups = node.querySelectorAll<HTMLElement>(':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<typeof watchConsole>
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// 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<ReturnType<typeof headingMaterial>> | 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<void> => {
|
||||
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<void> => {
|
||||
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<number>()
|
||||
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<HTMLElement>(':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<HTMLElement>(':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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 已接受的设置后会同步当前值。
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 01adce72791579fa
|
||||
zh: 70eb55a1529038e7
|
||||
/deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations:
|
||||
en: 86841942f6989fbb
|
||||
zh: 901557d3a5d0528e
|
||||
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: 0fd2b19e41605440
|
||||
zh: 52d5c653b877a1f0
|
||||
en: 3aeeba6c91c3ef19
|
||||
zh: d3afb3df6d15e17b
|
||||
/deepseek-ai-dsh-client-ui-commands/further-exploration:
|
||||
en: 409c04f534d23de7
|
||||
zh: 165022d7a0a554eb
|
||||
|
||||
@@ -31,6 +31,12 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
|
||||
|
||||
A contribution is a client-owned command; a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, without submitting a message. Business packages own their actions and availability; the composer registers File through this same API. A decoration adds a bare-invocation popup or action to an existing host command while preserving its catalog row, argument claim, and lifecycle logging; it never fires without a matching host row. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles, with prefixes first and no section headings.
|
||||
|
||||
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.
|
||||
@@ -44,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
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).
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
@@ -31,6 +31,12 @@ kind: "package-reference"
|
||||
|
||||
贡献项是客户端自有命令,与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用消费触发 token 后运行回调,不提交消息。业务包负责自己的动作及可用性,输入框通过同一 API 注册「文件」。装饰为已有宿主命令添加裸调用弹窗或动作,并保留其目录行、参数认领与生命周期记录;没有匹配的宿主行时不触发。菜单查询按顺序、不区分大小写地模糊匹配命令名与标题的子序列,前缀优先,不显示小节标题。
|
||||
|
||||
popupSelect 规格可提供 `searchLabels()`,在每次打开时解析占位文字、空目录提示和无匹配提示。未提供时保留外壳的通用文案;一个弹窗的文案不会沿用到下一个弹窗。
|
||||
|
||||
选项可携带 `group: { name, label }`:`name` 标识分组,`label` 提供本地化吸顶标题。同名选项归入同一组;分组保持在已加载选项中首次出现的顺序,即使查询隐藏了较早的行也不变。未分组选项在首次出现的位置组成一个无标题区块。没有匹配行的分组会隐藏。
|
||||
|
||||
可选的 `searchMode` 默认为 `'substring'`,不区分大小写地匹配 `label` 与 `detail` 的子串,不进行相关性排序。`'fuzzy-label'` 仅匹配标签中按顺序、不区分大小写的子序列,并在组内排序:前缀优先,其次按匹配得分,最后按原始行顺序。两种模式均忽略查询首尾空格;空查询按分组顺序保留所有行。渲染、键盘导航与选择使用相同的筛选顺序。`/model` 启用 `'fuzzy-label'`;其他命令未显式启用时仍使用子串匹配。
|
||||
|
||||
### 内置行的展示面
|
||||
|
||||
内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。
|
||||
@@ -44,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
菜单采用共享 `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)。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
@@ -107,7 +109,7 @@
|
||||
.search {
|
||||
margin: 2px 2px 3px;
|
||||
padding: 5px 7px;
|
||||
border: 0.5px solid var(--dsw-alias-border-inverted);
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -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, 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<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const rows = useMemo(() => filterOptions(state.options, state.search, state.searchMode),
|
||||
[state.options, state.search, state.searchMode])
|
||||
const groups = useMemo(() => groupOptions(rows), [rows])
|
||||
useEffect(() => {
|
||||
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,8 +89,10 @@ 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')
|
||||
: state.options.length === 0 ? state.searchLabels.empty : state.searchLabels.noResults
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
|
||||
@@ -118,6 +131,25 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderOption = (option: SelectOption, index: number) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
aria-label={option.badge === undefined ? undefined : `${option.label} ${option.badge}`}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>
|
||||
<span className={css.labelText}>{option.label}</span>
|
||||
{option.badge !== undefined && <sup className={css.badge}>{option.badge}</sup>}
|
||||
</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutlineRegular /></span>}
|
||||
</div>
|
||||
)
|
||||
let optionIndex = 0
|
||||
return (
|
||||
<>
|
||||
{state.confirming === null && (
|
||||
@@ -132,7 +164,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
placeholder={state.searchLabels?.placeholder ?? t('search.placeholder')}
|
||||
aria-label={t('search.aria')}
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
@@ -148,30 +180,14 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
|
||||
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{emptyLabel}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
aria-label={option.badge === undefined ? undefined : `${option.label} ${option.badge}`}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>
|
||||
<span className={css.labelText}>{option.label}</span>
|
||||
{option.badge !== undefined && <sup className={css.badge}>{option.badge}</sup>}
|
||||
</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutlineRegular /></span>}
|
||||
</div>
|
||||
))}
|
||||
<div ref={viewportRef} role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{groups.map(({ group, rows: groupRows }) => group === undefined
|
||||
? <Fragment key="ungrouped">{groupRows.map(option => renderOption(option, optionIndex++))}</Fragment>
|
||||
: <MenuGroup key={`group:${group.name}`} label={group.label}>
|
||||
{groupRows.map(option => renderOption(option, optionIndex++))}
|
||||
</MenuGroup>)}
|
||||
</div>
|
||||
)}
|
||||
</MenuSurface>
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +46,15 @@ export interface SelectOption {
|
||||
readonly confirmation?: SelectConfirmation
|
||||
}
|
||||
|
||||
/** Command-owned search copy, resolved when its popup opens. */
|
||||
export interface PopupSearchLabels {
|
||||
readonly placeholder: string
|
||||
/** Empty catalog, before filtering. */
|
||||
readonly empty: string
|
||||
/** Nonempty catalog with no matching rows. */
|
||||
readonly noResults: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Business registration for the popupSelect command kind. Data is
|
||||
* self-served: options/onSelect use the business package's own protocol.
|
||||
@@ -43,6 +63,13 @@ export interface SelectOption {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
searchLabels?(): PopupSearchLabels
|
||||
options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
|
||||
onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec,
|
||||
SelectConfirmation, SelectOption,
|
||||
ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSearchLabels, PopupSelectSpec,
|
||||
SelectConfirmation, SelectOption, SelectOptionGroup, PopupSearchMode,
|
||||
} from './contract.ts'
|
||||
export type { CommandKey } from './locales.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<string | undefined, { group: SelectOptionGroup | undefined; rows: SelectOption[] }>()
|
||||
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()]
|
||||
}
|
||||
@@ -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 { 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<TCtx> {
|
||||
export interface PopupSpec<TCtx> extends Pick<PopupSelectSpec, 'searchLabels' | 'searchMode'> {
|
||||
/** Load the option rows once per open (retry after failure reuses the same signal). */
|
||||
options(context: TCtx, signal: AbortSignal): Promise<readonly SelectOption[]>
|
||||
/** Settle the picked option against the open-time context. */
|
||||
@@ -63,6 +65,10 @@ export interface PopupState {
|
||||
readonly options: readonly SelectOption[]
|
||||
/** Local filter text over the loaded options. */
|
||||
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
|
||||
@@ -81,21 +87,31 @@ export interface PopupState {
|
||||
}
|
||||
|
||||
const CLOSED: PopupState = {
|
||||
open: false, command: null, status: 'pending', options: [], search: '', 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))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,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
|
||||
}
|
||||
|
||||
@@ -152,10 +169,11 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
* @param segment - open-time token segment snapshot for post-select consumption.
|
||||
*/
|
||||
open(command: string, spec: PopupSpec<TCtx>, context: TCtx, segment: TokenSegment): void {
|
||||
const searchLabels = spec.searchLabels?.() ?? null
|
||||
this.binding?.abort.abort()
|
||||
const binding: OpenBinding<TCtx> = { command, spec, context, segment, abort: new AbortController() }
|
||||
this.binding = binding
|
||||
this.state.set({ ...CLOSED, open: true, command })
|
||||
this.state.set({ ...CLOSED, open: true, command, searchLabels, searchMode: spec.searchMode ?? 'substring' })
|
||||
this.load(binding)
|
||||
}
|
||||
|
||||
@@ -165,7 +183,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
(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
|
||||
@@ -204,7 +222,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
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 })
|
||||
@@ -218,7 +236,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -236,7 +254,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
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 })
|
||||
|
||||
@@ -78,6 +78,55 @@ 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)
|
||||
const { popup, search } = await mountOpen({ searchLabels })
|
||||
expect(search.getAttribute('placeholder')).toBe(labels.placeholder)
|
||||
fireEvent.change(search, { target: { value: 'no-match' } })
|
||||
expect(screen.getByText(labels.noResults)).toBeTruthy()
|
||||
expect(screen.queryByText(labels.empty)).toBeNull()
|
||||
expect(searchLabels).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
popup.open('empty-models', spec({ searchLabels, options: () => Promise.resolve([]) }), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByText(labels.empty)).toBeTruthy()
|
||||
expect(screen.queryByText(labels.noResults)).toBeNull()
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(search.getAttribute('placeholder')).toBe(zh['search.placeholder'])
|
||||
fireEvent.change(search, { target: { value: 'no-match' } })
|
||||
expect(screen.getByText(zh['status.empty'])).toBeTruthy()
|
||||
expect(popup.state.getSnapshot().searchLabels).toBeNull()
|
||||
})
|
||||
|
||||
it('renders null while closed, opens with focus in the search input', async () => {
|
||||
const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
|
||||
const view = render(<PopupSelectView popup={popup} t={t} />)
|
||||
|
||||
@@ -48,6 +48,36 @@ async function readyPopup(overrides: Partial<PopupSpec<Ctx>> = {}, 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)
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package:
|
||||
en: e5a1df3a4dfe5041
|
||||
zh: 6269e73026b32a33
|
||||
en: 62235f8c8c9e1e7e
|
||||
zh: c18dc9c38ef9d4f9
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
|
||||
en: e38d0195677a7b69
|
||||
zh: 4eea8a9b1664afd9
|
||||
en: 48f8157ffdfe6e69
|
||||
zh: 64ec9a533e2aa8f4
|
||||
/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
|
||||
|
||||
@@ -31,13 +31,17 @@ 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 through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or 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.
|
||||
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. 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. Tab returns to model search with the current model highlighted when search is shown, otherwise 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -59,6 +63,8 @@ Menus use the shared `MenuSurface` material, including the macOS backing for bac
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -31,13 +31,17 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
|
||||
|
||||
未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。
|
||||
|
||||
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
|
||||
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
|
||||
|
||||
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。
|
||||
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。
|
||||
|
||||
### 模型与推理强度
|
||||
|
||||
模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
|
||||
按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
|
||||
|
||||
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 行;不存在任意推理强度输入。
|
||||
|
||||
展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。
|
||||
|
||||
@@ -59,6 +63,8 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
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` 与凭据更新事件上重拉。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
.trigger:focus-visible:not([data-selection-focus]) {
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
@@ -155,27 +155,69 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin: 2px 0 3px;
|
||||
}
|
||||
|
||||
.searchRow .search {
|
||||
display: flex;
|
||||
height: auto;
|
||||
padding: 5px 7px;
|
||||
border: 0 solid transparent;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.searchRow .searchWithQuery {
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 4px;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
corner-shape: round;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchClear:hover,
|
||||
.searchClear:focus-visible {
|
||||
outline: none;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchRow .search:focus-within {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.searchRow .search input {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.searchRow .search input::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.groups {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 4px 7px 2px;
|
||||
background: var(--dsw-specific-menu);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@@ -194,8 +236,9 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled),
|
||||
.option:focus-visible {
|
||||
.option:not(.modelOption):hover:not(:disabled),
|
||||
.option:focus-visible,
|
||||
.optionActive:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@@ -222,7 +265,7 @@
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -257,9 +300,11 @@
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
.cell:hover,
|
||||
.cell:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,21 +5,27 @@
|
||||
* each drilling into its own list — the provider-grouped model list over
|
||||
* the shared directory, and the effort levels. The trigger (313:14108's
|
||||
* ToggleButton) shows both: model name + effort in the caption tone.
|
||||
* While open, ↑/↓ move focus across the rows of the shown pane (wrapping; a
|
||||
* 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
|
||||
* 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.
|
||||
* Model catalogs above four entries show search, which retains focus while
|
||||
* ↑/↓ cycle the highlighted result; Enter and Tab accept it. Smaller model
|
||||
* catalogs, root panes, and effort panes move focus between rows. Escape and Shift+Tab leave a drilled pane first and otherwise close
|
||||
* back to the trigger. A drilled pane focuses the current effort or model
|
||||
* search field. Provider headings paint their background only while pinned
|
||||
* by scrolling. Clearing a query restores the full list and search focus.
|
||||
* Selecting restores trigger focus without a ring until the trigger loses focus
|
||||
* or the menu reopens. Model names match a 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 { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
|
||||
type CSSProperties, type KeyboardEvent, type FocusEvent,
|
||||
@@ -28,12 +34,13 @@ import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular,
|
||||
IconDataOutlineRegular, IconWarningOutlineRegular, StateDot, Toast,
|
||||
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular,
|
||||
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'
|
||||
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'
|
||||
@@ -64,6 +71,9 @@ export function ModelSelect(
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pane, setPane] = useState<Pane>('root')
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null)
|
||||
const [selectionFocus, setSelectionFocus] = useState(false)
|
||||
// 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
|
||||
@@ -73,14 +83,14 @@ export function ModelSelect(
|
||||
const toastSeq = useRef(0)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||
const groupsRef = useRef<HTMLDivElement | null>(null)
|
||||
const [menuPos, setMenuPos] = useState<CSSProperties | null>(null)
|
||||
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,
|
||||
@@ -93,6 +103,16 @@ export function ModelSelect(
|
||||
: { reasoningEffort: model.reasoning.defaultEffort },
|
||||
} satisfies ModelSelection,
|
||||
}))), [groups])
|
||||
const showSearch = choices.length > 4
|
||||
const filteredGroups = useMemo(() => groups.map(group => ({
|
||||
...group, models: rankByName(group.models, showSearch ? query.trim() : ''),
|
||||
})).filter(group => group.models.length > 0), [groups, query, showSearch])
|
||||
const visibleModels = useMemo(() => filteredGroups.flatMap(group => group.models.map(model => ({
|
||||
provider: group.id, model: model.id,
|
||||
}))), [filteredGroups])
|
||||
const currentVisibleIndex = visibleModels.findIndex(model =>
|
||||
model.provider === state.current?.provider && model.model === state.current.model)
|
||||
const activeModelIndex = Math.min(highlightedIndex ?? Math.max(0, currentVisibleIndex), visibleModels.length - 1)
|
||||
const selectedIndex = state.current === null
|
||||
? -1
|
||||
: choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model)
|
||||
@@ -136,17 +156,28 @@ 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.
|
||||
useLayoutEffect(() => {
|
||||
if (!showSearch) {
|
||||
setQuery('')
|
||||
setHighlightedIndex(null)
|
||||
}
|
||||
}, [showSearch])
|
||||
|
||||
// Pane switches unmount the focused row; restore focus inside the menu so
|
||||
// keyboard navigation remains available.
|
||||
const paneFocus = useRef<'drill' | 'model' | 'effort' | null>(null)
|
||||
const previousShowSearch = useRef(showSearch)
|
||||
useEffect(() => {
|
||||
const intent = paneFocus.current
|
||||
const changedSearchMode = previousShowSearch.current !== showSearch
|
||||
previousShowSearch.current = showSearch
|
||||
const intent = paneFocus.current ?? (changedSearchMode && pane === 'model' ? 'drill' : null)
|
||||
paneFocus.current = null
|
||||
if (!open || intent === null) return
|
||||
if (intent === 'drill') {
|
||||
if (pane === 'model' && showSearch) {
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
// The checked row is the value in use; a pane without one opens on its
|
||||
// first row.
|
||||
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
|
||||
@@ -158,7 +189,19 @@ export function ModelSelect(
|
||||
}
|
||||
const cell = itemRefs.current[intent === 'effort' ? 1 : 0]
|
||||
;(cell !== null && cell !== undefined && !cell.disabled ? cell : triggerRef.current)?.focus()
|
||||
}, [open, pane])
|
||||
}, [open, pane, showSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = groupsRef.current
|
||||
if (viewport === null) return
|
||||
return observeStickyMenuGroups(viewport)
|
||||
}, [available, open, pane, filteredGroups])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open && pane === 'model' && activeModelIndex >= 0) {
|
||||
itemRefs.current[activeModelIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [open, pane, activeModelIndex, visibleModels])
|
||||
|
||||
// Portaled placement (the Menu primitive's portal rules: fixed from the
|
||||
// anchor rect, measured before paint, clamped inside the viewport): above
|
||||
@@ -191,26 +234,41 @@ 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
|
||||
|
||||
const show = (): void => {
|
||||
setSelectionFocus(false)
|
||||
triggerRef.current?.focus()
|
||||
setQuery('')
|
||||
setHighlightedIndex(null)
|
||||
if (state.current === null) paneFocus.current = 'drill'
|
||||
setPane(state.current === null ? 'model' : 'root')
|
||||
setOpen(true)
|
||||
reload()
|
||||
}
|
||||
|
||||
const changeQuery = (next: string): void => {
|
||||
setQuery(next)
|
||||
setHighlightedIndex(0)
|
||||
}
|
||||
|
||||
const close = (restoreFocus = false): void => {
|
||||
setOpen(false)
|
||||
setPane('root')
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
|
||||
const closeAfterSelection = (): void => {
|
||||
setSelectionFocus(true)
|
||||
close(true)
|
||||
}
|
||||
|
||||
const drill = (next: Pane): void => {
|
||||
setQuery('')
|
||||
setHighlightedIndex(null)
|
||||
paneFocus.current = 'drill'
|
||||
setPane(next)
|
||||
}
|
||||
@@ -235,6 +293,7 @@ export function ModelSelect(
|
||||
}
|
||||
|
||||
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): 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 +302,23 @@ export function ModelSelect(
|
||||
return
|
||||
}
|
||||
if (!open) return
|
||||
if (pane === 'model' && showSearch && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
event.preventDefault()
|
||||
if (!busy && visibleModels.length > 0) {
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1
|
||||
setHighlightedIndex((activeModelIndex + direction + visibleModels.length) % visibleModels.length)
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (pane === 'model' && showSearch && event.target instanceof HTMLInputElement
|
||||
&& (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey))) {
|
||||
if (event.key === 'Tab' && visibleModels.length === 0) return
|
||||
event.preventDefault()
|
||||
const highlighted = visibleModels[activeModelIndex]
|
||||
if (!busy && highlighted !== undefined) choose(highlighted)
|
||||
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.
|
||||
@@ -266,6 +342,11 @@ export function ModelSelect(
|
||||
}
|
||||
if (focused !== triggerRef.current) return
|
||||
event.preventDefault()
|
||||
if (pane === 'model' && showSearch) {
|
||||
setHighlightedIndex(null)
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
|
||||
;(checked ?? rows.find(item => !item.disabled))?.focus()
|
||||
return
|
||||
@@ -287,7 +368,7 @@ export function ModelSelect(
|
||||
const settleSelection = (result: Awaited<ReturnType<ModelSelectInjected['select']>>): void => {
|
||||
if (result === undefined) return
|
||||
if (result.ok) {
|
||||
if (rootRef.current !== null) close(true)
|
||||
if (rootRef.current !== null) closeAfterSelection()
|
||||
return
|
||||
}
|
||||
const { error } = result
|
||||
@@ -303,13 +384,14 @@ export function ModelSelect(
|
||||
const submit = (selection: ModelSelection): void => {
|
||||
lastActionRef.current = 'select'
|
||||
// Disabled option rows cannot retain focus while a selection is pending.
|
||||
setSelectionFocus(true)
|
||||
triggerRef.current?.focus()
|
||||
void select(selection).then(settleSelection)
|
||||
}
|
||||
|
||||
const choose = (selection: ModelSelection): void => {
|
||||
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
|
||||
close(true)
|
||||
closeAfterSelection()
|
||||
return
|
||||
}
|
||||
submit(selection)
|
||||
@@ -318,7 +400,7 @@ export function ModelSelect(
|
||||
const chooseEffort = (effort: string | undefined): void => {
|
||||
if (state.current === null) return
|
||||
if (effectiveEffort === effort) {
|
||||
close(true)
|
||||
closeAfterSelection()
|
||||
return
|
||||
}
|
||||
const selection: ModelSelection = {
|
||||
@@ -344,6 +426,7 @@ export function ModelSelect(
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
let modelIndex = 0
|
||||
const itemRef = () => {
|
||||
const at = itemIndex++
|
||||
return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
|
||||
@@ -370,6 +453,8 @@ export function ModelSelect(
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
title={triggerLabel}
|
||||
aria-busy={busy}
|
||||
data-selection-focus={selectionFocus ? '' : undefined}
|
||||
onBlur={() => { setSelectionFocus(false) }}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
@@ -396,7 +481,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}
|
||||
>
|
||||
@@ -419,6 +504,35 @@ export function ModelSelect(
|
||||
|
||||
{pane === 'model' && (
|
||||
<>
|
||||
{showSearch && <div className={css.searchRow}>
|
||||
<Input
|
||||
ref={searchRef}
|
||||
className={clsx(css.search, query !== '' && css.searchWithQuery)}
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-label={t('search.placeholder')}
|
||||
aria-controls={`${id}-models`}
|
||||
aria-activedescendant={activeModelIndex < 0 ? undefined : `${id}-model-${activeModelIndex}`}
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
readOnly={busy}
|
||||
onChange={(event) => { changeQuery(event.target.value) }}
|
||||
/>
|
||||
{query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchClear}
|
||||
aria-label={t('search.clear')}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
changeQuery('')
|
||||
searchRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<IconCloseFillRegular />
|
||||
</button>
|
||||
)}
|
||||
</div>}
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
@@ -434,13 +548,19 @@ export function ModelSelect(
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
{groups.map((group) => {
|
||||
const headingId = `${id}-${group.id}`
|
||||
<div
|
||||
ref={groupsRef}
|
||||
id={`${id}-models`}
|
||||
className={clsx(css.groups, 'scrollable')}
|
||||
role="menu"
|
||||
aria-label={t('menu.model')}
|
||||
hidden={filteredGroups.length === 0}
|
||||
>
|
||||
{filteredGroups.map((group) => {
|
||||
return (
|
||||
<section role="group" aria-labelledby={headingId} className={css.group} key={group.id}>
|
||||
<div className={css.groupTitle} id={headingId}>{group.id === 'deepseek-account' ? t('provider.account') : group.name}</div>
|
||||
<MenuGroup key={group.id} label={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
|
||||
return (
|
||||
<button
|
||||
@@ -448,7 +568,17 @@ export function ModelSelect(
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
className={clsx(css.option, selected && css.selected)}
|
||||
id={`${id}-model-${index}`}
|
||||
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 : () => {
|
||||
if (showSearch) setHighlightedIndex(index)
|
||||
else itemRefs.current[index]?.focus()
|
||||
}}
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
disabled={busy}
|
||||
@@ -465,12 +595,12 @@ export function ModelSelect(
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</MenuGroup>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{state.status === 'ready' && choices.length === 0 && (
|
||||
<div className={css.empty}>{t('empty.models')}</div>
|
||||
{state.status === 'ready' && filteredGroups.length === 0 && (
|
||||
<div className={css.empty} role="status">{t(choices.length === 0 ? 'empty.models' : 'search.empty')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
@@ -47,33 +48,16 @@ function rowId(providerId: string, modelId: string): string {
|
||||
return `${providerId}/${modelId}`
|
||||
}
|
||||
|
||||
const BUILTIN_DESCRIPTION_KEYS: Readonly<Record<string, ModelKey>> = {
|
||||
'deepseek-account/deepseek-v4-flash': 'option.deepseekV4Flash.description',
|
||||
'deepseek-account/deepseek-v4-pro': 'option.deepseekV4Pro.description',
|
||||
'deepseek-official/deepseek-v4-flash': 'option.deepseekV4Flash.description',
|
||||
'deepseek-official/deepseek-v4-pro': 'option.deepseekV4Pro.description',
|
||||
}
|
||||
|
||||
function descriptionOf(
|
||||
providerId: string,
|
||||
model: ModelDirectoryState['groups'][number]['models'][number],
|
||||
t: TranslateNS<'model'>,
|
||||
): string | undefined {
|
||||
const key = BUILTIN_DESCRIPTION_KEYS[rowId(providerId, model.id)]
|
||||
return key !== undefined && model.description === en[key] ? t(key) : model.description
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const description = descriptionOf(group.id, model, t)
|
||||
rows.push({
|
||||
id: rowId(group.id, model.id),
|
||||
label: model.name,
|
||||
detail: description !== undefined ? `${name} · ${description}` : name,
|
||||
group: { name: group.id, label: name },
|
||||
...(directory.current !== null
|
||||
&& directory.current.provider === group.id
|
||||
&& directory.current.model === model.id
|
||||
@@ -150,6 +134,12 @@ 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'),
|
||||
noResults: t('search.empty'),
|
||||
}),
|
||||
options: async (session) => {
|
||||
if (sessions.subagentAddress(session.sessionId) !== undefined) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
|
||||
@@ -14,8 +14,6 @@ export const zh = {
|
||||
'command.label': '模型',
|
||||
'command.description': '选择本会话使用的模型',
|
||||
'option.loadError': '目录加载失败:{message}',
|
||||
'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。',
|
||||
'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。',
|
||||
'trigger.fallback': '请选择模型',
|
||||
'trigger.loading': '正在加载模型…',
|
||||
'trigger.selectAria': '请选择模型',
|
||||
@@ -30,6 +28,9 @@ export const zh = {
|
||||
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'search.placeholder': '搜索模型…',
|
||||
'search.clear': '清除搜索',
|
||||
'search.empty': '没有匹配的模型。',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
@@ -43,8 +44,6 @@ export const en = {
|
||||
'command.label': 'Model',
|
||||
'command.description': 'Select the model for this conversation',
|
||||
'option.loadError': 'Catalog failed to load: {message}',
|
||||
'option.deepseekV4Flash.description': 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
|
||||
'option.deepseekV4Pro.description': 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
|
||||
'trigger.fallback': 'Select model',
|
||||
'trigger.loading': 'Loading models…',
|
||||
'trigger.selectAria': 'Select model',
|
||||
@@ -59,6 +58,9 @@ 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.clear': 'Clear search',
|
||||
'search.empty': 'No matching models.',
|
||||
'empty.models': 'No models available.',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
@@ -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<T extends { readonly id: string }>(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))
|
||||
}
|
||||
@@ -225,31 +225,38 @@ describe('ui-model-selection dual entry', () => {
|
||||
expect(b.seat().locale).toBe('model')
|
||||
})
|
||||
|
||||
it('localizes built-in descriptions and preserves external provider descriptions', async () => {
|
||||
const b = await bench()
|
||||
it.each(['zh', 'en'] as const)('shows names and providers without catalog descriptions (%s)', async (locale) => {
|
||||
const b = await bench(locale)
|
||||
b.mint('s1')
|
||||
const options = await b.popup().options(projection('s1'), new AbortController().signal)
|
||||
expect(options.map((o: SelectOption) => o.label)).toEqual([
|
||||
'DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'External Flash',
|
||||
])
|
||||
expect(options[0]).toMatchObject({
|
||||
active: true,
|
||||
detail: 'DeepSeek · 快速、高效且经济;适合目标明确、常规或并行任务。',
|
||||
})
|
||||
expect(options[1]?.detail)
|
||||
.toBe('DeepSeek · 更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。')
|
||||
expect(options[2]?.detail).toBe('External Provider · Provider-authored description.')
|
||||
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'
|
||||
? { placeholder: '搜索模型…', empty: '没有可用的模型。', noResults: '没有匹配的模型。' }
|
||||
: { placeholder: 'Search models…', empty: 'No models available.', noResults: 'No matching models.' })
|
||||
})
|
||||
|
||||
it('keeps built-in descriptions unchanged in English', async () => {
|
||||
const b = await bench('en')
|
||||
b.mint('s1')
|
||||
const options = await b.popup().options(projection('s1'), new AbortController().signal)
|
||||
expect(options[0]?.detail)
|
||||
.toBe('DeepSeek · Fast, efficient, and economical; suited to focused, routine, or parallel tasks.')
|
||||
expect(options[1]?.detail)
|
||||
.toBe('DeepSeek · Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.')
|
||||
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 () => {
|
||||
@@ -291,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()
|
||||
}
|
||||
@@ -311,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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
@@ -53,6 +53,27 @@ function state(overrides: Partial<ModelDirectoryState> = {}): 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()
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView')
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, writable: true, value: scrollIntoView })
|
||||
onTestFinished(() => {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
else Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelect reasoning effort', () => {
|
||||
@@ -214,7 +235,9 @@ describe('ModelSelect reasoning effort', () => {
|
||||
const trigger = screen.getByRole('button', { name: /选择模型|当前/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
||||
const rejected = screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })
|
||||
fireEvent.mouseMove(rejected)
|
||||
fireEvent.click(rejected)
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
expect(toast.textContent).toBe(sessionInUse
|
||||
@@ -222,6 +245,9 @@ describe('ModelSelect reasoning effort', () => {
|
||||
: '模型操作失败:session/model-unavailable: session already contains images')
|
||||
// The selection failure does not render the in-menu load strip (no Retry).
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
fireEvent.keyDown(trigger, { key: 'Tab' })
|
||||
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 () => {
|
||||
@@ -469,15 +495,14 @@ 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(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)
|
||||
@@ -489,11 +514,12 @@ describe('ModelSelect keyboard walk', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('drills into the model list on the selected 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(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
|
||||
@@ -520,7 +546,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 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(<ModelSelect
|
||||
locked={false}
|
||||
@@ -534,10 +560,298 @@ describe('ModelSelect keyboard walk', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
|
||||
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(<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups, current }))}
|
||||
load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
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(<ModelSelect locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(count), current: null }))}
|
||||
load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
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(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
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(<ModelSelect locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(4), current: { provider: 'deepseek-official', model: 'model-2' } }))}
|
||||
load={vi.fn()} select={select} t={t} />)
|
||||
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(<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(4) }))}
|
||||
load={vi.fn()} select={select} t={t} />)
|
||||
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(<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(4) }))}
|
||||
load={vi.fn()} select={select} t={t} />)
|
||||
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(<ModelSelect locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(5), current: null }))}
|
||||
load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
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(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
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(<>
|
||||
<button type="button">Outside</button>
|
||||
<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(5) }))}
|
||||
load={vi.fn()} select={select} t={t} />
|
||||
</>)
|
||||
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: 'delta', name: 'Delta' }, { id: 'epsilon', name: 'Epsilon' }, { id: 'gamma', name: 'Gamma' },
|
||||
] },
|
||||
],
|
||||
}))
|
||||
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
const search = screen.getByRole('searchbox')
|
||||
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' })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(alpha!.id)
|
||||
fireEvent.keyDown(search, { key: 'ArrowUp' })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id)
|
||||
expect(gamma!.hasAttribute('data-highlighted')).toBe(true)
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(gamma)
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
|
||||
fireEvent.keyDown(search, { key: 'ArrowDown', isComposing: true })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id)
|
||||
fireEvent.change(search, { target: { value: 'alp' } })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(screen.getByRole('menuitemradio', { name: 'Alpha' }).id)
|
||||
fireEvent.change(search, { target: { value: 'zzzz' } })
|
||||
expect(search.hasAttribute('aria-activedescendant')).toBe(false)
|
||||
fireEvent.keyDown(search, { key: 'Enter' })
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
expect(fireEvent.keyDown(search, { key: 'Tab' })).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
const restored = screen.getAllByRole('menuitemradio')
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(restored[0]!.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: [
|
||||
...modelGroups(4),
|
||||
{ id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] },
|
||||
] }))
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
|
||||
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(search.closest('[role="menu"]')).toBeNull()
|
||||
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.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' } })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status.textContent).toBe('没有匹配的模型。')
|
||||
expect(status.closest('[role="menu"]')).toBeNull()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowDown' })).toBe(false)
|
||||
expect(document.activeElement).toBe(search)
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(search.getAttribute('value')).toBe('')
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull()
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5)
|
||||
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' } })
|
||||
const row = screen.getByRole('menuitemradio', { name: 'Gemini Flash' })
|
||||
expect(screen.getByRole('menu', { name: '模型' }).contains(row)).toBe(true)
|
||||
fireEvent.keyDown(reopened, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(reopened)
|
||||
expect(reopened.getAttribute('aria-activedescendant')).toBe(row.id)
|
||||
fireEvent.keyDown(reopened, { key: 'Tab' })
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gemini' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the unselected model control with the inherited effort', async () => {
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state({ current: null, routable: false, retainedEffort: 'High' }))
|
||||
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
@@ -547,10 +861,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' }))
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: b521f773eff5c728
|
||||
zh: 7b56c5b4777a0b00
|
||||
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: 464527753be6a4f6
|
||||
zh: d9df7442503bd58f
|
||||
en: 0b4082dbb778476f
|
||||
zh: b6daaaf9de675b57
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
|
||||
en: 44eb32370cbeafe8
|
||||
zh: 728009c66375c6bb
|
||||
|
||||
@@ -44,8 +44,9 @@ 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 (`<id>-<value>`) and the panel it controls (`<id>-<value>-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. |
|
||||
| `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. |
|
||||
@@ -118,6 +119,12 @@ 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)` 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.
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
|
||||
@@ -44,8 +44,9 @@ kind: "package-library"
|
||||
| `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
||||
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`<id>-<value>`)及其控制的面板 id(`<id>-<value>-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` 仅决定打开时是否聚焦首行。 |
|
||||
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
||||
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
||||
@@ -118,6 +119,12 @@ 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` 区段渲染为无内边距、无边框的滚动容器的直接子节点后,在普通 effect 中调用 `observeStickyMenuGroups(viewport)`。原生交叉观察与滚动区尺寸观察异步更新背景,不同步读取布局,也不注册滚动监听器。标题保持透明,直到观察结果确认分组跨过滚动区顶部;吸顶定位由 CSS 负责。
|
||||
|
||||
分组成员在初始化时确定。分组变化后(包括筛选)重新观察前必须清理,卸载时也必须清理。清理会断开观察器、忽略排队回调并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建观察器。缺少 `IntersectionObserver` 或 `ResizeObserver` 时,标题仍通过 CSS 吸顶,但保持透明。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement, {
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
||||
} & InputHTMLAttributes<HTMLInputElement>>(function Input({ icon, className, ...rest }, ref) {
|
||||
return (
|
||||
<span className={clsx(css.wrap, className)}>
|
||||
{icon != null && <span className={css.icon}>{icon}</span>}
|
||||
<input className={css.input} {...rest} />
|
||||
<input ref={ref} className={css.input} {...rest} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
.group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.start {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** 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 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.
|
||||
*/
|
||||
export function MenuGroup({ label, children }: { label: string; children?: ReactNode }) {
|
||||
const headingId = useId()
|
||||
return (
|
||||
<section role="group" aria-labelledby={headingId} data-menu-group="" className={css.group}>
|
||||
<span aria-hidden="true" data-menu-group-start="" className={css.start} />
|
||||
<div id={headingId} data-menu-group-heading="" className={css.heading}>{label}</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 groups = [...viewport.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')].flatMap((section) => {
|
||||
const heading = section.querySelector<HTMLElement>(':scope > [data-menu-group-heading]')
|
||||
const start = section.querySelector<HTMLElement>(':scope > [data-menu-group-start]')
|
||||
return heading === null || start === null ? [] : [{
|
||||
section, heading, start, atTop: false, above: false, topTime: -Infinity, aboveTime: -Infinity,
|
||||
}]
|
||||
})
|
||||
if (groups.length === 0 || typeof IntersectionObserver === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const sections = new Map<Element, typeof groups[number]>(groups.map(group => [group.section, group]))
|
||||
const starts = new Map<Element, typeof groups[number]>(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 () => {
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -59,6 +59,16 @@ describe('Pill', () => {
|
||||
})
|
||||
|
||||
describe('Input', () => {
|
||||
it('exposes its native input for focus and releases the ref on unmount', () => {
|
||||
const ref = createRef<HTMLInputElement>()
|
||||
const { unmount } = render(<Input ref={ref} aria-label="Search" />)
|
||||
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(<Input icon={<svg data-testid="ic" />} value="q" onChange={onChange} placeholder="search" />)
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Menu-group naming, sticky transitions, and viewport-observer ownership. */
|
||||
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(() => {
|
||||
intersections.length = 0
|
||||
sizes.length = 0
|
||||
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver)
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver)
|
||||
})
|
||||
|
||||
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 directChild(section: HTMLElement, attribute: string): HTMLElement {
|
||||
const child = section.querySelector<HTMLElement>(`:scope > [${attribute}]`)
|
||||
if (!child) throw new Error(`Missing ${attribute}`)
|
||||
return child
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const view = render(
|
||||
<div data-testid="viewport">
|
||||
<MenuGroup label="Provider A"><button type="button">Model A</button></MenuGroup>
|
||||
<MenuGroup label="Provider B"><button type="button">Model B</button></MenuGroup>
|
||||
<MenuGroup label="Provider C"><button type="button">Model C</button></MenuGroup>
|
||||
</div>,
|
||||
)
|
||||
const viewport = screen.getByTestId('viewport')
|
||||
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 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(<MenuGroup label="Empty provider" />)
|
||||
const group = screen.getByRole('group', { name: 'Empty provider' })
|
||||
expect(group.children).toHaveLength(2)
|
||||
expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('observeStickyMenuGroups', () => {
|
||||
it('does not synchronously read layout when observing a 30-model menu', () => {
|
||||
render(<div data-testid="large-viewport">{Array.from({ length: 6 }, (_, index) =>
|
||||
<MenuGroup key={index} label={`Provider ${index}`}>
|
||||
{Array.from({ length: 5 }, (_, row) => <button key={row} type="button">{`Model ${index}-${row}`}</button>)}
|
||||
</MenuGroup>)}</div>)
|
||||
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 addResize = vi.spyOn(window, 'addEventListener')
|
||||
const dispose = observe(viewport)
|
||||
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)
|
||||
size.notify(viewport, 180)
|
||||
intersections[2]!.notify(entry(groups[0]!, 99.75, 200, 3))
|
||||
dispose()
|
||||
for (const spy of [read, clientRects, computedStyle, offsetTop, offsetHeight, clientHeight]) {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(addScroll).not.toHaveBeenCalled()
|
||||
expect(addResize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for asynchronous size and intersection delivery, including an already-scrolled viewport', () => {
|
||||
const { viewport, groups: [first] } = fixture()
|
||||
viewport.scrollTop = 20
|
||||
observe(viewport)
|
||||
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('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(<div data-testid="viewport"><MenuGroup label="Filtered"><button type="button">Match</button></MenuGroup></div>)
|
||||
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(<div data-testid="viewport">
|
||||
<MenuGroup label="Direct"><MenuGroup label="Nested" /></MenuGroup>
|
||||
<section data-menu-group=""><span data-menu-group-start="" /><div><div data-menu-group-heading="">Indirect</div></div></section>
|
||||
<section data-menu-group=""><div data-menu-group-heading="">No start</div></section>
|
||||
<section><div data-menu-group-heading="">Unmarked</div></section>
|
||||
</div>)
|
||||
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(<div data-testid="viewport"><div><MenuGroup label="Nested" /></div><section data-menu-group="" /></div>)
|
||||
const viewport = screen.getByTestId('viewport')
|
||||
const addScroll = vi.spyOn(viewport, 'addEventListener')
|
||||
const addResize = vi.spyOn(window, 'addEventListener')
|
||||
const dispose = observe(viewport)
|
||||
expect(intersections).toHaveLength(0)
|
||||
expect(sizes).toHaveLength(0)
|
||||
expect(addScroll).not.toHaveBeenCalled()
|
||||
expect(addResize).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()
|
||||
})
|
||||
})
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 54296471349d9f77
|
||||
zh: 8ca162b7b57b822e
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation:
|
||||
en: 8fbfdb050e880f9b
|
||||
zh: dd201f049036c5c4
|
||||
en: 687ff0a8bccc9c0c
|
||||
zh: 463d6b7afd4bd4a4
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
|
||||
en: 4bbf1beae9e25e9c
|
||||
zh: 6494ac4d34b1fabb
|
||||
|
||||
@@ -44,7 +44,7 @@ When the host composition includes an HTTP server, the host half embeds the regi
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
Shared menus use `--dsw-menu-surface-fill` and blur through `MenuSurface`; platform code must preserve those token values. Other overlays consume `--dsw-specific-menu`, which keeps a nearly opaque macOS fill without a menu backing. The enforced source rules are defined in the [styling reference](../../../docs/web-styling.md#component-rules). Modal masks retain their dark translucent fill without background blur.
|
||||
Shared menus use `--dsw-menu-surface-fill` and blur through `MenuSurface`; platform code must preserve those token values. Sticky menu group headings can use `--dsw-alias-menu-group-header-fill`, a 94%-opaque light or dark fill, independently of the menu material. Other overlays consume `--dsw-specific-menu`, which keeps a nearly opaque macOS fill without a menu backing. The enforced source rules are defined in the [styling reference](../../../docs/web-styling.md#component-rules). Modal masks retain their dark translucent fill without background blur.
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
@@ -44,7 +44,7 @@ kind: "package-reference"
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
公共菜单通过 `MenuSurface` 共享 `--dsw-menu-surface-fill` 和模糊,平台代码须保留这些 token 值。其他浮层使用 `--dsw-specific-menu`,在没有菜单底层时保留 macOS 上接近不透明的填充。源码约束见[样式参考](../../../docs/web-styling.zh.md#component-rules)。 模态遮罩保留黑色半透明填充,不模糊背景。
|
||||
公共菜单通过 `MenuSurface` 共享 `--dsw-menu-surface-fill` 和模糊,平台代码须保留这些 token 值。菜单吸顶分组标题可使用 `--dsw-alias-menu-group-header-fill`,其浅/深色填充均为 94% 不透明度,独立于菜单材质。其他浮层使用 `--dsw-specific-menu`,在没有菜单底层时保留 macOS 上接近不透明的填充。源码约束见[样式参考](../../../docs/web-styling.zh.md#component-rules)。 模态遮罩保留黑色半透明填充,不模糊背景。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
@@ -269,6 +269,7 @@ body {
|
||||
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-menu-surface-fill: rgba(248, 249, 250, 0.58);
|
||||
--dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94);
|
||||
--dsw-specific-menu: var(--dsw-menu-surface-fill);
|
||||
--dsw-specific-selector: var(--dsw-static-neutral-bluish-60);
|
||||
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50);
|
||||
@@ -386,6 +387,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-900);
|
||||
--dsw-menu-surface-fill: rgba(67, 69, 74, 0.45);
|
||||
--dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94);
|
||||
--dsw-specific-menu: var(--dsw-menu-surface-fill);
|
||||
--dsw-specific-selector: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user