@fonlan/dsh-model-router 0.3.0 → 0.3.3

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.
@@ -16,6 +16,8 @@ import {
16
16
  resolveActive,
17
17
  setActive,
18
18
  setOrder,
19
+ sortModelsForList,
20
+ type ModelSortMode,
19
21
  type RouterConfigShape,
20
22
  } from '../shared/config.js'
21
23
  import { buildCatalog, type RouterCatalog } from './catalog.js'
@@ -33,16 +35,30 @@ export const RouterConfigSchema: z<RouterConfigShape> = z.object({
33
35
  // Schemastery object keys are input-optional, so absent is valid here.
34
36
  showQuickSwitch: z.boolean(),
35
37
  ignoreModelIdPrefix: z.boolean(),
38
+ modelSort: z.union([z.const('custom'), z.const('name'), z.const('recent')]),
39
+ modelOrder: z.array(z.string()),
40
+ recentlyUsed: z.dict(z.number()),
36
41
  })
37
42
 
38
- const EMPTY_CONFIG: RouterConfigShape = { models: {}, showQuickSwitch: true, ignoreModelIdPrefix: true }
43
+ const EMPTY_CONFIG: RouterConfigShape = {
44
+ models: {},
45
+ showQuickSwitch: true,
46
+ ignoreModelIdPrefix: true,
47
+ modelSort: 'custom',
48
+ modelOrder: [],
49
+ recentlyUsed: {},
50
+ }
39
51
 
40
52
  /** The settings-page state view served by the API. */
41
53
  export interface ModelRouterState {
42
- /** Whether the composer quick route-switcher button is enabled. */
54
+ /** Whether provider switching is shown inside the composer model picker menu. */
43
55
  showQuickSwitch: boolean
44
56
  /** Whether model ids are matched with their leading vendor/ prefix ignored. */
45
57
  ignoreModelIdPrefix: boolean
58
+ /** The model-list display order mode (custom | name | recent). */
59
+ modelSort: ModelSortMode
60
+ /** Explicit model id order for custom mode (display order). */
61
+ modelOrder: string[]
46
62
  providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
47
63
  models: Array<{
48
64
  id: string
@@ -66,9 +82,19 @@ export class ModelRouterService implements RouterFacts {
66
82
  credentialConfigured: new Map(),
67
83
  }
68
84
  private inflight: Promise<RouterCatalog> | null = null
85
+ private cachedAt = 0
69
86
  private scope: SettingsScope<RouterConfigShape> | undefined
70
87
  private registration: AdapterRegistrationHandle | undefined
71
88
 
89
+ /** How long a built catalog is considered fresh before being rebuilt. */
90
+ private readonly catalogTtlMs = 30_000
91
+
92
+ /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
93
+ private readonly usageFlushMs = 1_500
94
+ private usageDebounce: ReturnType<typeof setTimeout> | null = null
95
+ private pendingUsage: Record<string, number> = {}
96
+ private usageFlushRunning = false
97
+
72
98
  constructor(ctx: Context) {
73
99
  this.ctx = ctx
74
100
  }
@@ -95,6 +121,14 @@ export class ModelRouterService implements RouterFacts {
95
121
  }
96
122
 
97
123
  stop(): void {
124
+ // Flush any pending recent-use writes before the scope is torn down.
125
+ if (this.usageDebounce !== null) {
126
+ clearTimeout(this.usageDebounce)
127
+ this.usageDebounce = null
128
+ }
129
+ if (Object.keys(this.pendingUsage).length > 0) {
130
+ void this.flushUsage()
131
+ }
98
132
  this.registration?.()
99
133
  this.registration = undefined
100
134
  }
@@ -122,14 +156,68 @@ export class ModelRouterService implements RouterFacts {
122
156
  return this.ctx.llm
123
157
  }
124
158
 
159
+ /**
160
+ * Record one model request for the `recent` display order. Fire-and-forget
161
+ * (the adapter never awaits it): timestamps accumulate in memory and are
162
+ * persisted debounced, so a burst of requests coalesces into one settings
163
+ * write.
164
+ */
165
+ noteModelUsed(modelId: string): void {
166
+ if (this.scope === undefined) return
167
+ this.pendingUsage[modelId] = Date.now()
168
+ if (this.usageDebounce !== null) return
169
+ this.usageDebounce = setTimeout(() => {
170
+ this.usageDebounce = null
171
+ void this.flushUsage()
172
+ }, this.usageFlushMs)
173
+ }
174
+
175
+ /** Persist accumulated usage timestamps (merging with any concurrent writes). */
176
+ private async flushUsage(): Promise<void> {
177
+ if (this.usageFlushRunning) return
178
+ this.usageFlushRunning = true
179
+ const pending = this.pendingUsage
180
+ this.pendingUsage = {}
181
+ try {
182
+ if (this.scope === undefined || Object.keys(pending).length === 0) return
183
+ const current = this.config()
184
+ await this.scope.replace({
185
+ ...current,
186
+ recentlyUsed: { ...current.recentlyUsed, ...pending },
187
+ })
188
+ } catch (error) {
189
+ // Re-queue the failed batch so a transient write failure does not lose
190
+ // usage history; the next request retries it.
191
+ this.pendingUsage = { ...pending, ...this.pendingUsage }
192
+ this.ctx.logger.warn(`model-router: usage persist failed: ${error instanceof Error ? error.message : String(error)}`)
193
+ } finally {
194
+ this.usageFlushRunning = false
195
+ }
196
+ }
197
+
125
198
  // ── catalog lifecycle ────────────────────────────────────────────────────
126
199
 
127
- /** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
128
- refreshCatalog(): Promise<RouterCatalog> {
200
+ /**
201
+ * Return the live catalog, rebuilding it only when it is stale (older than
202
+ * `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
203
+ * build; a failed build keeps the last good catalog.
204
+ *
205
+ * The TTL is what keeps the composer quick-switcher snappy: every
206
+ * `state()` RPC goes through here, and without a cache each one rebuilt the
207
+ * catalog — including per-provider `listModels` network calls — which is
208
+ * what made the button gray out (routerLoading) for the whole duration.
209
+ * Topology events (`llm/adapters-updated`) and explicit user refreshes pass
210
+ * `force` so the cache never hides real changes.
211
+ */
212
+ refreshCatalog(force = false): Promise<RouterCatalog> {
213
+ if (!force && this.inflight === null && Date.now() - this.cachedAt < this.catalogTtlMs) {
214
+ return Promise.resolve(this.cached)
215
+ }
129
216
  if (this.inflight !== null) return this.inflight
130
217
  this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix)
131
218
  .then((catalog) => {
132
219
  this.cached = catalog
220
+ this.cachedAt = Date.now()
133
221
  return catalog
134
222
  })
135
223
  .catch((error) => {
@@ -142,18 +230,18 @@ export class ModelRouterService implements RouterFacts {
142
230
  return this.inflight
143
231
  }
144
232
 
145
- /** Refresh, then persist any config drift (migration, new/vanished models). */
233
+ /** Refresh (forced), then persist any config drift (migration, new/vanished models). */
146
234
  async refreshAndReconcile(): Promise<void> {
147
- const catalog = await this.refreshCatalog()
235
+ const catalog = await this.refreshCatalog(true)
148
236
  if (this.scope === undefined) return
149
237
  const current = this.config()
150
- const { models, changed } = reconcileConfig(current, catalog.models)
238
+ const { models, modelOrder, changed } = reconcileConfig(current, catalog.models)
151
239
  if (!changed) return
152
240
  try {
153
241
  await this.scope.replace({
242
+ ...current,
154
243
  models,
155
- showQuickSwitch: current.showQuickSwitch,
156
- ignoreModelIdPrefix: current.ignoreModelIdPrefix,
244
+ ...(modelOrder === undefined ? {} : { modelOrder }),
157
245
  })
158
246
  } catch (error) {
159
247
  this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
@@ -178,12 +266,14 @@ export class ModelRouterService implements RouterFacts {
178
266
  return {
179
267
  showQuickSwitch: config.showQuickSwitch,
180
268
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
269
+ modelSort: config.modelSort,
270
+ modelOrder: config.modelOrder,
181
271
  providers: catalog.providers.map(provider => ({
182
272
  id: provider.id,
183
273
  name: names.get(provider.id) ?? provider.id,
184
274
  credentialConfigured: creds.get(provider.id) ?? true,
185
275
  })),
186
- models: catalog.models.map((model) => {
276
+ models: sortModelsForList(catalog.models, config).map((model) => {
187
277
  const ids = model.providers.map(provider => provider.provider)
188
278
  const stored = config.models[model.id] ?? initialConfigFor(ids)
189
279
  const active = resolveActive(config, model.id, ids)
@@ -234,11 +324,7 @@ export class ModelRouterService implements RouterFacts {
234
324
  }
235
325
  const current = this.config()
236
326
  if (current.showQuickSwitch === value) return
237
- await this.scope.replace({
238
- models: current.models,
239
- showQuickSwitch: value,
240
- ignoreModelIdPrefix: current.ignoreModelIdPrefix,
241
- })
327
+ await this.scope.replace({ ...current, showQuickSwitch: value })
242
328
  }
243
329
 
244
330
  /**
@@ -253,23 +339,47 @@ export class ModelRouterService implements RouterFacts {
253
339
  }
254
340
  const current = this.config()
255
341
  if (current.ignoreModelIdPrefix === value) return
256
- await this.scope.replace({
257
- models: current.models,
258
- showQuickSwitch: current.showQuickSwitch,
259
- ignoreModelIdPrefix: value,
260
- })
342
+ await this.scope.replace({ ...current, ignoreModelIdPrefix: value })
261
343
  await this.refreshAndReconcile()
262
344
  }
263
345
 
346
+ /** Switch the model-list display order mode (global, persisted). */
347
+ async setModelSort(mode: ModelSortMode): Promise<void> {
348
+ if (this.scope === undefined) {
349
+ throw new Error('model-router: settings service is not available in this profile')
350
+ }
351
+ const current = this.config()
352
+ if (current.modelSort === mode) return
353
+ await this.scope.replace({ ...current, modelSort: mode })
354
+ }
355
+
356
+ /**
357
+ * Replace the explicit custom model order (global, persisted). Vanished
358
+ * ids are pruned and missing catalog models appended, so the stored order
359
+ * always covers the live catalog.
360
+ */
361
+ async setModelOrder(order: string[]): Promise<void> {
362
+ if (this.scope === undefined) {
363
+ throw new Error('model-router: settings service is not available in this profile')
364
+ }
365
+ await this.refreshCatalog()
366
+ const ids = new Set(this.cached.models.map(model => model.id))
367
+ const clean = order.filter(id => ids.has(id))
368
+ for (const model of this.cached.models) {
369
+ if (!clean.includes(model.id)) clean.push(model.id)
370
+ }
371
+ const current = this.config()
372
+ const sameOrder = clean.length === current.modelOrder.length
373
+ && clean.every((id, at) => id === current.modelOrder[at])
374
+ if (sameOrder) return
375
+ await this.scope.replace({ ...current, modelOrder: clean })
376
+ }
377
+
264
378
  private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
265
379
  if (this.scope === undefined) {
266
380
  throw new Error('model-router: settings service is not available in this profile')
267
381
  }
268
382
  const current = this.config()
269
- await this.scope.replace({
270
- models,
271
- showQuickSwitch: current.showQuickSwitch,
272
- ignoreModelIdPrefix: current.ignoreModelIdPrefix,
273
- })
383
+ await this.scope.replace({ ...current, models })
274
384
  }
275
385
  }
@@ -64,12 +64,28 @@ export interface RouterModelConfig {
64
64
  active: string
65
65
  }
66
66
 
67
+ /**
68
+ * How the router orders the model list it advertises to the host model
69
+ * picker (the `model-router` group in DSH's model selection box).
70
+ *
71
+ * - `custom`: the explicit `modelOrder` array wins; models absent from it are
72
+ * appended in catalog (first-seen) order.
73
+ * - `name`: models sorted by their display name (active provider's name),
74
+ * falling back to the merged id.
75
+ * - `recent`: models sorted by most-recently-used first (timestamp desc);
76
+ * never-used models come last in catalog order.
77
+ *
78
+ * Absent/unknown documents normalize to `custom` (the pre-existing catalog
79
+ * order, so upgrading is a no-op until the user changes it).
80
+ */
81
+ export type ModelSortMode = 'custom' | 'name' | 'recent'
82
+
67
83
  /** The whole router config document. */
68
84
  export interface RouterConfigShape {
69
85
  models: Record<string, RouterModelConfig>
70
86
  /**
71
- * Whether the quick route-switcher button is shown beside the composer
72
- * model selector (settings → 模型路由 → 显示快速路由切换按钮). Stored
87
+ * Whether the provider switching entry is shown inside the composer model
88
+ * picker menu (settings → 模型路由 → 在模型选择菜单中显示提供商切换). Stored
73
89
  * globally; absent/unknown documents normalize to `true`.
74
90
  */
75
91
  showQuickSwitch: boolean
@@ -81,6 +97,23 @@ export interface RouterConfigShape {
81
97
  * `true`.
82
98
  */
83
99
  ignoreModelIdPrefix: boolean
100
+ /**
101
+ * The model-list display order mode (settings → 模型路由 → 模型显示顺序).
102
+ * Absent/unknown documents normalize to `custom`.
103
+ */
104
+ modelSort: ModelSortMode
105
+ /**
106
+ * Explicit model id order for `custom` mode, in display order. Kept
107
+ * reconciled against the catalog (vanished ids pruned, new ids appended).
108
+ * Absent/unknown documents normalize to `[]`.
109
+ */
110
+ modelOrder: string[]
111
+ /**
112
+ * Merged model id → last-use epoch millis, for `recent` mode. Written by
113
+ * the router adapter whenever a model request streams. Absent/unknown
114
+ * documents normalize to `{}`.
115
+ */
116
+ recentlyUsed: Record<string, number>
84
117
  }
85
118
 
86
119
  export const ROUTER_PROVIDER_ID = 'model-router'
@@ -152,6 +185,59 @@ export function displayProvider(
152
185
  return merged.providers.find(p => p.provider === active) ?? merged.providers[0]
153
186
  }
154
187
 
188
+ /** The display name a merged model advertises (active provider's name). */
189
+ export function displayNameOf(
190
+ model: MergedModel,
191
+ config: RouterConfigShape,
192
+ ): string {
193
+ const ids = model.providers.map(provider => provider.provider)
194
+ const active = resolveActive(config, model.id, ids)
195
+ return displayProvider(model, active ?? undefined)?.name ?? model.id
196
+ }
197
+
198
+ /**
199
+ * Order the merged catalog the way the model picker should list it, per the
200
+ * configured `modelSort` mode. Always returns a permutation of `models`
201
+ * (never drops or duplicates entries).
202
+ *
203
+ * - `custom`: `config.modelOrder` first (in stored order), then any model not
204
+ * present in it, in catalog order.
205
+ * - `name`: display-name ascending (numeric-aware, case-insensitive), ties
206
+ * keep catalog order (stable sort).
207
+ * - `recent`: most-recently-used first; never-used models sink to the tail in
208
+ * catalog order. Ties (equal timestamps, or both unused) keep catalog
209
+ * order (stable sort).
210
+ */
211
+ export function sortModelsForList(
212
+ models: readonly MergedModel[],
213
+ config: RouterConfigShape,
214
+ ): MergedModel[] {
215
+ if (config.modelSort === 'name') {
216
+ const byName = new Map(models.map(model => [model, displayNameOf(model, config)]))
217
+ return [...models].sort((left, right) =>
218
+ byName.get(left)!.localeCompare(byName.get(right)!, undefined, { numeric: true, sensitivity: 'base' }),
219
+ )
220
+ }
221
+ if (config.modelSort === 'recent') {
222
+ const recent = config.recentlyUsed ?? {}
223
+ return [...models].sort((left, right) =>
224
+ (recent[right.id] ?? -Infinity) - (recent[left.id] ?? -Infinity),
225
+ )
226
+ }
227
+ // custom: explicit order first (deduped), then the catalog-order tail
228
+ const listed: MergedModel[] = []
229
+ const seen = new Set<string>()
230
+ for (const id of config.modelOrder ?? []) {
231
+ if (seen.has(id)) continue
232
+ const model = models.find(candidate => candidate.id === id)
233
+ if (model === undefined) continue
234
+ seen.add(id)
235
+ listed.push(model)
236
+ }
237
+ const tail = models.filter(model => !seen.has(model.id))
238
+ return [...listed, ...tail]
239
+ }
240
+
155
241
  /** Initial config for a model the user never configured: first in order wins. */
156
242
  export function initialConfigFor(providerIds: readonly string[]): RouterModelConfig | null {
157
243
  if (providerIds.length === 0) return null
@@ -256,11 +342,14 @@ function sameModels(a: Record<string, RouterModelConfig>, b: Record<string, Rout
256
342
  * for `active`, and `order` becomes the deduped union in exact-key-first
257
343
  * order. Stored keys no longer mapping to any merged model are dropped (this
258
344
  * also covers turning the flag off — split models re-initialize fresh).
345
+ *
346
+ * `modelOrder` (custom display order) is reconciled the same way: vanished
347
+ * ids are pruned, newly discovered models are appended in catalog order.
259
348
  */
260
349
  export function reconcileConfig(
261
350
  config: RouterConfigShape,
262
351
  models: readonly MergedModel[],
263
- ): { models: Record<string, RouterModelConfig>; changed: boolean } {
352
+ ): { models: Record<string, RouterModelConfig>; modelOrder: string[] | undefined; changed: boolean } {
264
353
  // stored key → merged id it belongs to (exact key wins; prefixed raw keys
265
354
  // map to their stripped merged id only while the flag is on)
266
355
  const keyToMerged = new Map<string, string>()
@@ -306,7 +395,31 @@ export function reconcileConfig(
306
395
  if (entry !== undefined) next[model.id] = entry
307
396
  }
308
397
 
309
- return { models: next, changed: !sameModels(next, config.models) }
398
+ // Reconcile the custom display order against the live catalog: prune
399
+ // vanished ids, append newly discovered models in catalog order. Documents
400
+ // that predate the field (modelOrder undefined) keep it absent — the
401
+ // service's normalizeConfig materializes it on the next write, and an old
402
+ // document must not report drift merely because the field is missing.
403
+ const storedOrder = config.modelOrder
404
+ let modelOrder: string[] | undefined
405
+ let orderChanged = false
406
+ if (storedOrder !== undefined) {
407
+ const catalogIds = new Set(models.map(model => model.id))
408
+ modelOrder = storedOrder.filter(id => catalogIds.has(id))
409
+ orderChanged = modelOrder.length !== storedOrder.length
410
+ for (const model of models) {
411
+ if (!modelOrder.includes(model.id)) {
412
+ modelOrder.push(model.id)
413
+ orderChanged = true
414
+ }
415
+ }
416
+ }
417
+
418
+ return {
419
+ models: next,
420
+ modelOrder,
421
+ changed: !sameModels(next, config.models) || orderChanged,
422
+ }
310
423
  }
311
424
 
312
425
  /** Switch a model's active provider (no-op when already active). */
@@ -354,13 +467,34 @@ export function setOrder(
354
467
  export function normalizeConfig(value: unknown): RouterConfigShape {
355
468
  const models: Record<string, RouterModelConfig> = {}
356
469
  if (typeof value !== 'object' || value === null) {
357
- return { models, showQuickSwitch: true, ignoreModelIdPrefix: true }
470
+ return { models, showQuickSwitch: true, ignoreModelIdPrefix: true, modelSort: 'custom', modelOrder: [], recentlyUsed: {} }
471
+ }
472
+ const root = value as {
473
+ models?: unknown
474
+ showQuickSwitch?: unknown
475
+ ignoreModelIdPrefix?: unknown
476
+ modelSort?: unknown
477
+ modelOrder?: unknown
478
+ recentlyUsed?: unknown
358
479
  }
359
- const root = value as { models?: unknown; showQuickSwitch?: unknown; ignoreModelIdPrefix?: unknown }
360
480
  const showQuickSwitch = root.showQuickSwitch !== false
361
481
  const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false
482
+ const modelSort: ModelSortMode = root.modelSort === 'name' || root.modelSort === 'recent'
483
+ ? root.modelSort
484
+ : 'custom'
485
+ const modelOrder = Array.isArray(root.modelOrder)
486
+ ? root.modelOrder.filter((x): x is string => typeof x === 'string')
487
+ : []
488
+ const recentlyUsed: Record<string, number> = {}
489
+ if (typeof root.recentlyUsed === 'object' && root.recentlyUsed !== null) {
490
+ for (const [id, ts] of Object.entries(root.recentlyUsed as Record<string, unknown>)) {
491
+ if (typeof ts === 'number' && Number.isFinite(ts)) recentlyUsed[id] = ts
492
+ }
493
+ }
362
494
  const raw = root.models
363
- if (typeof raw !== 'object' || raw === null) return { models, showQuickSwitch, ignoreModelIdPrefix }
495
+ if (typeof raw !== 'object' || raw === null) {
496
+ return { models, showQuickSwitch, ignoreModelIdPrefix, modelSort, modelOrder, recentlyUsed }
497
+ }
364
498
  for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
365
499
  if (typeof entry !== 'object' || entry === null) continue
366
500
  const e = entry as { order?: unknown; active?: unknown }
@@ -369,5 +503,5 @@ export function normalizeConfig(value: unknown): RouterConfigShape {
369
503
  if (active === '') continue
370
504
  models[id] = { order, active }
371
505
  }
372
- return { models, showQuickSwitch, ignoreModelIdPrefix }
506
+ return { models, showQuickSwitch, ignoreModelIdPrefix, modelSort, modelOrder, recentlyUsed }
373
507
  }
@@ -1,136 +0,0 @@
1
- /* Quick provider route control beside DSH's composer model seat. */
2
- .mr-route-anchor {
3
- /* Menu's wrapper span: never let the row crush the trigger. */
4
- flex: none;
5
- min-width: 0;
6
- }
7
-
8
- .mr-route-trigger {
9
- min-width: 0;
10
- max-width: 180px;
11
- height: 28px;
12
- display: flex;
13
- align-items: center;
14
- gap: 2px;
15
- padding: 0 6px;
16
- border: none;
17
- border-radius: 24px;
18
- color: var(--dsw-alias-label-secondary);
19
- background: transparent;
20
- cursor: pointer;
21
- font: inherit;
22
- font-size: 12px;
23
- font-weight: 500;
24
- line-height: 20px;
25
- outline: none;
26
- }
27
-
28
- .mr-route-trigger:hover:not(:disabled) {
29
- background: var(--dsw-alias-interactive-bg-hover);
30
- }
31
-
32
- .mr-route-trigger:focus-visible {
33
- box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
34
- }
35
-
36
- .mr-route-trigger:disabled {
37
- color: var(--dsw-alias-label-dimmed);
38
- cursor: default;
39
- opacity: 0.7;
40
- }
41
-
42
- .mr-route-trigger-label {
43
- min-width: 0;
44
- overflow: hidden;
45
- text-overflow: ellipsis;
46
- white-space: nowrap;
47
- }
48
-
49
- .mr-route-trigger-chevron {
50
- color: var(--dsw-alias-label-caption);
51
- flex: none;
52
- transition: transform 0.12s;
53
- }
54
-
55
- .mr-route-trigger[aria-expanded="true"] .mr-route-trigger-chevron {
56
- transform: rotate(180deg);
57
- }
58
-
59
- .mr-route-item-copy {
60
- min-width: 0;
61
- display: flex;
62
- align-items: baseline;
63
- gap: 6px;
64
- }
65
-
66
- .mr-route-item-name {
67
- min-width: 0;
68
- overflow: hidden;
69
- text-overflow: ellipsis;
70
- white-space: nowrap;
71
- }
72
-
73
- .mr-route-item-note {
74
- flex: none;
75
- color: var(--dsw-alias-label-tertiary);
76
- font-size: 11px;
77
- line-height: 16px;
78
- }
79
-
80
- /*
81
- * Mobile portrait: the composer row is an inline-size container. Keep the
82
- * fixed control row intact — attachment, permission, provider (icon-only),
83
- * context meter, send — and let the model seat absorb the remaining width.
84
- * The breakpoint matches DSH's own narrow-composer rule (<=460px).
85
- */
86
- @container (width <= 460px) {
87
- .mr-route-anchor {
88
- flex: 0 0 auto;
89
- }
90
-
91
- .mr-route-trigger {
92
- /* The trigger now carries globe + chevron, so let it size naturally
93
- instead of forcing a 28px circle that could overflow the row. */
94
- height: 28px;
95
- padding: 4px 6px;
96
- justify-content: center;
97
- border-radius: 999px;
98
- }
99
-
100
- /*
101
- * Make the composer's trailing group (the parent of our [data-slot] right
102
- * wrapper) shrinkable on narrow rows so the globe/chevron trigger never
103
- * spills left over the permission control. The model seat inside it
104
- * absorbs the compression (flex 1 1 0, min-width 0 below).
105
- */
106
- div:has(> [data-slot="conversation.input.right"]) {
107
- flex: 1 1 auto;
108
- min-width: 0;
109
- justify-content: flex-end;
110
- /* Mirror the trailing group's internal gap (12px) so the provider
111
- trigger has equal spacing to the permission button on its left and
112
- the model seat on its right. */
113
- margin-left: 12px;
114
- }
115
-
116
- .mr-route-trigger-label {
117
- display: none;
118
- }
119
-
120
- /*
121
- * The model seat: each slot is wrapped in a display:contents
122
- * [data-slot] div, so the ModelSelect root div (the real flex item of the
123
- * composer row) is [data-slot="conversation.input.model"] > *. Give it
124
- * the remaining row width; the other controls keep fixed sizes.
125
- */
126
- [data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] > * {
127
- flex: 1 1 0;
128
- min-width: 0;
129
- max-width: 100%;
130
- }
131
-
132
- [data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] [aria-haspopup="menu"] {
133
- width: 100%;
134
- max-width: 100%;
135
- }
136
- }