@fonlan/dsh-model-router 0.3.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,626 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ type FocusEvent as ReactFocusEvent,
10
+ type KeyboardEvent as ReactKeyboardEvent,
11
+ } from 'react'
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
14
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
15
+ type ClientContext = Context
16
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
17
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
18
+ import {
19
+ IconCheckOutline16,
20
+ IconChevronDownOutline14,
21
+ IconChevronRightOutline14,
22
+ IconWarningOutline16,
23
+ Toast,
24
+ } from '@deepseek-ai/dsh-client-ui-primitives'
25
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
26
+ import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
27
+ import { api, type ModelRouterState, type RouterModelView } from './api'
28
+ import { LOCALE_NS } from './locales'
29
+ import './model-seat.css'
30
+
31
+ const ROUTER_PROVIDER_ID = 'model-router'
32
+
33
+ /**
34
+ * Minimal structural ModelSelection (wire shape: provider / model /
35
+ * reasoningEffort). Declared locally to avoid pulling a new peer dependency
36
+ * for a type-only surface; structurally identical to the host wire type.
37
+ */
38
+ interface ModelSelection {
39
+ provider: string
40
+ model: string
41
+ reasoningEffort?: string
42
+ }
43
+
44
+ /** The injected business face of the composer model seat (same share the host ModelSelect receives). */
45
+ interface ModelSeatInjected {
46
+ available: boolean
47
+ directory: SnapshotStore<ModelDirectoryState>
48
+ load: () => void
49
+ select: (selection: ModelSelection) => Promise<boolean>
50
+ }
51
+
52
+ type ModelSeatProps = PropsRuntime<'conversation.input.model'>
53
+ & ModelSeatInjected
54
+ & PropsLocale<typeof LOCALE_NS>
55
+
56
+ type RouteProvider = RouterModelView['providers'][number]
57
+
58
+ /**
59
+ * The composer model seat, shadowing the host ModelSelect at a lower slot
60
+ * priority. It renders the same two-level Model / Effort menu as the host
61
+ * (model list grouped by provider, reasoning effort levels), plus a third
62
+ * Provider row that opens the model-router provider list — the former quick
63
+ * route-switcher button moved into the model picker, on par with the model
64
+ * and effort menus.
65
+ *
66
+ * Shadowing contract: the host ui-model-selection registers the
67
+ * `conversation.input.model` occupant at priority 0; this entry registers at
68
+ * priority -1, so the renderer projects this component as the single-slot
69
+ * winner while the host entry stays as the abdication fallback. Both entries
70
+ * share the same per-session ModelDirectory, so selections stay consistent
71
+ * with the /model popup and the host picker.
72
+ */
73
+ export function ModelRouterSeat({
74
+ locked,
75
+ available,
76
+ directory,
77
+ load,
78
+ select,
79
+ t,
80
+ }: ModelSeatProps): JSX.Element | null {
81
+ const directoryState = useSyncExternalStore(
82
+ onChange => directory.subscribe(onChange),
83
+ () => directory.getSnapshot(),
84
+ () => directory.getSnapshot(),
85
+ )
86
+ const current = directoryState.current
87
+
88
+ const [open, setOpen] = useState(false)
89
+ const [pane, setPane] = useState<'root' | 'model' | 'provider' | 'effort'>('root')
90
+ const lastActionRef = useRef<'load' | 'select'>('load')
91
+ const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
92
+ const toastSeq = useRef(0)
93
+ const rootRef = useRef<HTMLDivElement | null>(null)
94
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
95
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
96
+ const id = useId()
97
+
98
+ // ---- model-router state (moved from the quick route switcher) ----
99
+ const [routerState, setRouterState] = useState<ModelRouterState | null>(null)
100
+ const [routerLoading, setRouterLoading] = useState(false)
101
+ const [routerError, setRouterError] = useState<string | null>(null)
102
+ const [busyProvider, setBusyProvider] = useState<string | null>(null)
103
+
104
+ const isRouterModel = available && current?.provider === ROUTER_PROVIDER_ID
105
+ const modelId = isRouterModel ? current.model : null
106
+
107
+ const loadRouterState = useCallback(async (silent = false) => {
108
+ if (!silent) setRouterLoading(true)
109
+ try {
110
+ setRouterState(await api.state())
111
+ setRouterError(null)
112
+ } catch (cause) {
113
+ setRouterError(cause instanceof Error ? cause.message : String(cause))
114
+ } finally {
115
+ if (!silent) setRouterLoading(false)
116
+ }
117
+ }, [])
118
+
119
+ // Load the advisory directory on mount (host behavior).
120
+ useEffect(() => {
121
+ if (!available) return
122
+ load()
123
+ }, [available, load])
124
+
125
+ // Fetch the router state (and thus the global toggle) on mount too, so the
126
+ // "hide provider switching" setting applies as soon as it resolves.
127
+ useEffect(() => {
128
+ if (!available) return
129
+ void loadRouterState()
130
+ }, [available, loadRouterState])
131
+
132
+ // Reset the open pane when the selected model changes (a different model may
133
+ // no longer be a model-router route).
134
+ useEffect(() => {
135
+ setPane('root')
136
+ setBusyProvider(null)
137
+ setRouterError(null)
138
+ }, [modelId])
139
+
140
+ // Silent debounced refresh of the router state after directory refreshes
141
+ // (settings edits, adapter updates), so the provider row never shows a stale
142
+ // active provider.
143
+ const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
144
+ useEffect(() => {
145
+ if (!isRouterModel || modelId === null || directoryState.status !== 'ready') return
146
+ if (refreshTimer.current !== null) clearTimeout(refreshTimer.current)
147
+ refreshTimer.current = setTimeout(() => {
148
+ refreshTimer.current = null
149
+ void loadRouterState(true)
150
+ }, 300)
151
+ return () => {
152
+ if (refreshTimer.current !== null) clearTimeout(refreshTimer.current)
153
+ refreshTimer.current = null
154
+ }
155
+ }, [directoryState.status, isRouterModel, loadRouterState, modelId])
156
+
157
+ const routeModel = useMemo(
158
+ () => modelId === null
159
+ ? undefined
160
+ : routerState?.models.find(model => model.id === modelId),
161
+ [modelId, routerState],
162
+ )
163
+
164
+ const providers = useMemo(() => {
165
+ if (routeModel === undefined) return []
166
+ const byId = new Map(routeModel.providers.map(provider => [provider.id, provider]))
167
+ const ordered = routeModel.order
168
+ .map(id => byId.get(id))
169
+ .filter((provider): provider is RouteProvider => provider !== undefined)
170
+ const seen = new Set(ordered.map(provider => provider.id))
171
+ return [...ordered, ...routeModel.providers.filter(provider => !seen.has(provider.id))]
172
+ }, [routeModel])
173
+
174
+ const switchProvider = useCallback(async (providerId: string) => {
175
+ if (modelId === null || routeModel === undefined || routeModel.active === providerId) {
176
+ setOpen(false)
177
+ return
178
+ }
179
+ setBusyProvider(providerId)
180
+ try {
181
+ const next = await api.switchActive(modelId, providerId)
182
+ setRouterState(next)
183
+ setRouterError(null)
184
+ setOpen(false)
185
+ // Keep the host model picker description in sync immediately.
186
+ load()
187
+ } catch (cause) {
188
+ setRouterError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
189
+ } finally {
190
+ setBusyProvider(null)
191
+ }
192
+ }, [load, modelId, routeModel, t])
193
+
194
+ // ---- host ModelSelect logic (faithful copy) ----
195
+ const choices = useMemo(() => directoryState.groups.flatMap(group => group.models.map(model => ({
196
+ group,
197
+ model,
198
+ selection: {
199
+ provider: group.id,
200
+ model: model.id,
201
+ ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort },
202
+ },
203
+ }))), [directoryState.groups])
204
+ const currentChoice = choices[current === null ? -1 : choices.findIndex(c =>
205
+ c.selection.provider === current?.provider && c.selection.model === current.model)]
206
+ const reasoning = currentChoice?.model.reasoning
207
+ const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
208
+ const effortLabel = reasoning === undefined ? undefined
209
+ : effectiveEffort === undefined ? t('effortProviderDefault')
210
+ : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
211
+ const effortChoices = useMemo<Array<{ key: string; effort: string | undefined; label: string; description?: string }>>(() => reasoning === undefined ? [] : [
212
+ ...(reasoning.defaultEffort === undefined ? [{
213
+ key: 'provider-default',
214
+ effort: undefined as string | undefined,
215
+ label: t('effortProviderDefault'),
216
+ }] : []),
217
+ ...reasoning.efforts.map(effort => ({
218
+ key: `effort:${effort.id}`,
219
+ effort: effort.id,
220
+ label: effort.name,
221
+ ...effort.description === undefined ? {} : { description: effort.description },
222
+ })),
223
+ ], [reasoning, t])
224
+
225
+ const busy = directoryState.status === 'selecting'
226
+ const reload = () => {
227
+ lastActionRef.current = 'load'
228
+ load()
229
+ }
230
+
231
+ // Close on outside mousedown (host behavior).
232
+ useEffect(() => {
233
+ if (!open) return
234
+ const closeOutside = (event: MouseEvent) => {
235
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
236
+ }
237
+ document.addEventListener('mousedown', closeOutside)
238
+ return () => {
239
+ document.removeEventListener('mousedown', closeOutside)
240
+ }
241
+ }, [open])
242
+
243
+ if (!available) return null
244
+
245
+ const show = () => {
246
+ setPane('root')
247
+ setOpen(true)
248
+ reload()
249
+ void loadRouterState()
250
+ }
251
+ const close = (restoreFocus = false) => {
252
+ setOpen(false)
253
+ setPane('root')
254
+ if (restoreFocus) queueMicrotask(() => {
255
+ triggerRef.current?.focus()
256
+ })
257
+ }
258
+ const moveFocus = (offset: number) => {
259
+ const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null)
260
+ if (items.length === 0) return
261
+ const active = items.findIndex(item => item === document.activeElement)
262
+ items[(Math.max(active, 0) + offset + items.length) % items.length]?.focus()
263
+ }
264
+ const onRootKeyDown = (event: ReactKeyboardEvent) => {
265
+ if (event.key === 'Escape' && open) {
266
+ event.preventDefault()
267
+ if (pane !== 'root') setPane('root')
268
+ else close(true)
269
+ return
270
+ }
271
+ if (!open) return
272
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
273
+ event.preventDefault()
274
+ moveFocus(event.key === 'ArrowDown' ? 1 : -1)
275
+ }
276
+ }
277
+ const onBlur = (event: ReactFocusEvent) => {
278
+ if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
279
+ close()
280
+ }
281
+ const settleSelection = (accepted: boolean) => {
282
+ if (accepted) {
283
+ if (rootRef.current !== null) close(true)
284
+ return
285
+ }
286
+ const message = directory.getSnapshot().error
287
+ if (message !== null) {
288
+ toastSeq.current += 1
289
+ setToast({ seq: toastSeq.current, text: t('errorAction', { message }) })
290
+ }
291
+ }
292
+ const choose = (selection: ModelSelection) => {
293
+ if (current !== null && current.provider === selection.provider && current.model === selection.model) {
294
+ close(true)
295
+ return
296
+ }
297
+ lastActionRef.current = 'select'
298
+ select(selection).then(settleSelection)
299
+ }
300
+ const chooseEffort = (effort: string | undefined) => {
301
+ if (current === null) return
302
+ if (effectiveEffort === effort) {
303
+ close(true)
304
+ return
305
+ }
306
+ const selection: ModelSelection = {
307
+ provider: current.provider,
308
+ model: current.model,
309
+ ...effort === undefined ? {} : { reasoningEffort: effort },
310
+ }
311
+ lastActionRef.current = 'select'
312
+ select(selection).then(settleSelection)
313
+ }
314
+
315
+ const modelLabel = currentChoice?.model.name ?? t('triggerFallback')
316
+ const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
317
+ const triggerAria = currentChoice === undefined ? t('triggerSelectAria')
318
+ : effortLabel === undefined ? t('triggerAria', { model: modelLabel })
319
+ : t('triggerAriaEffort', { model: modelLabel, effort: effortLabel })
320
+
321
+ // ---- provider row visibility (the former quick-switch button conditions) ----
322
+ const activeProvider = providers.find(provider => provider.id === routeModel?.active)
323
+ const activeProviderName = activeProvider?.name ?? routeModel?.active ?? t('routeLoading')
324
+ const providerRowVisible = isRouterModel
325
+ && routerState !== null
326
+ && routerState.showQuickSwitch !== false
327
+ && routerState.providers.length >= 2
328
+ && routeModel !== undefined
329
+ && providers.length >= 2
330
+
331
+ itemRefs.current = []
332
+ let itemIndex = 0
333
+ const itemRef = () => {
334
+ const at = itemIndex++
335
+ return (node: HTMLButtonElement | null) => {
336
+ itemRefs.current[at] = node
337
+ }
338
+ }
339
+
340
+ return (
341
+ <div
342
+ ref={rootRef}
343
+ className="mr-seat-root"
344
+ onKeyDown={onRootKeyDown}
345
+ onBlur={onBlur}
346
+ >
347
+ <button
348
+ ref={triggerRef}
349
+ type="button"
350
+ className="mr-seat-trigger"
351
+ aria-label={triggerAria}
352
+ aria-haspopup="menu"
353
+ aria-expanded={open}
354
+ aria-controls={open ? `${id}-menu` : undefined}
355
+ title={triggerLabel}
356
+ disabled={locked}
357
+ onClick={() => {
358
+ if (open) close()
359
+ else show()
360
+ }}
361
+ >
362
+ <span className="mr-seat-triggerLabel">{modelLabel}</span>
363
+ {effortLabel !== undefined && (
364
+ <span className="mr-seat-triggerEffort">{effortLabel}</span>
365
+ )}
366
+ <span className={`mr-seat-chevron${open ? ' mr-seat-chevronOpen' : ''}`} aria-hidden>
367
+ <IconChevronDownOutline14 />
368
+ </span>
369
+ </button>
370
+ {open && (
371
+ <div
372
+ id={`${id}-menu`}
373
+ className="mr-seat-menu"
374
+ role="menu"
375
+ aria-label={t('menuAria')}
376
+ aria-busy={directoryState.status === 'loading' || busy}
377
+ >
378
+ {pane === 'root' && (
379
+ <>
380
+ <button
381
+ ref={itemRef()}
382
+ type="button"
383
+ role="menuitem"
384
+ className="mr-seat-cell"
385
+ onClick={() => setPane('model')}
386
+ >
387
+ <span className="mr-seat-cellLabel">{t('menuModel')}</span>
388
+ <span className="mr-seat-cellValue">{modelLabel}</span>
389
+ <span className="mr-seat-cellChevron" aria-hidden>
390
+ <IconChevronRightOutline14 />
391
+ </span>
392
+ </button>
393
+ {providerRowVisible && (
394
+ <button
395
+ ref={itemRef()}
396
+ type="button"
397
+ role="menuitem"
398
+ className="mr-seat-cell"
399
+ onClick={() => setPane('provider')}
400
+ >
401
+ <span className="mr-seat-cellLabel">{t('menuProvider')}</span>
402
+ <span className="mr-seat-cellValue">{activeProviderName}</span>
403
+ <span className="mr-seat-cellChevron" aria-hidden>
404
+ <IconChevronRightOutline14 />
405
+ </span>
406
+ </button>
407
+ )}
408
+ {reasoning !== undefined && (
409
+ <button
410
+ ref={itemRef()}
411
+ type="button"
412
+ role="menuitem"
413
+ className="mr-seat-cell"
414
+ onClick={() => setPane('effort')}
415
+ >
416
+ <span className="mr-seat-cellLabel">{t('menuEffort')}</span>
417
+ <span className="mr-seat-cellValue">{effortLabel}</span>
418
+ <span className="mr-seat-cellChevron" aria-hidden>
419
+ <IconChevronRightOutline14 />
420
+ </span>
421
+ </button>
422
+ )}
423
+ </>
424
+ )}
425
+ {pane === 'model' && (
426
+ <>
427
+ {directoryState.status === 'loading' && (
428
+ <div className="mr-seat-status">{t('statusLoading')}</div>
429
+ )}
430
+ {directoryState.error !== null && lastActionRef.current === 'load' && (
431
+ <div className="mr-seat-error">
432
+ <span>{t('errorAction', { message: directoryState.error })}</span>
433
+ <button type="button" className="mr-seat-retry" onClick={reload}>
434
+ {t('retry')}
435
+ </button>
436
+ </div>
437
+ )}
438
+ {directoryState.failures.map(failure => (
439
+ <div key={failure.id} className="mr-seat-warning">
440
+ <span>{t('warningGroupLoad', { name: failure.name, message: failure.message })}</span>
441
+ <button type="button" className="mr-seat-retry" onClick={reload}>
442
+ {t('retry')}
443
+ </button>
444
+ </div>
445
+ ))}
446
+ <div className="mr-seat-groups">
447
+ {directoryState.groups.map(group => {
448
+ const headingId = `${id}-${group.id}`
449
+ return (
450
+ <section
451
+ key={group.id}
452
+ role="group"
453
+ aria-labelledby={headingId}
454
+ className="mr-seat-group"
455
+ >
456
+ <div className="mr-seat-groupTitle" id={headingId}>
457
+ {group.name}
458
+ </div>
459
+ {group.models.map(model => {
460
+ const selected = current?.provider === group.id && current.model === model.id
461
+ return (
462
+ <button
463
+ key={model.id}
464
+ ref={itemRef()}
465
+ type="button"
466
+ role="menuitemradio"
467
+ aria-checked={selected}
468
+ className={`mr-seat-option${selected ? ' mr-seat-selected' : ''}`}
469
+ title={model.name}
470
+ disabled={busy}
471
+ onClick={() => choose({ provider: group.id, model: model.id })}
472
+ >
473
+ <span className="mr-seat-optionCopy">
474
+ <span className="mr-seat-modelName">{model.name}</span>
475
+ {model.description !== undefined && (
476
+ <span className="mr-seat-description">{model.description}</span>
477
+ )}
478
+ </span>
479
+ <span className="mr-seat-check" aria-hidden>
480
+ {selected ? <IconCheckOutline16 /> : null}
481
+ </span>
482
+ </button>
483
+ )
484
+ })}
485
+ </section>
486
+ )
487
+ })}
488
+ </div>
489
+ {directoryState.status === 'ready' && choices.length === 0 && (
490
+ <div className="mr-seat-empty">{t('emptyModels')}</div>
491
+ )}
492
+ </>
493
+ )}
494
+ {pane === 'provider' && (
495
+ <>
496
+ {routerError !== null && (
497
+ <div className="mr-seat-error">
498
+ <span>{routerError}</span>
499
+ <button
500
+ type="button"
501
+ className="mr-seat-retry"
502
+ onClick={() => {
503
+ void loadRouterState()
504
+ load()
505
+ }}
506
+ >
507
+ {t('retry')}
508
+ </button>
509
+ </div>
510
+ )}
511
+ {routeModel === undefined ? (
512
+ <div className="mr-seat-status">{t('routeLoading')}</div>
513
+ ) : (
514
+ <>
515
+ <div className="mr-seat-title">{t('routeMenuTitle', { model: modelId ?? '' })}</div>
516
+ <div className="mr-seat-groups">
517
+ {providers.map(provider => {
518
+ const selected = routeModel.active === provider.id
519
+ return (
520
+ <button
521
+ key={provider.id}
522
+ ref={itemRef()}
523
+ type="button"
524
+ role="menuitemradio"
525
+ aria-checked={selected}
526
+ className={`mr-seat-option${selected ? ' mr-seat-selected' : ''}`}
527
+ disabled={!provider.credentialConfigured || busyProvider !== null}
528
+ onClick={() => void switchProvider(provider.id)}
529
+ >
530
+ <span className="mr-seat-optionCopy">
531
+ <span className="mr-seat-modelName">{provider.name}</span>
532
+ {!provider.credentialConfigured && (
533
+ <span className="mr-seat-description">{t('noCredentialShort')}</span>
534
+ )}
535
+ </span>
536
+ <span className="mr-seat-check" aria-hidden>
537
+ {selected ? <IconCheckOutline16 /> : null}
538
+ </span>
539
+ </button>
540
+ )
541
+ })}
542
+ </div>
543
+ <div className="mr-seat-hint">{t('globalRouteHint')}</div>
544
+ </>
545
+ )}
546
+ </>
547
+ )}
548
+ {pane === 'effort' && (
549
+ <>
550
+ {directoryState.error !== null && lastActionRef.current === 'load' && (
551
+ <div className="mr-seat-error">
552
+ <span>{t('errorAction', { message: directoryState.error })}</span>
553
+ <button type="button" className="mr-seat-retry" onClick={reload}>
554
+ {t('actionReload')}
555
+ </button>
556
+ </div>
557
+ )}
558
+ {effortChoices.length === 0 ? (
559
+ <div className="mr-seat-empty">{t('emptyEfforts')}</div>
560
+ ) : effortChoices.map(level => (
561
+ <button
562
+ key={level.key}
563
+ ref={itemRef()}
564
+ type="button"
565
+ role="menuitemradio"
566
+ aria-checked={effectiveEffort === level.effort}
567
+ className={`mr-seat-option${effectiveEffort === level.effort ? ' mr-seat-selected' : ''}`}
568
+ disabled={busy}
569
+ onClick={() => chooseEffort(level.effort)}
570
+ >
571
+ <span className="mr-seat-optionCopy">
572
+ <span className="mr-seat-modelName">{level.label}</span>
573
+ {level.description !== undefined && (
574
+ <span className="mr-seat-description">{level.description}</span>
575
+ )}
576
+ </span>
577
+ <span className="mr-seat-check" aria-hidden>
578
+ {effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
579
+ </span>
580
+ </button>
581
+ ))}
582
+ </>
583
+ )}
584
+ </div>
585
+ )}
586
+ {toast !== null && (
587
+ <Toast
588
+ key={toast.seq}
589
+ text={toast.text}
590
+ icon={<IconWarningOutline16 />}
591
+ anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
592
+ onDone={() => setToast(null)}
593
+ />
594
+ )}
595
+ </div>
596
+ )
597
+ }
598
+
599
+ /** Register the model seat onto the composer's named model slot (priority -1 shadows the host seat). */
600
+ export function registerModelRouterSeat(ctx: ClientContext): void {
601
+ const t = ctx.locale.bind(LOCALE_NS)
602
+ ctx.inject(['slots', 'modelDirectories'], scope => {
603
+ const models = scope.modelDirectories
604
+ const sessions = scope.sessions
605
+ scope.slots.inject('conversation.input.model', () => scope.slots.register({
606
+ name: 'conversation.input.model',
607
+ id: 'model-router-model-seat',
608
+ priority: -1,
609
+ label: () => t('seatLabel'),
610
+ locale: LOCALE_NS,
611
+ inject: (sessionId: SessionId) => {
612
+ const directory = models.directoryFor(sessionId)
613
+ const available = sessions.subagentAddress(sessionId) === undefined
614
+ return {
615
+ available,
616
+ directory: directory.store,
617
+ load: () => {
618
+ if (available) directory.load().catch(() => {})
619
+ },
620
+ select: (selection: ModelSelection) =>
621
+ available ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false),
622
+ }
623
+ },
624
+ } as never, ModelRouterSeat as never))
625
+ })
626
+ }