Merge pull request #6357 from deepseek-harness/worktree/model-label-slot

feat(web): add provider-aware model label slot
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-16 18:12:40 +08:00
commit 29a497ee5c
12 files changed
+351 -59

No files matched your search

+64
View File
@@ -92,6 +92,70 @@ describe('web e2e: the composer model switch is the default for later sessions',
await scaffold?.close()
})
it('exposes inline model labels with exact routes throughout the composer picker', async () => {
const trigger = page.getByRole('button', { name: /^选择模型/ })
const label = '[data-provider-id][data-model-id]'
const readLabels = (surface: Locator) => surface.locator(label).evaluateAll(nodes => nodes.map(node => ({
provider: node.getAttribute('data-provider-id'), model: node.getAttribute('data-model-id'),
name: node.textContent, slot: node.querySelector('[data-slot]')?.getAttribute('data-slot'),
inline: node.tagName === 'SPAN' && node.querySelector('div') === null,
})))
const current = {
provider: START_ROUTE, model: START_MODEL, name: 'Origin Large',
slot: 'conversation.input.model.label', inline: true,
}
await expect.poll(() => readLabels(trigger)).toEqual([current])
try {
await trigger.click()
const cell = page.getByRole('menuitem', { name: /^模型/ })
expect(await readLabels(cell)).toEqual([current])
await cell.click()
const menu = page.getByRole('menu', { name: '模型', exact: true })
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/default-model/model-labels.expected.md', import.meta.url)),
JSON.stringify(await readLabels(menu), null, 2),
webSnapshotMode(),
)
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
})
it.each(['root', 'model'])('reanchors the %s menu when a label grows and shrinks without a window resize', async (pane) => {
const viewport = page.viewportSize()!
const trigger = page.getByRole('button', { name: /^选择模型/ })
try {
await page.setViewportSize({ width: 390, height: 844 })
await trigger.click()
if (pane === 'model') await page.getByRole('menuitem', { name: /^模型/ }).click()
const surface = page.getByRole(pane === 'model' ? 'group' : 'menu', { name: '模型与推理等级', exact: true })
const label = surface.locator('[data-provider-id="origin-gateway"][data-model-id="origin-large"]')
await label.waitFor()
const original = await surface.boundingBox()
expect(original).not.toBeNull()
// A Slot descendant can resize independently of ModelSelect's props and state.
await label.evaluate((node) => {
const badge = node.ownerDocument.createElement('span')
badge.dataset.modelLabelResizeProbe = ''
badge.textContent = ' — Provider identification badge with a long channel name'
node.appendChild(badge)
})
await expect.poll(async () => (await surface.boundingBox())!.width).toBeGreaterThan(original!.width + 50)
await expect.poll(async () => {
const box = (await surface.boundingBox())!
return box.x >= 12 && box.x + box.width <= 378
}).toBe(true)
await label.locator('[data-model-label-resize-probe]').evaluate((node) => { node.remove() })
await expect.poll(async () => (await surface.boundingBox())!.width).toBeCloseTo(original!.width)
await expect.poll(async () => (await surface.boundingBox())!.x).toBeCloseTo(original!.x)
} finally {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.setViewportSize(viewport)
}
})
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()
@@ -0,0 +1,37 @@
[
{
"provider": "deepseek-official",
"model": "deepseek-v4-flash",
"name": "DeepSeek-V4-Flash",
"slot": "conversation.input.model.label",
"inline": true
},
{
"provider": "deepseek-official",
"model": "deepseek-v4-flash-vision-exp",
"name": "DeepSeek-V4-Flash-Vision-Exp",
"slot": "conversation.input.model.label",
"inline": true
},
{
"provider": "origin-gateway",
"model": "origin-large",
"name": "Origin Large",
"slot": "conversation.input.model.label",
"inline": true
},
{
"provider": "acme-gateway",
"model": "acme-large",
"name": "Acme Large",
"slot": "conversation.input.model.label",
"inline": true
},
{
"provider": "acme-gateway",
"model": "acme-small",
"name": "Acme Small",
"slot": "conversation.input.model.label",
"inline": true
}
]
+1
View File
@@ -161,6 +161,7 @@ Factory: conversation.content [ConversationContent]
│ ├─ conversation.input.permission
│ ├─ conversation.input.plan
│ ├─ conversation.input.model
│ │ └─ conversation.input.model.label
│ ├─ conversation.input.overlay
│ ├─ conversation.composer.dock
│ ├─ conversation.input.left
+1
View File
@@ -163,6 +163,7 @@ Factory: conversation.content [ConversationContent]
│ ├─ conversation.input.permission
│ ├─ conversation.input.plan
│ ├─ conversation.input.model
│ │ └─ conversation.input.model.label
│ ├─ conversation.input.overlay
│ ├─ conversation.composer.dock
│ ├─ conversation.input.left
@@ -20,6 +20,9 @@
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
en: 48f8157ffdfe6e69
zh: 64ec9a533e2aa8f4
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions:
en: bc47871cf8b61451
zh: 17d21da46604f071
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions:
en: 7f20428c747ff052
zh: 975e6de728e02fa0
@@ -45,6 +45,14 @@ Both entries group models by provider, with DeepSeek Account first and DeepSeek
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.
### Model label extensions
`conversation.input.model.label` is a session-scoped, inline `single` slot for the composer trigger (`placement: 'trigger'`), the root menu's current value (`'menu'`), and each model option (`'option'`). Its owner props provide `provider: { id, name }`, the exact catalog `model`, `placement`, and `selected`. Provider names are localized display names; match channels by `provider.id`, and identify a model by the provider/model ID pair. Connection URLs and credentials are not passed to the slot.
Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name. The trigger and root-menu value use the slot only when the selected model is present in the displayed catalog; otherwise they keep the built-in placeholder. During refresh, retained catalog entries continue to use the slot. Open menus track label size changes to stay inside the viewport. `/model` command rows use the command popup's separate rendering API.
Import this package's `/client` declarations type-only, then register through `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))`. Derive `ModelLabel` props from `PropsRuntime<'conversation.input.model.label'>`; use provider IDs to choose CSS Module classes and retain `model.name` as the text. Registration follows the declaration and contributing plugin lifetimes. Without a component replacement, styles can target the enclosing label's `data-provider-id` and `data-model-id` attributes in all three placements.
### Unroutable sessions
Catalog availability does not block sending with a saved selection; request execution reports missing credentials or unavailable models. Refreshes and refresh failures retain the last displayed selection and groups. A Host reset clears that display. Sign-out hides the account provider from the picker while preserving the saved provider/model ID and reasoning effort. Signing in restores the catalog name when that model is available again. Existing session logs remain unchanged.
@@ -45,6 +45,14 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。
### 模型名称扩展
`conversation.input.model.label` 是会话作用域的内联 `single` slot,覆盖 composer 触发按钮(`placement: 'trigger'`)、根菜单中的当前值(`'menu'`)和各模型选项(`'option'`)。owner props 提供 `provider: { id, name }`、准确的目录 `model`、`placement` 和 `selected`。提供方名称是本地化显示名称;按 `provider.id` 匹配渠道,按提供方与模型 ID 的组合识别模型。slot 不接收连接 URL 或凭据。
扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称。触发按钮和根菜单当前值仅在所选模型存在于显示目录中时调用 slot,否则保留内置占位文字。刷新期间保留的目录条目仍调用 slot。菜单打开时会跟随名称内容的尺寸变化调整位置,保持在视口内。`/model` 命令行使用命令弹窗独立的渲染 API。
以 type-only 方式导入本包的 `/client` 声明,再通过 `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))` 注册。`ModelLabel` 的 props 从 `PropsRuntime<'conversation.input.model.label'>` 派生;用提供方 ID 选择 CSS Module 类,并保留 `model.name` 作为文字。注册随声明和贡献插件的生命周期清理。不替换组件时,样式也可以在三个显示位置通过名称外层的 `data-provider-id` 和 `data-model-id` 属性定位。
### 不可路由的会话
目录可用性不会阻止使用已保存选择发送消息;请求执行负责报告凭据缺失或模型不可用。刷新及刷新失败期间保留上次显示的选择和分组。Host 重置时清空这些显示。退登后选择器隐藏账号提供方,同时保留已保存的提供方/模型 ID 和推理强度。再次登录且该模型可用时恢复目录名称。既有会话日志保持不变。
@@ -37,8 +37,8 @@ import {
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 type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelLabelOwner, ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
import { orderModelProviders } from './provider-order.ts'
@@ -62,8 +62,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect(
{ locked, available, directory, load, select, t }:
ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
{ locked, available, directory, load, select, t, renderSlot }:
ModelSelectInjected & Pick<PropsRuntime<'conversation.input.model'>, 'locked'> & PropsLocale<'model'>
& Omit<PropsRenderSlots<'conversation.input.model.label'>, 'SessionProvider'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
@@ -90,7 +91,14 @@ export function ModelSelect(
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const groups = useMemo(() => orderModelProviders(state.groups), [state.groups])
const accountProviderName = t('provider.account')
const groups = useMemo(() => orderModelProviders(state.groups).map(group => ({
...group,
provider: {
id: group.id,
name: group.id === 'deepseek-account' ? accountProviderName : group.name,
},
})), [state.groups, accountProviderName])
const choices = useMemo(() => groups.flatMap(group =>
group.models.map(model => ({
group,
@@ -205,15 +213,10 @@ export function ModelSelect(
}
}, [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
// the trigger, right edges aligned. Depends on pane and directory state
// because pane switches and async catalog loads resize the card.
/* jscpd:ignore-start -- deliberate mirror of ui-primitives useAnchoredPosition:
that hook only places from the anchor's LEFT edge, while this card aligns
right edges (x = rect.right - width), so the measure-and-clamp plumbing repeats. */
// The portal needs viewport coordinates from the trigger and the menu's
// measured size before paint. Slot content can resize without parent renders.
useLayoutEffect(() => {
if (!open) { setMenuPos(null); return }
if (!available || !open) { setMenuPos(null); return }
const place = (): void => {
/* v8 ignore next 2 -- the trigger ref is attached whenever the menu is open. */
const rect = triggerRef.current?.getBoundingClientRect()
@@ -227,17 +230,20 @@ export function ModelSelect(
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
setMenuPos({ left: x, top: y })
}
// First run measures the hidden pre-render (same commit as `open`), so
// the card lands placed before anything paints.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
const menu = menuRef.current
const observer = typeof ResizeObserver !== 'undefined' && menu !== null
? new ResizeObserver(place)
: null
if (menu !== null) observer?.observe(menu)
return () => {
observer?.disconnect()
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, pane, state, query])
/* jscpd:ignore-end */
}, [available, open, pane, state, query])
if (!available) return null
@@ -426,7 +432,16 @@ export function ModelSelect(
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
itemRefs.current = []
const renderModelLabel = (
provider: ModelLabelOwner['provider'], model: ModelLabelOwner['model'],
placement: ModelLabelOwner['placement'], selected: boolean,
) => (
<span data-provider-id={provider.id} data-model-id={model.id}>
{renderSlot('conversation.input.model.label', {
provider, model, placement, selected,
}, { fallback: model.name })}
</span>
)
let itemIndex = 0
let modelIndex = 0
const itemRef = () => {
@@ -467,7 +482,9 @@ export function ModelSelect(
}}
>
<IconDataOutlineRegular className={css.triggerIcon} size={16} />
<span className={css.triggerLabel}>{modelLabel}</span>
<span className={css.triggerLabel}>
{currentChoice === undefined ? modelLabel : renderModelLabel(currentChoice.group.provider, currentChoice.model, 'trigger', true)}
</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
{busy
? <StateDot state="ongoing" />
@@ -491,7 +508,9 @@ export function ModelSelect(
<>
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('model') }}>
<span className={css.cellLabel}>{t('menu.model')}</span>
<span className={css.cellValue}>{modelLabel}</span>
<span className={css.cellValue}>
{currentChoice === undefined ? modelLabel : renderModelLabel(currentChoice.group.provider, currentChoice.model, 'menu', true)}
</span>
<IconChevronRightOutlineRegular className={css.cellChevron} />
</button>
{reasoning !== undefined && (
@@ -560,7 +579,7 @@ export function ModelSelect(
>
{filteredGroups.map((group) => {
return (
<MenuGroup key={group.id} label={group.id === 'deepseek-account' ? t('provider.account') : group.name}>
<MenuGroup key={group.id} label={group.provider.name}>
{group.models.map((model) => {
const index = modelIndex++
const selected = state.current?.provider === group.id && state.current.model === model.id
@@ -587,7 +606,7 @@ export function ModelSelect(
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
<span className={css.modelName}>{renderModelLabel(group.provider, model, 'option', selected)}</span>
</span>
<span className={css.check}>
{pending?.provider === group.id && pending.model === model.id
@@ -33,7 +33,7 @@ import { orderModelProviders } from './provider-order.ts'
export { ModelDirectory } from './directory.ts'
export type { ModelDirectoryState } from './directory.ts'
export { ModelDirectoryResolver } from './service.ts'
export type { ModelSelectInjected } from './slots.ts'
export type { ModelLabelOwner, ModelSelectInjected } from './slots.ts'
export type { ModelKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -172,6 +172,9 @@ export function apply(ctx: ClientContext): void {
scope.slots.inject('conversation.input.model', () => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
children: {
'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true },
},
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
const available = sessions.subagentAddress(sessionId) === undefined
@@ -1,14 +1,47 @@
/**
* ModelSelect's injected face. The target 'conversation.input.model' seat is
* declared (children table) and typed by ui-conversation's composer-bar
* entry; this package only contributes the single occupant, so no SlotMap
* merge lives here.
*/
/** Composer model selection injection and inline label extension. */
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import type { ModelCatalogModel, ModelProviderGroup } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ModelDirectoryState } from './directory.ts'
/** Catalog data for a model name inside the composer's existing controls. */
export interface ModelLabelOwner {
/** Provider identity and localized display name; contains no connection configuration. */
provider: Pick<ModelProviderGroup, 'id' | 'name'>
/** The exact provider's model catalog entry. */
model: ModelCatalogModel
/** Trigger caption, root-menu current value, or selectable model row. */
placement: 'trigger' | 'menu' | 'option'
/** Whether this provider/model pair is the current selection. */
selected: boolean
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* Non-interactive inline model label; selection controls remain owner-managed.
* Without a contribution, the composer displays the catalog name.
* @example
* return {
* inject: ['slots'],
* apply(ctx) {
* ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register(
* { name: 'conversation.input.model.label' },
* ({ provider, model }) => React.createElement('span', { title: provider.name }, model.name),
* ))
* },
* }
*/
'conversation.input.model.label': {
kind: 'single'
scope: 'session'
inline: true
owner: ModelLabelOwner
}
}
}
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
/** Whether this session supports Agent-bound model inspection and selection. */
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { RemoteError, SlotTestRuntime } 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'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -22,6 +22,8 @@ const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
}
const renderLabel: ComponentProps<typeof ModelSelect>['renderSlot'] = (_name, _owner, options) => options?.fallback ?? null
const reasoning = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -76,6 +78,76 @@ beforeEach(() => {
afterEach(cleanup)
describe('ModelSelect label slot', () => {
it('distinguishes identical model names and ids by provider across all placements and restores labels on unload', async () => {
const runtime = await SlotTestRuntime.create()
onTestFinished(() => runtime.dispose())
const sessionId = SessionId('model-label')
await runtime.sessions.add({ id: sessionId })
const session = runtime.sessions.retainFor(runtime.ctx, sessionId)
const groups = ['trusted', 'external'].map(id => ({
id, name: `${id} gateway`, models: [{ id: 'same-model', name: 'Same model' }],
}))
const directory = createSnapshotStore(state({ groups, current: { provider: 'trusted', model: 'same-model' } }))
const select = vi.fn(async (selection: ModelSelection) => {
directory.update((snapshot) => { snapshot.current = selection })
return { ok: true as const, value: undefined }
})
await runtime.root.declare({
'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true },
}, ({ renderSlot, SessionProvider }) => <SessionProvider session={session}>
<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} renderSlot={renderSlot} />
</SessionProvider>)
runtime.renderRoot()
const trigger = screen.getByRole('button', { name: '选择模型,当前 Same model' })
expect(trigger.textContent).toBe('Same model')
const plugin = await runtime.mount({ inject: ['slots'], apply(ctx) {
ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({
name: 'conversation.input.model.label',
}, ({ provider, model, placement, selected }) => <span
data-testid={`${placement}-${provider.id}`}
data-selected={selected}
title={provider.name}
>{provider.id}: {model.name}</span>))
} })
expect(screen.getByTestId('trigger-trusted').textContent).toBe('trusted: Same model')
expect(screen.getByTestId('trigger-trusted').title).toBe('trusted gateway')
expect(trigger.querySelector('div')).toBeNull()
fireEvent.click(trigger)
expect(screen.getByTestId('menu-trusted').textContent).toBe('trusted: Same model')
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
expect(screen.getByTestId('option-trusted').getAttribute('data-selected')).toBe('true')
expect(screen.getByTestId('option-external').getAttribute('data-selected')).toBe('false')
const external = screen.getByRole('menuitemradio', { name: 'external: Same model' })
expect(external.querySelector('[data-provider-id="external"][data-model-id="same-model"]')).toBeTruthy()
expect(external.querySelector('div')).toBeNull()
fireEvent.keyDown(screen.getByRole('menuitemradio', { name: 'trusted: Same model' }), { key: 'ArrowDown' })
expect(document.activeElement).toBe(external)
fireEvent.keyDown(external, { key: 'Tab' })
await waitFor(() => { expect(screen.getByTestId('trigger-external')).toBeTruthy() })
expect(select).toHaveBeenCalledWith({ provider: 'external', model: 'same-model' })
await waitFor(() => { expect(document.activeElement).toBe(trigger) })
await act(async () => { await plugin.dispose() })
expect(runtime.slots.entries('conversation.input.model.label')).toHaveLength(0)
expect(trigger.textContent).toBe('Same model')
expect(trigger.querySelector('[data-provider-id="external"][data-model-id="same-model"]')).toBeTruthy()
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Same model', 'Same model'])
})
it('does not decorate loading or missing selections with a stale provider', () => {
const directory = createSnapshotStore(state({ groups: [], status: 'loading' }))
const renderSlot = vi.fn(() => null)
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} renderSlot={renderSlot} />)
expect(renderSlot).not.toHaveBeenCalled()
expect(document.querySelector('[data-provider-id]')).toBeNull()
act(() => { directory.set(state({ groups: [] })) })
expect(screen.getByRole('button', { name: '请选择模型' })).toBeTruthy()
expect(renderSlot).not.toHaveBeenCalled()
})
})
describe('ModelSelect reasoning effort', () => {
it('renders effort names without descriptions and submits the effort as part of the session selection', async () => {
const directory = createSnapshotStore<ModelDirectoryState>(state())
@@ -83,7 +155,7 @@ describe('ModelSelect reasoning effort', () => {
directory.set(state({ current: selection }))
return { ok: true as const, value: undefined }
})
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={directory}
@@ -126,7 +198,7 @@ describe('ModelSelect reasoning effort', () => {
}],
current: { provider: 'provider', model: 'model' },
}))
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={directory}
@@ -148,7 +220,7 @@ describe('ModelSelect reasoning effort', () => {
current: { provider: 'deepseek-official', model: 'removed-model' },
}))
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={directory}
@@ -173,7 +245,7 @@ describe('ModelSelect reasoning effort', () => {
it.each(['model', 'provider'])('hides the saved id and effort when the selected %s disappears', (removed) => {
const directory = createSnapshotStore(state({ retainedEffort: 'High' }))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toContain('DeepSeek-V4-Flash')
act(() => { directory.update((snapshot) => {
snapshot.groups = removed === 'provider' ? [] : snapshot.groups.map(group => ({ ...group, models: [] }))
@@ -195,7 +267,7 @@ describe('ModelSelect reasoning effort', () => {
groups: [],
status: 'loading',
}))
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={directory}
@@ -231,7 +303,7 @@ describe('ModelSelect reasoning effort', () => {
directory.set(state({ groups, status: 'error', error: 'unrelated catalog refresh' }))
return { ok: false as const, error }
})
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={directory}
@@ -278,7 +350,7 @@ describe('ModelSelect reasoning effort', () => {
}
})
})
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
const spinners = () => document.querySelectorAll('[data-state="ongoing"]')
const trigger = screen.getByRole('button', { name: /选择模型|当前/ })
@@ -307,7 +379,7 @@ describe('ModelSelect reasoning effort', () => {
directory.set(state({ status: 'selecting', pending: selection }))
return new Promise<undefined>(() => {})
})
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
@@ -323,7 +395,7 @@ describe('ModelSelect reasoning effort', () => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
try {
const { container } = render(<ModelSelect
const { container } = render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={createSnapshotStore(state())}
@@ -356,7 +428,7 @@ describe('ModelSelect reasoning effort', () => {
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available={false}
directory={createSnapshotStore(state())}
@@ -373,7 +445,7 @@ describe('ModelSelect reasoning effort', () => {
describe('ModelSelect keyboard walk', () => {
function mountOpen() {
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={createSnapshotStore(state())}
@@ -446,7 +518,7 @@ describe('ModelSelect keyboard walk', () => {
})
it('Tab with the keyboard still on the trigger enters the menu at the value in use', () => {
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={createSnapshotStore(state())}
@@ -492,7 +564,7 @@ describe('ModelSelect keyboard walk', () => {
const directory = createSnapshotStore<ModelDirectoryState>(state({
groups: [], failures: [], status: 'error', error: 'catalog down',
}))
render(<ModelSelect
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={directory}
@@ -551,7 +623,7 @@ describe('ModelSelect keyboard walk', () => {
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
render(<ModelSelect renderSlot={renderLabel}
locked={false}
available
directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
@@ -571,7 +643,7 @@ 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 }))}
render(<ModelSelect renderSlot={renderLabel} 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)
@@ -593,7 +665,7 @@ describe('ModelSelect catalog size', () => {
})
it.each([0, 1, 4, 5])('opens an unselected %i-model catalog on search or its first row', (count) => {
render(<ModelSelect locked={false} available
render(<ModelSelect renderSlot={renderLabel} 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: '请选择模型' })
@@ -608,7 +680,7 @@ describe('ModelSelect catalog size', () => {
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} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
if (checked) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const search = screen.getByRole('searchbox')
@@ -635,7 +707,7 @@ describe('ModelSelect catalog size', () => {
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
render(<ModelSelect renderSlot={renderLabel} 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: /选择模型/ })
@@ -667,7 +739,7 @@ describe('ModelSelect catalog size', () => {
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) }))}
render(<ModelSelect renderSlot={renderLabel} 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: /^模型/ }))
@@ -682,7 +754,7 @@ describe('ModelSelect catalog size', () => {
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) }))}
render(<ModelSelect renderSlot={renderLabel} 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: /^模型/ }))
@@ -698,7 +770,7 @@ describe('ModelSelect catalog size', () => {
describe('ModelSelect search', () => {
it('clears search when reopening an unselected large catalog', () => {
render(<ModelSelect locked={false} available
render(<ModelSelect renderSlot={renderLabel} 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: '请选择模型' })
@@ -718,7 +790,7 @@ describe('ModelSelect search', () => {
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} />)
render(<ModelSelect renderSlot={renderLabel} 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()
@@ -731,7 +803,7 @@ describe('ModelSelect search', () => {
const select = vi.fn()
render(<>
<button type="button">Outside</button>
<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(5) }))}
<ModelSelect renderSlot={renderLabel} locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(5) }))}
load={vi.fn()} select={select} t={t} />
</>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
@@ -758,7 +830,7 @@ describe('ModelSelect search', () => {
] },
],
}))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
render(<ModelSelect renderSlot={renderLabel} 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')
@@ -810,7 +882,7 @@ describe('ModelSelect search', () => {
{ 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} />)
render(<ModelSelect renderSlot={renderLabel} 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: /模型/ }))
@@ -856,7 +928,7 @@ describe('ModelSelect search', () => {
it('hides the inherited effort until a listed model is selected', 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} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
const trigger = screen.getByRole('button', { name: '请选择模型' })
expect(trigger.hasAttribute('disabled')).toBe(false)
await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt')
@@ -880,7 +952,7 @@ it('places account and official models before third-party models', async () => {
id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })),
}))
const directory = createSnapshotStore<ModelDirectoryState>(state({ current: null, groups }))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '请选择模型' }))
const names = screen.getAllByRole('menuitemradio').map(row => row.textContent)
expect(names).toEqual([
@@ -896,7 +968,7 @@ it.each([en, zh])('localizes the account group while preserving external names',
id, name: id === 'deepseek-account' ? 'DeepSeek Account' : 'My Gateway',
models: [{ id: 'model', name: 'Model' }],
}))
render(<ModelSelect locked={false} available
render(<ModelSelect renderSlot={renderLabel} locked={false} available
directory={createSnapshotStore(state({ current: null, groups }))}
load={vi.fn()} select={vi.fn()} t={key => key in copy ? copy[key as keyof typeof copy] : key} />)
fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] }))
@@ -910,7 +982,7 @@ it('restores the account model name after login without changing the saved route
] }]
const selected = { provider: 'deepseek-account', model: 'deepseek-flash', reasoningEffort: 'high' }
const directory = createSnapshotStore(state({ current: selected, groups, retainedEffort: 'High' }))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
render(<ModelSelect renderSlot={renderLabel} locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh')
act(() => { directory.update((snapshot) => { snapshot.groups = []; snapshot.routable = false }) })
expect(screen.getByRole('button', { name: '请选择模型' }).textContent).toBe('请选择模型')
@@ -1231,6 +1231,49 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298',
},
{
key: 'conversation.input.model.label',
kind: 'single',
scope: 'session',
summary: 'Non-interactive inline model label; selection controls remain owner-managed.',
doc: 'Non-interactive inline model label; selection controls remain owner-managed.\nWithout a contribution, the composer displays the catalog name.',
registerOptions: [],
ownerProps: [
'/** Catalog data for a model name inside the composer\'s existing controls. */\nexport interface ModelLabelOwner {\n /** Provider identity and localized display name; contains no connection configuration. */\n provider: Pick<ModelProviderGroup, \'id\' | \'name\'>\n /** The exact provider\'s model catalog entry. */\n model: ModelCatalogModel\n /** Trigger caption, root-menu current value, or selectable model row. */\n placement: \'trigger\' | \'menu\' | \'option\'\n /** Whether this provider/model pair is the current selection. */\n selected: boolean\n}',
],
ownerPropsReferences: [
'ModelCatalogModel',
'ModelProviderGroup',
],
standardProps: [
'useRemoteAccess: (action: RemoteEndpoint) => boolean',
'useResource: UseResource',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'usePanelInfo: UsePanelInfo',
'useSessions: UseSessions',
'useSessionStatus: UseSessionStatus',
'useSessionRetainInfo: UseSessionRetainInfo',
'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean',
'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean',
'useChat: UseChat',
'useConversation: UseConversation',
'useInput: SnapshotSelectorHook<InputState>',
'inputActions: InputActions',
'useSession: SessionSnapshotSelector',
'sessionId: SessionId',
'useProjection: UseProjection',
'useAccess: (action: RemoteEndpoint) => boolean',
'useTrajectory: UseTrajectory',
],
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'conversation.input.model\' (client-ui-model-selection), so it exists while that entry is mounted',
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model.label\', () => ctx.slots.register(\n { name: \'conversation.input.model.label\' },\n ({ provider, model }) => React.createElement(\'span\', { title: provider.name }, model.name),\n ))\n },\n}',
source: 'packages/client/ui-model-selection/src/client/slots.ts:36',
},
{
key: 'conversation.input.overlay',
kind: 'list',