diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 67eafd66cc..a4d412dffd 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -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", diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css new file mode 100644 index 0000000000..f3b044496c --- /dev/null +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -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); +} diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx new file mode 100644 index 0000000000..86068777e4 --- /dev/null +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -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(null) + const triggerRef = useRef(null) + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) + const pendingFocus = useRef(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): 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): 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): 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 ( +
+ + + {open && ( + + )} +
+ ) +} diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts new file mode 100644 index 0000000000..e6221c84c9 --- /dev/null +++ b/packages/client/ui-model/src/client/directory.ts @@ -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 = createSnapshotStore({ + 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, + 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 { + 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 { + 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 + } +} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index e83a05c7a3..f25b32d5f8 100644 Binary files a/packages/client/ui-model/src/client/index.ts and b/packages/client/ui-model/src/client/index.ts differ diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts new file mode 100644 index 0000000000..28d8d951b9 --- /dev/null +++ b/packages/client/ui-model/src/client/service.ts @@ -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 +} + +/** 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 + } +} diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts new file mode 100644 index 0000000000..fdbd1a744e --- /dev/null +++ b/packages/client/ui-model/src/client/slots.ts @@ -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 + /** 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 +} diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..d04e001bd2 --- /dev/null +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -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 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() + 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/) + }) +}) diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json index 5cefc3e6de..5e6c9a35ac 100644 --- a/packages/client/ui-model/tsconfig.json +++ b/packages/client/ui-model/tsconfig.json @@ -20,9 +20,18 @@ { "path": "../ui-command" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, + { + "path": "../ui-slots" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7031a77617..0757393b6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: