From 190e49356f6ba61686d6721f5b3d09b303551ef6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:27:49 +0800 Subject: [PATCH] Revive the composer model selector on the named input.model seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/ui-model/package.json | 15 +- .../src/client/ModelSelect.module.css | 199 +++++++++++++++ .../ui-model/src/client/ModelSelect.tsx | 231 ++++++++++++++++++ .../client/ui-model/src/client/directory.ts | 96 ++++++++ packages/client/ui-model/src/client/index.ts | Bin 3506 -> 5451 bytes .../client/ui-model/src/client/service.ts | 68 ++++++ packages/client/ui-model/src/client/slots.ts | 23 ++ .../ui-model/tests/browser-plugin.spec.ts | 149 +++++++++++ packages/client/ui-model/tsconfig.json | 9 + pnpm-lock.yaml | 18 ++ 10 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 packages/client/ui-model/src/client/ModelSelect.module.css create mode 100644 packages/client/ui-model/src/client/ModelSelect.tsx create mode 100644 packages/client/ui-model/src/client/directory.ts create mode 100644 packages/client/ui-model/src/client/service.ts create mode 100644 packages/client/ui-model/src/client/slots.ts create mode 100644 packages/client/ui-model/tests/browser-plugin.spec.ts 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 e83a05c7a321d9e4eb736e66f6a308f6cf25c48a..f25b32d5f8dd3ec55f5bdbd31fac6a42e3557ba9 100644 GIT binary patch literal 5451 zcmbVQO^+i<5xx6Y1k!3 zh%*QYIB;Mu9FUNZxG)!f3R1(~_zS#<$V``aXLcJ#ni<;_`QrOUWX;abMs!9m>%x}g zZE07Xt1D{Ce&ebsE!+Cu+m^PbT+=^)_A7ez<7={2*E;K|zQdF2A3dkWw&&h@kCO~m zxE3&VyQliTvIQ;oihpCF;R%Itu@fEY~ihQ)GMHGQY(3wUHYTGri`(aPE=La-G8O}=$a z;uZ$(8%qybJ+CATQ@YVYljwRn(7LU6H2%1-w(-{9o*OqS{PuiRI+%Qp(x|kiXZp+d zE7+GJcZouS^9lW`_*Ue$uR6E0?}M#(JCsN$d4_M1F~;W>yKbbSzs>mG!G6I>T4pnP zg`k|*Rk@#2)0d@|g@_)zfNsjVdubZlSriXtoasShNMMMTjrgL81+4^Jfx9Q=Wx6p_L} z{LOeYvTvmD!hz(+;LNEDL4IgfI`qxXpKAJM=ii~_3C@pZXJ>Scp7T<4W(nQ^pQDLg zF$aNB-I&jM`HgPvo-#CjUEU#-VC=H4Z~JBnPGFXAOzUj{u10UJ`$~rdu&#Sr+VssdZ=`Tvtb+!_2NKr=_ceTz5 zcE&#g6$)R;H!`iI(s`~2Pz%V{yQM2#whdr@^H^oXF2tzNhaV0y5JffMEGM53vw{5|VG@DE-JP9FB)|!C zdN`!09ws7uz`y)=kn>ofCs_|aro0cbDgC(!0_cqJ3FaIYQwq6@KS~HFW^JKyfBfYi zzI^-k@sEG`@~>a~=kLEgLhLX-&Bh{xVlEpR`jPdA&f{_YiE;rpiN>vNksLgyrCHq) z+Rt^EI=YsCDs~2&1`lzwF}&7D!}79a5xz4Gxz`z_OOWYwC1RCyZ_%>L-j!Gec$D+{ z;tCHgF<2mlp)jd{^D#)s?D%(#lylt}bQb`lrH&Kbkz>y#N{J1P=yDxt*$*lz1uqi% zC-$=*n*kPQ9qb;BBn$mu%Wv%lgwyV)A;G3YMmz>O42nJQ;yG~U;NJd0~fDnZjU=VelVVFj6t@C<9_b6)uAyPBe*D>?!7_Q!7eErxD!>_Au~*c$lk zBB;^*p^NW_F$nj-Bq3wSo`~nT2xTda6EdDI-??Z@IVyyzObGf_ zo-3qXudx(nndlDs@|edoS(#<$G?vrjI4lP<3C}`YI3yw_o!f13*@7YIewq%OJB(T0 z$>yZGdAdgA-eI(ek=$3fAz`eK2bbf7LZC>#OQ4mE-&ZRXkle#LkjH(-+#B3e)%844 z<%_z_;?*|%e}x^7nLC@Lu?7v5x)xPr3OtRv23cckk$uTs(u3GPO?7fOLWCKE%|nLG za5Kw2-Y{4$h?_w8*#wj=!QfqIfW>?lk%s%mag$QB`5u3d9r2!?^CdMKOY-9S@DtF? z3X`j>H!=uN@|cA3PJu@jPZkWzka-?tMjk>;Sav+LNa<3wYHm`naxcqzF!*@r5KAL` zz}`V)-1ppD0E&V#*^Dshz(U8TTu%P}FRBTO#DcMSSyvm(NeT8Zei2RX8p< zQ^HrUpaRNCRC-}orLY0k0>(f;$-A2Kr3o(|p7HN$(y%S!NI;MqTth70jKnt{f%oO@ z&bc>hFpk?m|JQhir-V@W5SS@ltMBQoqw-Ir3}q3~{a)G_8Z>-CmD*Uj5=?OH!>|=- z>!nL>;>Z8F&+tkEba|unfId$R6866%-Ow@X^W#TtxLAd(o@+mn*TX7S}0HNq`=MKj7!YWFsyct+q0MOdCC;O3*&HK6~M{QL>rO;`h*0m|U9WYw# dzt~IDILrC{z-eQ}3J#qug+8pv*ky;&e*v;VIY0ma literal 3506 zcmbtXPm3c(6!)A@@fL>dWK25uNOWOQ+{3cs9@l|Xx+_UJm8!O?Ix`^~@S=!EQ3Uni z!M%781i_1Y@uMt^?#-{@d#|cHo!MDNFfcp4>G%Kr-tWB`pPu&M6du}AYVcCaA}DL1 z()CiAAq?d&%8l~YZJ|U1MWCntESb*Pw4V)z zoitD{NVXML_`F;}D;{&@yl2p-7+2N@@J3XAWrGJ5Km-oT$jgx8Syy2syqD{_-oo70 zrW9_A9xVuPwK2*pA=J*mxs(;z@Q}nY{2<&?hCxoYK4@~&wh$T$5rno&SO}$SCq4Mu zEkq$v8IsOdlBSd;850X^3Wr*=C~(32TX3&8w7EOrwVmJ4Hm7oUFx+TN3tJGgI+iNJavW6*L4k0Tzq{2unOaCQyZy^^XX<@rdcap_m1LMv&)2r-rP$MKu& zx&xL&HvwC9t2t~`P;2=*TXwz1C?89=QH3O%P85Mcrr~(Rh`rTCtuSb|(|YNRPfsDK zs}kp=l*I(Bk+9GRv{*=u^98;VI1>%}Q!{fo9rqTsiSro;=Rw(Tq$bz{hu|2-5o`WC zXdYnK1Ds#R=ImtGsO{f4+411)XT5!oLwpaT3DPi}4s)hVuqj4{U!NfcTFj9*2tkn( zN*NHrn4GTHh=m(s_Kz0**6idM2pDa*%1_QXYV1B1g}lDR zL>`A>z_yNLIdMQ)mPiEB$YHh6V_wyM)u-CUk1DZc3<0ctR8wWDd9KRAu$AP>Oky{3 zBi8cpDi*1ER~u?N(!+E*h2UzO!}jwIV6UPZGO>q0e}}(*`1$IKZ?8Uk_V@R{Ts{A0 z))8+^VmAt>B2w?5Rir7oPzHV6WH2Eh?`Z93FiBN84jm^!ZRDg=-J*65XEbjR#c4E^ zfxCBa5RG-GbomME*3@P{;B5BzFa0FEQX?94eu!L#6AYUs;;tYzX++@KAQf!$OpKpSdS( zOxy|O_OK|~f7+8rA-0RrSsS2@kXOUKN;t8GB3!}*TO5;pzEb7zrrSYUzs{Q*qh#<*BCZ|;jUa0I;xWDJrlLZpLHd=trniySdU zL=aXAdut8$)eAZ=lB(DsGtnZQK=ewp7OmE5lGp(NH!RM0*12kuJ~Y?L1Z%T31@u!H zas8{M5t>suP(@R0+~&2Wr8EU`A@Hhbs-Fm_-}d7HI!-V1^!q^x`_?{|E`HPk`gfw; z+@23GWgHPO@&ewaZGHVF;;TZU@snLr;-#L`3fjN&?Ha)$mU2Ifvq&CeDl~?eM5I5W z@nmcxbS)3U +} + +/** 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: