Revive the composer model selector on the named input.model seat

The original ModelSelector returns as the conversation.input.model seat
occupant (the named single slot the composer bar renders before its primary
button), alongside — not instead of — the /model popup. Both entries are
projections of ONE per-session directory owned by the new ModelService
(ctx.models): the same session.models load, the same selectModel submit,
the same host-reported current — a switch in either entry is what the
other shows next, asserted both ways in the plugin spec.

Trigger chrome follows figma 313:14108's composer ToggleButton (13/20
medium secondary label, 4px gap, caption chevron, 28px chip height
matching the sibling Plan/Read-only selects); the menu keeps the original
upward provider-grouped single-select with keyboard navigation. The
seat honors the owner locked bit as its disabled state.
This commit is contained in:
imccyu
2026-07-27 11:27:49 +08:00
parent c132cbdb6a
commit 190e49356f
10 files changed
+806 -2

No files matched your search

+13 -2
View File
@@ -38,17 +38,28 @@
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"clsx": "^2.1.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@types/react": "~18.3.1",
"clsx": "^2.1.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
@@ -0,0 +1,199 @@
.root {
position: relative;
min-width: 0;
}
/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap,
12px caption chevron; 28px chip height matches the sibling Plan /
Read-only selects in the same tool row. */
.trigger {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 220px;
height: 28px;
padding: 0 4px 0 8px;
border: none;
border-radius: 8px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.trigger:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
display: flex;
flex-direction: column;
width: min(320px, calc(100vw - 32px));
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 6px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 14px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
.status,
.empty {
padding: 10px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.error,
.warning {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
padding: 7px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.warning {
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-state-warn-label);
}
.retry {
flex: 0 0 auto;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.groups {
min-height: 0;
overflow-y: auto;
}
.group + .group {
margin-top: 4px;
}
.groupTitle {
position: sticky;
top: 0;
z-index: 1;
padding: 5px 8px 3px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
font-weight: 500;
}
.option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 6px 8px;
border: none;
border-radius: 10px;
outline: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.option:hover:not(:disabled),
.option:focus-visible,
.selected {
background: var(--dsw-alias-interactive-bg-hover);
}
.option:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.optionCopy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.modelName {
overflow: hidden;
color: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.description,
.unlisted {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.unlisted {
color: var(--dsw-alias-state-warn-label);
}
.check {
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary);
}
@@ -0,0 +1,231 @@
/**
* ModelSelect: the composer's named model seat (`conversation.input.model`).
* Compact trigger + upward provider-grouped single-select menu, revived from
* the original PR #600 ModelSelector form. Data and submission ride the SAME
* per-session ModelDirectory as the /model popup — one shared current, one
* catalog load path, one selectModel route: a switch in either entry is what
* the other shows next.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import { IconCheckOutline16, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
type FocusPreference = 'current' | 'first' | 'last'
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory store/verbs).
* @returns the trigger and, while open, the upward menu.
*/
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
)
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const pendingFocus = useRef<FocusPreference | null>(null)
const id = useId()
const choices = useMemo(() => state.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
target: { provider: group.id, model: model.id } satisfies ModelTarget,
}))), [state.groups])
const selectedIndex = state.current === null
? -1
: choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model)
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => { load() }, [load])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
useEffect(() => {
const preference = pendingFocus.current
if (!open || preference === null || choices.length === 0) return
const index = preference === 'first'
? 0
: preference === 'last'
? choices.length - 1
: selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
pendingFocus.current = null
}, [choices.length, open, selectedIndex])
const show = (preference: FocusPreference | null = null): void => {
pendingFocus.current = preference
setOpen(true)
load()
}
const close = (restoreFocus = false): void => {
setOpen(false)
pendingFocus.current = null
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
if (choices.length === 0) return
const active = itemRefs.current.findIndex(item => item === document.activeElement)
const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0
const next = (origin + offset + choices.length) % choices.length
itemRefs.current[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus()
}
}
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
if (!open) {
show(event.key === 'ArrowDown' ? 'first' : 'last')
return
}
pendingFocus.current = 'current'
const index = selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const choose = (target: ModelTarget): void => {
if (state.current?.provider === target.provider && state.current.model === target.model) {
close(true)
return
}
void select(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const label = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
return (
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${label}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={label}
disabled={locked}
onClick={() => { open ? close() : show() }}
onKeyDown={onTriggerKeyDown}
>
<span className={css.triggerLabel}>{label}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型"
aria-busy={state.status === 'loading' || busy}
>
{state.status === 'loading' && (
<div className={css.status}></div>
)}
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} {failure.message}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
{state.groups.map((group) => {
const headingId = `${id}-${group.id}`
return (
<section role="group" aria-labelledby={headingId} className={css.group} key={group.id}>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
{group.models.map((model) => {
const index = choices.findIndex(c => c.target.provider === group.id && c.target.model === model.id)
const selected = state.current?.provider === group.id && state.current.model === model.id
return (
<button
ref={(node) => { itemRefs.current[index] = node }}
type="button"
role="menuitemradio"
aria-checked={selected}
className={clsx(css.option, selected && css.selected)}
key={model.id}
title={model.name}
disabled={busy}
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}> · </span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{state.status === 'ready' && choices.length === 0 && (
<div className={css.empty}></div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,96 @@
/**
* Per-session model directory: the ONE state both selection entries share.
* The /model popup and the composer-seat selector load through the same
* controller and submit through the same selectModel call, so the host stays
* the single fact source and the store is one shared echo — a switch made in
* either entry is what the other shows next.
*/
import type {
IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelTarget, SessionId, SessionModels,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {
/** Target the host reports for the next assembled step; null before the first load. */
current: ModelTarget | null
/** Successfully loaded provider groups (last good load). */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last load; usable groups stay usable. */
failures: readonly ModelCatalogFailure[]
/** Lifecycle of the in-flight operation. */
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Whole-request or selection failure text; null when none. */
error: string | null
}
/** One session's shared directory controller; disposed with the session scope. */
export class ModelDirectory {
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
current: null, groups: [], failures: [], status: 'idle', error: null,
})
/** Latest operation wins; an older response never overwrites a newer one. */
private generation = 0
private disposed = false
/**
* @param sessions - the session wire face (captured from the plugin's root connection).
* @param sessionId - the owning session.
*/
constructor(
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
private readonly sessionId: SessionId,
) {}
/**
* Refresh the advisory directory (both entries call this on open).
* Failure preserves the last good groups and current target.
* @returns the fresh directory value.
*/
async load(): Promise<SessionModels> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
const { result } = await this.sessions.models({ sessionId: this.sessionId })
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return result.value
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
}
const { current, groups, failures } = result.value
this.store.set({ current, groups, failures, status: 'ready', error: null })
return result.value
}
/**
* Select the complete route (both entries submit through here). Success
* updates the shared current; failure surfaces on the store and throws so
* each entry's own retry surface engages.
* @param target - provider and provider-owned model id.
*/
async select(target: ModelTarget): Promise<void> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const { result } = await this.sessions.selectModel({
sessionId: this.sessionId, provider: target.provider, model: target.model,
})
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
}
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
}
/** Scope teardown: late settlements lose write access to the store. */
dispose(): void {
this.disposed = true
}
}
Binary file not shown.
@@ -0,0 +1,68 @@
/**
* ModelService (`ctx.models`): the root owner of per-session
* {@link ModelDirectory} instances. Both selection entries (the /model popup
* and the composer model seat) resolve their session's directory through
* this service, which is what makes the dual entry one shared state.
*
* Per-session storage follows the client service pattern (SlashService /
* CommandService): a lazy service-internal map whose entry is deleted by the
* owning scope's disposer. The host `dsh-scope` ScopedLayers registry does
* not transplant here: it derives scope from the host carrier mechanism
* (object-keyed), while client scopes tag contexts with branded SessionId
* strings, and it models global+shadow named registries — this is a
* per-session singleton with no global layer to merge.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ModelDirectory } from './directory.ts'
declare module 'cordis' {
interface Context {
models: ModelService
}
}
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
/** Per-session directories; entries are deleted by their scope disposer. */
readonly directories: Map<SessionId, ModelDirectory>
}
/** The `ctx.models` session model-selection service. */
export class ModelService extends Service {
static inject = ['connection', 'sessions']
private readonly live: LiveState = { directories: new Map() }
/**
* @param ctx - owning root context (the service registers itself as `models`).
*/
constructor(ctx: Context) {
super(ctx, 'models')
}
/**
* Resolve the per-session shared directory (lazy; the scope disposer
* removes and disposes it). Unknown sessions fail loud.
* @param sessionId - the owning session.
* @returns the resident directory both entries share.
*/
directoryFor(sessionId: SessionId): ModelDirectory {
const { live } = this
const existing = live.directories.get(sessionId)
if (existing !== undefined) return existing
const sessions = this.ctx.get('sessions') as SessionsService
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`)
const connection = this.ctx.get('connection') as ConnectionHandle
const directory = new ModelDirectory(connection.api.sessions, sessionId)
live.directories.set(sessionId, directory)
actx.effect(() => () => {
directory.dispose()
live.directories.delete(sessionId)
}, 'ui-model: session directory')
return directory
}
}
@@ -0,0 +1,23 @@
/**
* 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.
*/
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
/** The session's shared directory store (same instance the /model popup reads). */
directory: SnapshotStore<ModelDirectoryState>
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
load(): void
/**
* Select a complete provider/model target through the shared route.
* @param target - target picked from one provider group.
* @returns whether the host accepted the selection.
*/
select(target: ModelTarget): Promise<boolean>
}
@@ -0,0 +1,149 @@
/**
* ui-model browser half on a real cordis Context with fake command/slots/
* connection faces and real session scopes: the plugin mounts ModelService
* as `models`, the /model contribution and the conversation.input.model
* seat both register, and BOTH entries resolve the SAME per-session
* directory through the service — a selection submitted through the seat's
* inject face is the current the popup's next options pass marks active
* (and the reverse), the one-shared-state contract of the dual entry.
* Scope disposal drops the directory (HMR safety).
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
const GROUPS = [{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
],
}]
/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
async function bench() {
const ctx = new Context()
let current: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
const calls = { models: 0, select: 0 }
ctx.provide('connection', { api: { sessions: {
models: () => {
calls.models += 1
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
},
selectModel: (payload: { provider: string; model: string }) => {
calls.select += 1
current = { provider: payload.provider, model: payload.model }
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
},
} } })
let contribution: CommandContribution | undefined
ctx.provide('command', {
register(c: CommandContribution) {
contribution = c
return () => { contribution = undefined }
},
})
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
ctx.provide('slots', {
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject })
return () => { seats.delete(options.name) }
},
})
ctx.provide('conversation', {})
const scopes = new Map<SessionId, Context>()
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await ctx.plugin(function probe() {}).await()
const mint = (key: string) => {
const handle = createScope(ctx, sid(key))
scopes.set(sid(key), handle.ctx)
return handle
}
return {
ctx, fiber, mint, calls,
contribution: () => contribution!,
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => current,
}
}
const projection = (id: string) => ({ sessionId: sid(id) })
describe('ui-model dual entry', () => {
it('registers the /model contribution and the composer model seat', async () => {
const b = await bench()
expect(b.contribution().name).toBe('model')
expect(b.contribution().ui.kind).toBe('popupSelect')
expect(b.seat().inject).toBeTypeOf('function')
})
it('popup options mark the host current active with the provider group in the detail', async () => {
const b = await bench()
b.mint('s1')
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
expect(options[1]?.active).toBeUndefined()
})
it('a seat selection is the current the popup marks active next — one shared state', async () => {
const b = await bench()
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
// Switch through the SEAT entry.
expect(await seatFace.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).toBe(true)
expect(b.hostCurrent()).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
// The POPUP's next options pass reflects it without a seat-side reload.
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
})
it('a popup selection lands on the seat store — the reverse direction of the same state', async () => {
const b = await bench()
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
await b.contribution().ui.onSelect(pro, projection('s1'))
expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
})
it('both entries share one directory instance per session, isolated across sessions', async () => {
const b = await bench()
b.mint('a')
b.mint('b')
const faceA = b.seat().inject!(sid('a'))
const faceA2 = b.seat().inject!(sid('a'))
const faceB = b.seat().inject!(sid('b'))
expect(faceA.directory).toBe(faceA2.directory)
expect(faceA.directory).not.toBe(faceB.directory)
// The service face resolves the same instance the seat inject handed out.
expect(b.ctx.models.directoryFor(sid('a')).store).toBe(faceA.directory)
})
it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => {
const b = await bench()
const first = b.mint('s1')
const face1 = b.seat().inject!(sid('s1'))
await first.fiber.dispose()
b.mint('s1')
const face2 = b.seat().inject!(sid('s1'))
expect(face2.directory).not.toBe(face1.directory)
})
it('an unknown session fails loud at the seat inject', async () => {
const b = await bench()
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
})
})
+9
View File
@@ -20,9 +20,18 @@
{
"path": "../ui-command"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
+18
View File
@@ -973,15 +973,33 @@ importers:
'@deepseek-ai/dsh-client-ui-command':
specifier: workspace:^
version: link:../ui-command
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slash':
specifier: workspace:^
version: link:../ui-slash
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@types/react':
specifier: ~18.3.1
version: 18.3.31
clsx:
specifier: ^2.1.1
version: 2.1.1
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-models:
devDependencies: