dsh-model-flatmap 1.0.3 → 1.1.0

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.
package/client.js CHANGED
@@ -54,6 +54,31 @@ window.__ModuleLoader__.load({
54
54
  .dshmf-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
55
55
  .dshmf-triggerIcon { display: var(--dsh-composer-model-icon-display, none); flex: 0 0 auto; }
56
56
  .dshmf-triggerText { display: var(--dsh-composer-model-text-display, block); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ /* The trigger's label cell, whatever fills it: the shipped picker's own label
58
+ slot (a provider's chip, its rate, its load mark) or this plugin's fallback.
59
+ An inline-flex box keeps a chip on the text baseline instead of riding high,
60
+ and a full-width cap lets the occupant ellipsise inside the trigger rather
61
+ than widening it. */
62
+ .dshmf-triggerLabel { display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; vertical-align: baseline; }
63
+ /* Fallback chip, matching the shipped Tag's geometry (11/17, 1px 8px, 999px)
64
+ so a model the catalog badges looks the same whether a label occupant drew
65
+ it or this plugin did. */
66
+ .dshmf-triggerBadge {
67
+ flex: none; display: inline-flex; align-items: center; padding: 1px 8px;
68
+ border-radius: 999px; corner-shape: round;
69
+ font-size: 11px; line-height: 17px; font-weight: 500; white-space: nowrap;
70
+ background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary);
71
+ }
72
+ .dshmf-triggerBadge[data-tone="info"] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); color: var(--dsw-alias-state-business-primary); }
73
+ .dshmf-triggerBadge[data-tone="success"] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
74
+ .dshmf-triggerBadge[data-tone="warning"] { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); color: var(--dsw-alias-state-warn-primary); }
75
+ .dshmf-triggerBadge[data-tone="danger"] { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
76
+ .dshmf-triggerBadge[data-tone="solid"] { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); }
77
+ .dshmf-triggerBadge[data-tone="outline"] { border: 0.5px solid var(--dsw-alias-border-l4); color: var(--dsw-alias-label-tertiary); }
78
+ .dshmf-triggerBadge[data-tone="quiet"] { background: transparent; color: var(--dsw-alias-label-tertiary); }
79
+ /* A model name inside the label cell: ellipsised like the shipped trigger's,
80
+ so a long name truncates instead of pushing the chevron out. */
81
+ .dshmf-triggerName { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
82
  .dshmf-triggerEffort { flex-shrink: 1000; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-caption); }
58
83
  .dshmf-chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; }
59
84
  .dshmf-chevronOpen { transform: rotate(180deg); }
@@ -334,6 +359,66 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
334
359
  body[data-ds-dark-theme] .dshmf-footer,
335
360
  html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
336
361
 
362
+ /* Display-mode button: the footer's trailing member, pinned to the panel's
363
+ bottom-right by an auto left margin. Drawn with the panel's own segment
364
+ recipe (toned track, 2px inset, radius-xs) so it reads as the same design
365
+ system as the header grouping control and the reasoning levels, and so the
366
+ pressed state is unmistakable in both themes. The auto margin also survives
367
+ the wrap case: alone on its line it still sits right. */
368
+ .dshmf-modeBtn {
369
+ flex: 0 0 auto; margin-left: auto;
370
+ height: 22px; padding: 0 8px; border: 0; border-radius: var(--dsw-radius-xs);
371
+ background: var(--dsw-alias-interactive-bg-hover);
372
+ color: var(--dsw-alias-label-secondary);
373
+ font-family: inherit; font-size: 11px; line-height: 16px; font-weight: 400;
374
+ white-space: nowrap; cursor: pointer;
375
+ transition: color 120ms ease, background 120ms ease;
376
+ }
377
+ @media (hover: hover) { .dshmf-modeBtn:hover:not(:disabled):not([aria-pressed="true"]) { color: var(--dsw-alias-label-primary); } }
378
+ .dshmf-modeBtn[aria-pressed="true"] {
379
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
380
+ box-shadow: var(--dsw-elevation-soft); font-weight: 500;
381
+ }
382
+ .dshmf-modeBtn:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
383
+ .dshmf-modeBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
384
+
385
+ /* The bottom action that brings the panel back from native mode. It occupies
386
+ the HOST picker's own root-menu slot (conversation.input.model.options), which
387
+ renders its occupants after the Model / Effort rows. The slot hands us a bare
388
+ display: contents anchor, so the box below is entirely ours to draw: it is
389
+ deliberately NOT a menu-row clone. A row in this menu means "one of a set of
390
+ values, pick one" — this control does something else, so it separates itself
391
+ with a rule above and presents as a compact, out-lined push button.
392
+ The band spans the FULL row width (the menu already pads 4px, and a row's
393
+ hover fill spans that same width), so it carries no horizontal padding of its
394
+ own: the button's edges line up with the rows above it instead of sitting a
395
+ few pixels inside them. */
396
+ .dshmf-modeAction {
397
+ box-sizing: border-box; display: flex; align-items: center;
398
+ width: auto; min-width: 100%; margin-top: 2px; padding: 7px 0 1px;
399
+ border-top: 1px solid var(--dsw-alias-border-l2);
400
+ }
401
+ .dshmf-modeBtnRow {
402
+ box-sizing: border-box; display: flex; align-items: center; justify-content: center;
403
+ gap: 6px; width: 100%; height: 30px; padding: 0 10px;
404
+ border: 1px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-sm);
405
+ outline: none; background: transparent; color: var(--dsw-alias-label-primary);
406
+ font-family: inherit; font-size: 12px; line-height: 18px; font-weight: 500;
407
+ white-space: nowrap; cursor: pointer;
408
+ transition: background 120ms ease, border-color 120ms ease;
409
+ }
410
+ .dshmf-modeBtnRowIcon { flex: 0 0 auto; color: var(--dsw-alias-label-secondary); }
411
+ .dshmf-modeBtnRowLabel { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
412
+ .dshmf-modeBtnRow:focus-visible {
413
+ box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
414
+ }
415
+ @media (hover: hover) {
416
+ .dshmf-modeBtnRow:hover {
417
+ background: var(--dsw-alias-interactive-bg-hover);
418
+ border-color: var(--dsw-alias-border-l4);
419
+ }
420
+ }
421
+
337
422
  /* Reasoning levels: a caption and a multi-segmentation control on one line;
338
423
  the track wraps below the caption only when the row cannot fit. The
339
424
  segment visual mirrors the header's grouping control, so the panel has
@@ -439,11 +524,64 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
439
524
  const ROW_GAP = 2
440
525
  const BALANCE_THRESHOLD = 24
441
526
 
527
+ /**
528
+ * The band a re-measurement must leave before it counts as a real layout
529
+ * change (DEC-026). Two physical pixels: smaller than anything the column
530
+ * plan acts on (its smallest unit is a 26px row), larger than the
531
+ * scrollbar / flex-wrap jitter that would otherwise re-plan the panel from
532
+ * its own output.
533
+ */
534
+ const METRIC_HYSTERESIS = 2
535
+
536
+ /**
537
+ * The height quantum a measurement is snapped to (DEC-026).
538
+ *
539
+ * `getBoundingClientRect().height` and `offsetHeight` are physical pixels:
540
+ * a web font swapping in, a scrollbar appearing, or a flex container
541
+ * re-rounding can move them by a fraction of a pixel between two frames
542
+ * that render the same visual layout. Because the measured value feeds back
543
+ * into the panel's own column plan, taking such noise literally makes the
544
+ * panel oscillate. Half a pixel is below anything the layout acts on
545
+ * (the smallest thing it decides is a 26px row), so snapping both the
546
+ * comparison and the stored value to it absorbs the noise while keeping
547
+ * every real change — a wrapped footer, a shown error strip.
548
+ * @param {number} value - a raw measured height in px.
549
+ * @returns {number} the height snapped to the nearest half pixel.
550
+ */
551
+ const quantizeMetric = value => {
552
+ const numeric = Number(value)
553
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0
554
+ return Math.round(numeric * 2) / 2
555
+ }
556
+
557
+ /**
558
+ * Is a new measurement meaningfully different from the stored one (DEC-026)?
559
+ *
560
+ * Measurement feeds the panel's own column plan, and the plan feeds the
561
+ * panel's layout, so this comparison is the loop's control point: it must
562
+ * answer "no" for anything that is not a real change. A 2px band absorbs
563
+ * the one thing quantization cannot — the footer's CSS flex wrapping
564
+ * flipping back and forth across a scrollbar boundary, which moves the
565
+ * measured block by a couple of pixels in either direction but draws the
566
+ * same panel. Real transitions (a footer wrapping at all, an error strip
567
+ * appearing) move the value far outside the band.
568
+ * @param {number} previous - the stored measurement.
569
+ * @param {number} next - the freshly measured one.
570
+ * @returns {boolean} true when the difference is worth a re-plan.
571
+ */
572
+ const metricChanged = (previous, next) => {
573
+ if (previous === next) return false
574
+ return Math.abs(next - previous) >= METRIC_HYSTERESIS
575
+ }
576
+
442
577
  const PATH_CHEVRON = 'M3.2 5.6 7 9.4 10.8 5.6'
443
578
  const PATH_CLOSE = 'M4 4 10 10M10 4 4 10'
444
579
  const PATH_INFO = 'M7 6.4v4.1M7 3.7v.8'
445
580
  const PATH_SPARK = 'M7 1.6 8.3 5.7 12.4 7 8.3 8.3 7 12.4 5.7 8.3 1.6 7 5.7 5.7 7 1.6Z'
446
581
  const PATH_MODELS = 'M7 2.1 11.9 4.7 7 7.3 2.1 4.7 7 2.1Zm-4.9 5.1L7 9.8l4.9-2.6M2.1 10.3 7 12.9l4.9-2.6'
582
+ /* Four tiles: the flat panel this control returns to, so the icon names the
583
+ DESTINATION rather than repeating the model glyph above it. */
584
+ const PATH_GRID = 'M2.4 2.4h3.6v3.6H2.4zM8 2.4h3.6v3.6H8zM2.4 8h3.6v3.6H2.4zM8 8h3.6v3.6H8z'
447
585
 
448
586
  function svg(path, size, extra) {
449
587
  return h('svg', {
@@ -464,6 +602,26 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
464
602
  return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ''))
465
603
  }
466
604
 
605
+ /**
606
+ * A provider's display label, resolved from the catalog's own name.
607
+ *
608
+ * Extracted so the trigger's label seat and the panel's group headings
609
+ * cannot drift: both hand the label slot the same `provider.name`, and a
610
+ * heading that said "豆包(doubao)" while the trigger said "Doubao" would be
611
+ * two spellings of one provider on one screen.
612
+ * @param {{id: string, name: unknown}} group - one provider entry.
613
+ * @param {Function} t - the panel's own translate seat.
614
+ * @param {Function|undefined} resolveText - the host's catalog-text resolver.
615
+ * @returns {string} a display-ready label; never empty.
616
+ */
617
+ function providerLabel(group, t, resolveText) {
618
+ if (group.id === 'deepseek-account') return t('provider.account')
619
+ const resolved = resolveText === undefined ? undefined : resolveText(group.name)
620
+ if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
621
+ const fallback = fallbackText(group.name)
622
+ return fallback.trim() === '' ? group.id : fallback
623
+ }
624
+
467
625
  /**
468
626
  * Split a raw query into lowercase search tokens. Whitespace separates
469
627
  * tokens, so `mod inte` asks for `mod` AND `inte` rather than for that
@@ -1221,6 +1379,120 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1221
1379
  }
1222
1380
  }
1223
1381
 
1382
+ // ---------------------------------------------------------------------
1383
+ // Display mode — `flat` (this panel) or `native` (the Host's own picker).
1384
+ //
1385
+ // The panel can hand the composer's model seat back to whatever the Host
1386
+ // ships in `conversation.input.model`. It does that by DISPOSING its own
1387
+ // slot entry. A `single` slot keeps EVERY registration and renders only the
1388
+ // highest-priority live one, so the shipped entry underneath is elected and
1389
+ // the real, current Host component appears. Nothing is copied, imported or
1390
+ // re-mounted — which is the whole point: an upgraded DSH shows its own
1391
+ // newest picker, so this mode cannot drift out of date (DEC-027).
1392
+ //
1393
+ // The choice persists exactly like the grouping choice (origin-scoped
1394
+ // `localStorage`), and every access degrades to `flat` — a missing store
1395
+ // must never be able to leave the composer without a model control.
1396
+ // ---------------------------------------------------------------------
1397
+ const DISPLAY_MODE_KEY = 'dshmf:mode'
1398
+ const DISPLAY_MODE_DEFAULT = 'flat'
1399
+ /**
1400
+ * Read the persisted display mode, or the default when nothing valid was
1401
+ * stored (or storage is unavailable).
1402
+ * @returns `'flat'` or `'native'`.
1403
+ */
1404
+ function readDisplayMode() {
1405
+ try {
1406
+ if (typeof localStorage === 'undefined') return DISPLAY_MODE_DEFAULT
1407
+ const value = localStorage.getItem(DISPLAY_MODE_KEY)
1408
+ return value === 'flat' || value === 'native' ? value : DISPLAY_MODE_DEFAULT
1409
+ } catch {
1410
+ return DISPLAY_MODE_DEFAULT
1411
+ }
1412
+ }
1413
+ /**
1414
+ * Persist one display mode. A write failure is swallowed: the in-memory
1415
+ * value still drives the current tab.
1416
+ * @param {'flat'|'native'} value - the mode to persist.
1417
+ */
1418
+ function writeDisplayMode(value) {
1419
+ try {
1420
+ if (typeof localStorage === 'undefined') return
1421
+ localStorage.setItem(DISPLAY_MODE_KEY, value)
1422
+ } catch {
1423
+ /* storage unavailable — keep the in-memory choice only */
1424
+ }
1425
+ }
1426
+ /** Schedule a callback outside the current event dispatch. */
1427
+ const scheduleMicrotask = typeof queueMicrotask === 'function'
1428
+ ? queueMicrotask
1429
+ : callback => { Promise.resolve().then(callback) }
1430
+
1431
+ /**
1432
+ * The ONE display-mode store per plugin generation: a uSES-safe snapshot
1433
+ * (`getSnapshot` keeps referential identity until the value changes) shared
1434
+ * by the panel's mode button and the composer-row chip.
1435
+ *
1436
+ * `set` notifies `queueMicrotask`-late on purpose. The click that switches
1437
+ * the mode DISPOSES the slot entry that owns the button, so notifying
1438
+ * synchronously would mutate the slot registry in the middle of React's
1439
+ * event dispatch — the entry would vanish while its own handler is still on
1440
+ * the stack.
1441
+ * @param deps - storage accessors, injected for testability.
1442
+ * @returns the store plus an idempotent `set`.
1443
+ */
1444
+ function createDisplayModeStore(deps) {
1445
+ const normalize = value => (value === 'native' ? 'native' : DISPLAY_MODE_DEFAULT)
1446
+ let value = normalize(typeof deps.readStored === 'function' ? deps.readStored() : DISPLAY_MODE_DEFAULT)
1447
+ const listeners = new Set()
1448
+ let scheduled = false
1449
+ const flush = () => {
1450
+ scheduled = false
1451
+ for (const listener of [...listeners]) {
1452
+ try {
1453
+ listener()
1454
+ } catch {
1455
+ /* one bad subscriber must not starve the others */
1456
+ }
1457
+ }
1458
+ }
1459
+ return {
1460
+ getSnapshot: () => value,
1461
+ subscribe: listener => {
1462
+ listeners.add(listener)
1463
+ return () => { listeners.delete(listener) }
1464
+ },
1465
+ set: next => {
1466
+ const normalized = normalize(next)
1467
+ if (normalized === value) return
1468
+ value = normalized
1469
+ try {
1470
+ if (typeof deps.writeStored === 'function') deps.writeStored(value)
1471
+ } catch {
1472
+ /* storage unavailable — the in-memory value still applies */
1473
+ }
1474
+ if (scheduled) return
1475
+ scheduled = true
1476
+ scheduleMicrotask(flush)
1477
+ },
1478
+ /** Drop every subscriber (activation teardown). */
1479
+ dispose: () => {
1480
+ listeners.clear()
1481
+ scheduled = false
1482
+ },
1483
+ }
1484
+ }
1485
+ /**
1486
+ * Frozen stand-in for a screen that has no store (a degraded activation, or
1487
+ * a test double). One shared instance keeps `getSnapshot` stable, so a uSES
1488
+ * consumer never re-renders in a loop.
1489
+ */
1490
+ const FLAT_DISPLAY_MODE = {
1491
+ getSnapshot: () => DISPLAY_MODE_DEFAULT,
1492
+ subscribe: () => () => {},
1493
+ set: () => {},
1494
+ }
1495
+
1224
1496
  // ---------------------------------------------------------------------
1225
1497
  // Insufficient-balance marks are GLOBAL, not Session-scoped.
1226
1498
  //
@@ -1405,7 +1677,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1405
1677
  const listeners = new Set()
1406
1678
  /** Per-Session route of the latest request header (a route is Session-local). */
1407
1679
  const routes = new Map()
1408
- /** Per-Session stop handle for its event feed. */
1680
+ /**
1681
+ * Per-Session event feed subscription: `{ source, stop }`. The source is
1682
+ * kept beside its unsubscribe so a re-materialised Session (same id, new
1683
+ * `eventSource`) is detected and re-attached instead of silently staying
1684
+ * bound to the retired feed.
1685
+ */
1409
1686
  const stops = new Map()
1410
1687
  /** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
1411
1688
  const pinned = new Set()
@@ -1515,17 +1792,28 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1515
1792
  const applyWindow = (sessionId, window, full) => {
1516
1793
  const change = window?.change
1517
1794
  let result
1518
- if (full || change?.kind === 'replace') {
1519
- // The window is that Session's whole truth, so its route restarts and
1795
+ if (full || change?.kind === 'replace' || change?.kind === 'prepend') {
1796
+ // `replace` is that Session's whole truth, so its route restarts and
1520
1797
  // its entries are re-walked. Global watermarks keep a newer fact from
1521
1798
  // another Session from being overwritten by this replay.
1799
+ //
1800
+ // `prepend` (scrolling up) must be re-walked for the SAME reason, and
1801
+ // this is not an optimisation to skip: older history is exactly where
1802
+ // a failure lives once the turn that hit it has scrolled out of the
1803
+ // first page. Folding only the appended tail left the store blind to
1804
+ // every failure that happened before the window's oldest entry, which
1805
+ // is the common case on a long Session — the strike then never
1806
+ // appeared for the model that actually ran out. The route map is
1807
+ // Session-local and positional, so the prepended page is folded with
1808
+ // the whole window (oldest entry first) to attribute each failure to
1809
+ // the header that really preceded it.
1522
1810
  routes.delete(sessionId)
1523
1811
  result = absorb(sessionId, entriesOf(window))
1524
1812
  } else if (change?.kind === 'append') {
1525
1813
  result = absorb(sessionId, change.entries)
1526
1814
  } else {
1527
- // `prepend` pages in OLDER history and `settle-assistant` only swaps a
1528
- // live attempt for its durable form; neither can change a mark.
1815
+ // `settle-assistant` only swaps a live attempt for its durable form —
1816
+ // an Assistant message — so it cannot carry a turn/end or a header.
1529
1817
  return
1530
1818
  }
1531
1819
  if (result.stored) deps.writeStored(entries)
@@ -1535,12 +1823,23 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1535
1823
  }
1536
1824
 
1537
1825
  /**
1538
- * Attach to one Session's durable feed. Idempotent, and a no-op when the
1539
- * Session has no retained binding (an unopened Session has no feed; its
1540
- * marks can only come from storage).
1826
+ * Attach to one Session's durable feed. Idempotent for one feed, and a
1827
+ * no-op when the Session has no retained binding (an unopened Session has
1828
+ * no feed; its marks can only come from storage).
1829
+ *
1830
+ * "Already attached" has to mean "attached to THIS feed", not "attached to
1831
+ * this Session id". The Client materialises a Session scope on demand and
1832
+ * retires it when nothing retains it any more — switching to another
1833
+ * conversation and coming back, or opening a second one, drops the old
1834
+ * scope and builds a NEW Session object with a NEW `eventSource`. Keying
1835
+ * the subscription by id alone left the store listening to the retired
1836
+ * feed forever: the live failure was never folded (no strike appeared)
1837
+ * while the dead feed could still inject stale facts. So the subscription
1838
+ * is remembered together with the source it came from and replaced when
1839
+ * the identity changes.
1541
1840
  */
1542
1841
  const attachSession = sessionId => {
1543
- if (disposed || stops.has(sessionId)) return
1842
+ if (disposed) return
1544
1843
  const sessions = deps.sessionsOf()
1545
1844
  let binding
1546
1845
  try {
@@ -1551,6 +1850,19 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1551
1850
  const source = binding?.eventSource
1552
1851
  if (source === undefined || typeof source.subscribe !== 'function'
1553
1852
  || typeof source.getSnapshot !== 'function') return
1853
+ const attached = stops.get(sessionId)
1854
+ if (attached !== undefined && attached.source === source) return
1855
+ // A different feed for the same id: drop the dead subscription and
1856
+ // forget the route map, which was derived from the retired window.
1857
+ if (attached !== undefined) {
1858
+ try {
1859
+ attached.stop()
1860
+ } catch {
1861
+ /* a refused unsubscribe must not keep the new feed out */
1862
+ }
1863
+ stops.delete(sessionId)
1864
+ routes.delete(sessionId)
1865
+ }
1554
1866
  try {
1555
1867
  applyWindow(sessionId, source.getSnapshot(), true)
1556
1868
  } catch {
@@ -1565,7 +1877,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1565
1877
  }
1566
1878
  try {
1567
1879
  const stop = source.subscribe(listener)
1568
- stops.set(sessionId, typeof stop === 'function' ? stop : () => {})
1880
+ stops.set(sessionId, {
1881
+ source,
1882
+ stop: typeof stop === 'function' ? stop : () => {},
1883
+ })
1569
1884
  } catch {
1570
1885
  /* subscription refused: leave this Session unattached */
1571
1886
  }
@@ -1578,7 +1893,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1578
1893
  if (sessions === undefined) return
1579
1894
  // A client reload replaces the service; re-attach from the new one.
1580
1895
  if (sessions !== attachedSessions) {
1581
- for (const stop of stops.values()) stop()
1896
+ for (const attached of stops.values()) attached.stop()
1582
1897
  stops.clear()
1583
1898
  routes.clear()
1584
1899
  if (typeof stopList === 'function') stopList()
@@ -1605,7 +1920,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1605
1920
  for (const id of [...stops.keys()]) {
1606
1921
  if (pinned.has(id)) continue
1607
1922
  if (!ids.includes(id)) {
1608
- stops.get(id)?.()
1923
+ stops.get(id)?.stop()
1609
1924
  stops.delete(id)
1610
1925
  routes.delete(id)
1611
1926
  }
@@ -1652,7 +1967,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1652
1967
  clearTimeout(expiryTimer)
1653
1968
  expiryTimer = null
1654
1969
  }
1655
- for (const stop of stops.values()) stop()
1970
+ for (const attached of stops.values()) attached.stop()
1656
1971
  stops.clear()
1657
1972
  routes.clear()
1658
1973
  pinned.clear()
@@ -1693,6 +2008,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1693
2008
  'failure.group': '{name} 加载失败:{message}',
1694
2009
  'error.action': '模型操作失败:{message}',
1695
2010
  'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他正在运行的 DSH 后重试。',
2011
+ 'native.label': '默认样式',
2012
+ 'native.enter': '切换到默认样式',
2013
+ 'native.back': '回到平铺模型面板',
2014
+ 'native.unavailable': '默认样式当前不可用',
1696
2015
  },
1697
2016
  en: {
1698
2017
  'provider.account': 'DeepSeek Account',
@@ -1723,6 +2042,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1723
2042
  'failure.group': '{name} failed to load: {message}',
1724
2043
  'error.action': 'Model operation failed: {message}',
1725
2044
  'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.',
2045
+ 'native.label': 'Default style',
2046
+ 'native.enter': 'Switch to the default style',
2047
+ 'native.back': 'Back to the flat model panel',
2048
+ 'native.unavailable': 'The default style is unavailable right now',
1726
2049
  },
1727
2050
  }
1728
2051
 
@@ -1774,6 +2097,126 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1774
2097
  refresh: () => {},
1775
2098
  }
1776
2099
 
2100
+ /**
2101
+ * The slot whose occupant decorates a model name in the shipped picker.
2102
+ *
2103
+ * It is a `single`, session-scoped child slot declared by the shipped
2104
+ * `ModelSelect` entry. This plugin shadows that entry but never disposes it,
2105
+ * so the declaration and every contribution to it stay alive — which is
2106
+ * exactly why the trigger can draw the same occupant the native picker
2107
+ * would (DEC-030).
2108
+ */
2109
+ const LABEL_SLOT = 'conversation.input.model.label'
2110
+
2111
+ /**
2112
+ * Read the entry that currently OWNS the label cell, or undefined.
2113
+ *
2114
+ * `entriesOfSlot` is the documented "winner per cell" accessor (the same
2115
+ * one the renderer's outlet uses), so this asks the host the same question
2116
+ * the native trigger does rather than guessing at priorities. Anything
2117
+ * unexpected — an older host without the accessor, a thrown service call, a
2118
+ * slot that lost its declaration — answers undefined and the trigger falls
2119
+ * back to the catalog name, which is the shipped fallback's own behaviour.
2120
+ * @param {object|undefined} slots - the `slots` client service.
2121
+ * @returns {object|undefined} the winning StoredEntry.
2122
+ */
2123
+ function readLabelEntry(slots) {
2124
+ if (slots === undefined || slots === null || typeof slots.entriesOfSlot !== 'function') return undefined
2125
+ try {
2126
+ const winners = slots.entriesOfSlot(LABEL_SLOT)
2127
+ const entry = Array.isArray(winners) ? winners[0] : undefined
2128
+ // A component is the whole point; an entry without one (or a cell that
2129
+ // abdicated after a crash) is the same as no occupant at all.
2130
+ return entry !== undefined && typeof entry.component === 'function' ? entry : undefined
2131
+ } catch {
2132
+ return undefined
2133
+ }
2134
+ }
2135
+
2136
+ /** One shared passthrough, so a degraded seat is not a new reference per render. */
2137
+ const identityTranslate = key => String(key)
2138
+
2139
+ /** Shared empty business face, for the same reason as `identityTranslate`. */
2140
+ const EMPTY_LABEL_FACE = {}
2141
+
2142
+ /** Per (locale face, namespace, revision) translate seats, mirroring the renderer. */
2143
+ const labelSeatCache = new WeakMap()
2144
+
2145
+ /**
2146
+ * Build the locale seat a label occupant expects.
2147
+ *
2148
+ * A registered entry's `t` is synthesized by the renderer from its declared
2149
+ * `locale` namespace (`entry.locale`) through the installed locale face's
2150
+ * `bind()`. Rendering that component ourselves means synthesizing the same
2151
+ * seat; a contribution that declares no namespace gets a passthrough that
2152
+ * answers its own key, which is what the renderer's kit would omit anyway.
2153
+ * @param {object|undefined} locale - the `locale` client service.
2154
+ * @param {string|undefined} namespace - the occupant's declared namespace.
2155
+ * @returns {Function} a translate seat.
2156
+ */
2157
+ function labelSeat(locale, namespace) {
2158
+ if (typeof namespace !== 'string' || namespace === '') return identityTranslate
2159
+ if (locale === undefined || typeof locale.bind !== 'function') return identityTranslate
2160
+ try {
2161
+ // Cached per (face, namespace, revision) exactly as the renderer's own
2162
+ // `localeSeat` does. The identity matters: the occupant is a React
2163
+ // component, and a fresh `t` on every render would look like new props
2164
+ // on every paint — a memoized chip would re-render forever, and an
2165
+ // effect keyed on `t` would re-run forever.
2166
+ const revision = typeof locale.getSnapshot === 'function'
2167
+ ? locale.getSnapshot()?.revision
2168
+ : undefined
2169
+ let perNamespace = labelSeatCache.get(locale)
2170
+ if (perNamespace === undefined) {
2171
+ perNamespace = new Map()
2172
+ labelSeatCache.set(locale, perNamespace)
2173
+ }
2174
+ const cached = perNamespace.get(namespace)
2175
+ if (cached !== undefined && cached.revision === revision) return cached.t
2176
+ const bound = locale.bind(namespace)
2177
+ const t = typeof bound === 'function'
2178
+ ? (key, params) => bound(key, params)
2179
+ : identityTranslate
2180
+ perNamespace.set(namespace, { revision, t })
2181
+ return t
2182
+ } catch {
2183
+ return identityTranslate
2184
+ }
2185
+ }
2186
+
2187
+ /**
2188
+ * A `useSyncExternalStore` source over "who owns the label cell".
2189
+ *
2190
+ * The trigger has to re-read the winner when a connection plugin registers
2191
+ * or disposes — otherwise a plugin that loads a second after boot keeps its
2192
+ * chip off the trigger until a page reload. The host already publishes the
2193
+ * change channel: `slots.subscribe(key, fn)` plus a monotonic `getVersion`,
2194
+ * which is the documented uSES pairing (`SlotRegistry.getVersion`).
2195
+ *
2196
+ * A host without those accessors yields a source that never notifies and
2197
+ * always answers 0 — the trigger then simply renders whatever it read on
2198
+ * its first paint.
2199
+ * @param {object|undefined} slots - the `slots` client service.
2200
+ * @returns {{subscribe: Function, getSnapshot: Function}} a stable source.
2201
+ */
2202
+ const labelSlotSources = new WeakMap()
2203
+ function labelSlotSource(slots) {
2204
+ const noop = { subscribe: () => () => {}, getSnapshot: () => 0 }
2205
+ if (slots === undefined || slots === null || typeof slots !== 'object') return noop
2206
+ if (typeof slots.subscribe !== 'function' || typeof slots.getVersion !== 'function') return noop
2207
+ const cached = labelSlotSources.get(slots)
2208
+ if (cached !== undefined) return cached
2209
+ const source = {
2210
+ subscribe: listener => slots.subscribe(LABEL_SLOT, listener),
2211
+ getSnapshot: () => slots.getVersion(LABEL_SLOT),
2212
+ }
2213
+ labelSlotSources.set(slots, source)
2214
+ return source
2215
+ }
2216
+
2217
+ /** The source a seat uses when the face was degraded or omitted. */
2218
+ const NO_LABEL_SLOT = { subscribe: () => () => {}, getSnapshot: () => 0 }
2219
+
1777
2220
  /**
1778
2221
  * How long the pointer must rest on a truncated model name before the
1779
2222
  * marquee starts. Long enough that sweeping across the grid never starts a
@@ -1908,7 +2351,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1908
2351
  locked, sessionId, t,
1909
2352
  available, directory, load, select, resolveText, resolveDirectory,
1910
2353
  teamAvailable, setTeamMode, useProjection, useSession, useSessions,
1911
- insufficient,
2354
+ insufficient, displayMode, setDisplayMode, nativeAvailable,
1912
2355
  } = props
1913
2356
 
1914
2357
  // The store may arrive after the first render (the Session binds later),
@@ -1930,6 +2373,29 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1930
2373
  const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
1931
2374
  const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
1932
2375
 
2376
+ // The display mode is shared page state, not panel state: the composer-row
2377
+ // chip that brings the panel back reads the SAME store while this seat is
2378
+ // disposed. The fallback keeps the hook shape stable when the face was
2379
+ // degraded.
2380
+ const modeFace = displayMode ?? FLAT_DISPLAY_MODE
2381
+ const subscribeMode = useCallback(listener => modeFace.subscribe(listener), [modeFace])
2382
+ const getMode = useCallback(() => modeFace.getSnapshot(), [modeFace])
2383
+ const mode = useSyncExternalStore(subscribeMode, getMode, getMode)
2384
+ // Resolved per render: the shipped entry may register after this face was
2385
+ // built, and the button has to enable itself without a reload.
2386
+ const canGoNative = typeof nativeAvailable === 'function' && nativeAvailable() === true
2387
+ // The panel only ever renders while it OWNS the cell, so the stored mode
2388
+ // and the visible mode can disagree in exactly one case: `native` was
2389
+ // persisted but there is nothing to inherit the cell, so the controller
2390
+ // refused to step aside and this panel is still on screen. Reporting the
2391
+ // stored value then would paint the button pressed while the panel shows —
2392
+ // so report the mode that is actually in effect (DEC-027).
2393
+ const modeIsNative = mode === 'native' && canGoNative
2394
+ const switchMode = next => {
2395
+ if (typeof setDisplayMode === 'function') setDisplayMode(next)
2396
+ else modeFace.set(next)
2397
+ }
2398
+
1933
2399
  // Keep the store attached to this Session's feed while the seat is mounted.
1934
2400
  // It is shared, so unmounting must NOT dispose it — the marks belong to the
1935
2401
  // page, not to this seat.
@@ -2081,13 +2547,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2081
2547
  * @param {{id: string, name: unknown}} group - one provider entry.
2082
2548
  * @returns {string} a display-ready label; never empty.
2083
2549
  */
2084
- const providerLabelOf = group => {
2085
- if (group.id === 'deepseek-account') return t('provider.account')
2086
- const resolved = resolveText === undefined ? undefined : resolveText(group.name)
2087
- if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
2088
- const fallback = fallbackText(group.name)
2089
- return fallback.trim() === '' ? group.id : fallback
2090
- }
2550
+ const providerLabelOf = group => providerLabel(group, t, resolveText)
2091
2551
  /**
2092
2552
  * A provider whose own name or id matches the query keeps its whole
2093
2553
  * catalog: `dou` shows the `豆包(doubao)` provider with every model it
@@ -2213,27 +2673,66 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2213
2673
  const effortChoices = reasoning === undefined ? [] : reasoning.efforts
2214
2674
  const busy = state.pending !== null
2215
2675
 
2676
+ // What decides which fixed sections EXIST — the measurement effect's
2677
+ // dependency set (DEC-026). The derived members are reduced to stable
2678
+ // keys so a new array/object identity per render cannot re-arm it.
2679
+ //
2680
+ // The footer itself is ALWAYS rendered now (it carries the display-mode
2681
+ // button), so what can move its height is which of its dynamic members
2682
+ // are present — the reasoning group and the teamwork row — not whether
2683
+ // the footer exists. Naming the key after those members keeps a footer
2684
+ // that merely gains or loses one of them a real measurement, while a
2685
+ // footer whose content is unchanged stays quiet.
2686
+ const teamRowShown = teamMode !== undefined && teamAvailable()
2687
+ const sectionCount = sections.length
2688
+ const footerKey = `${effortChoices.length > 0 ? 'reason' : 'none'}:${teamRowShown ? 'team' : 'none'}`
2689
+ const errorKey = state.error === null ? '' : state.error
2690
+ const confirmOpen = confirming
2691
+ const teamErrorKey = teamFailure === null ? '' : teamFailure
2692
+
2216
2693
  // ---- measurement: probe metrics + fixed-section heights
2217
2694
  // The reasoning group and the teamwork row now share ONE footer flex
2218
2695
  // container, so measure that container once instead of the two children
2219
2696
  // separately: when they sit on the same line the two heights overlap,
2220
2697
  // and summing them would over-subtract from the grid's budget.
2698
+ //
2699
+ // This effect MUST NOT run on every render (DEC-026). `metrics.fixed`
2700
+ // feeds `availableHeight`, which decides the column plan, which changes
2701
+ // the panel's own height, which changes the next measurement: an
2702
+ // unconditional measure/setMetrics pass closes that into a feedback loop
2703
+ // and React aborts it with "Maximum update depth exceeded" (error #185).
2704
+ // A browser reaches it on its own — a web font swapping in, a scrollbar
2705
+ // appearing, a sub-pixel height drift — which is why the crash looked
2706
+ // spontaneous. Measuring is therefore tied to the inputs that can
2707
+ // actually move a section (the panel opening, the section list, the
2708
+ // locale, and the debounced replan), and the compared values are
2709
+ // QUANTIZED so sub-pixel noise can never be mistaken for a new layout.
2221
2710
  useLayoutEffect(() => {
2222
2711
  if (!open) return
2223
- const rowH = probeRowRef.current?.getBoundingClientRect().height ?? 26
2224
- const headH = probeHeadRef.current?.getBoundingClientRect().height ?? 20
2712
+ const rowH = quantizeMetric(probeRowRef.current?.getBoundingClientRect().height ?? 26)
2713
+ const headH = quantizeMetric(probeHeadRef.current?.getBoundingClientRect().height ?? 20)
2225
2714
  const heights = [headRef, errorRef, footerRef, confirmRef, teamErrorRef]
2226
- .map(ref => ref.current?.offsetHeight ?? 0)
2715
+ .map(ref => quantizeMetric(ref.current?.offsetHeight ?? 0))
2227
2716
  .filter(height => height > 0)
2228
2717
  const fixed = heights.reduce((sum, height) => sum + height, 0)
2229
2718
  const fixedCount = heights.length
2230
- setMetrics(previous => (previous.rowH === rowH && previous.headH === headH
2231
- && previous.fixed === fixed && previous.fixedCount === fixedCount
2232
- ? previous
2233
- : { ...previous, rowH, headH, fixed, fixedCount }))
2234
- })
2719
+ setMetrics(previous => (metricChanged(previous.rowH, rowH)
2720
+ || metricChanged(previous.headH, headH)
2721
+ || metricChanged(previous.fixed, fixed)
2722
+ || previous.fixedCount !== fixedCount
2723
+ ? { ...previous, rowH, headH, fixed, fixedCount }
2724
+ : previous))
2725
+ }, [open, sectionCount, footerKey, errorKey, confirmOpen, teamErrorKey, replan])
2235
2726
 
2236
2727
  // ---- placement: above the trigger, kept inside the viewport
2728
+ //
2729
+ // Like the measurement above, this must NOT re-run on every render
2730
+ // (DEC-026): `place()` writes `metrics.box`, and the panel's height —
2731
+ // which `place()` reads — is derived from `metrics`. Left unconditional,
2732
+ // a panel whose height rounds differently at the new position re-places
2733
+ // itself forever, which is the same #185 abort the measure loop caused.
2734
+ // It is therefore armed on the inputs that move the panel (opening, the
2735
+ // plan, the viewport) and observes resize/scroll directly.
2237
2736
  useLayoutEffect(() => {
2238
2737
  if (!open) {
2239
2738
  setMetrics(previous => (previous.box === null ? previous : { ...previous, box: null }))
@@ -2265,7 +2764,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2265
2764
  window.removeEventListener('resize', place)
2266
2765
  window.removeEventListener('scroll', place, true)
2267
2766
  }
2268
- })
2767
+ // `plan` is derived from these metrics, so re-placing on the metrics is
2768
+ // the same trigger one level up — and it avoids reading `plan` before
2769
+ // it is computed.
2770
+ }, [open, metrics.fixed, metrics.rowH, metrics.headH, metrics.fixedCount])
2269
2771
 
2270
2772
  // Re-plan once fonts settle and on panel resize (debounced).
2271
2773
  useEffect(() => {
@@ -2416,6 +2918,101 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2416
2918
  return { label, tone: badge.tone ?? 'neutral' }
2417
2919
  }
2418
2920
 
2921
+ // ---- the trigger's label cell (DEC-030)
2922
+ //
2923
+ // The shipped picker does not paint a model name on its trigger: it
2924
+ // renders the `conversation.input.model.label` slot and lets the winner
2925
+ // decorate the name. Connection plugins use that seam for things the
2926
+ // catalog does not carry — a promotion chip, a rate, and workbuddy's
2927
+ // load mark (one/two/three queued people, or a rocket when idle). Drawing
2928
+ // a bare name here therefore loses decoration the native trigger shows.
2929
+ //
2930
+ // This plugin cannot DECLARE that slot (the shipped entry already did, and
2931
+ // a second declaration throws), but it does not need to: the declaration
2932
+ // outlives our shadowing entry, so `entriesOfSlot` still names its owner
2933
+ // and that owner's component can be rendered directly with the seats it
2934
+ // expects. The occupant is third-party code, so the whole cell sits behind
2935
+ // its own error boundary — a crashing chip must cost the chip, never the
2936
+ // seat.
2937
+ const labelSlot = props.labelSlot ?? NO_LABEL_SLOT
2938
+ const subscribeLabel = useCallback(listener => labelSlot.subscribe(listener), [labelSlot])
2939
+ const getLabelVersion = useCallback(() => labelSlot.getSnapshot(), [labelSlot])
2940
+ useSyncExternalStore(subscribeLabel, getLabelVersion, getLabelVersion)
2941
+ // Re-read on every render: the winner can change without the version
2942
+ // changing (a disposed entry re-elected to its predecessor in the same
2943
+ // microtask batch), and the read is a map lookup.
2944
+ const labelEntry = typeof props.labelEntry === 'function'
2945
+ ? derive('reading the model label slot', () => props.labelEntry(), undefined)
2946
+ : undefined
2947
+ /**
2948
+ * The occupant's own props: the shipped owner share, exactly as the native
2949
+ * picker passes it. Memoized on the identity of what it names, so the
2950
+ * occupant does not see a "new" provider/model object on every paint.
2951
+ */
2952
+ const labelOwnerProps = useMemo(() => {
2953
+ if (currentModel === null || currentModel === undefined) return undefined
2954
+ const group = currentModel.group
2955
+ return {
2956
+ provider: {
2957
+ id: group.id,
2958
+ // The catalog's own name, unresolved: the occupant resolves it with
2959
+ // its own locale seat, exactly as it does inside the native picker.
2960
+ name: group.id === 'deepseek-account' ? t('provider.account') : group.name,
2961
+ },
2962
+ model: currentModel.model,
2963
+ placement: 'trigger',
2964
+ selected: true,
2965
+ }
2966
+ }, [currentModel, t])
2967
+ /**
2968
+ * The occupant's props, assembled once per (entry, model, label text).
2969
+ *
2970
+ * The identity has to be stable across renders: the occupant is an
2971
+ * ordinary React component, and a fresh props object on every paint makes
2972
+ * a memoized one re-render forever. `entry.inject(sessionId)` is the
2973
+ * expensive half (it reads services and builds a face), so it is part of
2974
+ * the same memo rather than a separate call per render.
2975
+ */
2976
+ const labelNode = useMemo(() => {
2977
+ if (labelEntry === undefined || labelOwnerProps === undefined) return undefined
2978
+ const Occupant = labelEntry.component
2979
+ const face = typeof props.labelFace === 'function'
2980
+ ? derive('building the model label face', () => props.labelFace(labelEntry), {})
2981
+ : EMPTY_LABEL_FACE
2982
+ const translate = typeof props.labelSeatFor === 'function'
2983
+ ? derive('binding the model label locale', () => props.labelSeatFor(labelEntry.locale), identityTranslate)
2984
+ : identityTranslate
2985
+ return h(LabelBoundary, {
2986
+ // Keyed by the winner, so a change of occupant remounts rather than
2987
+ // handing one plugin's component another plugin's state.
2988
+ key: `label-${labelEntry.options?.id ?? labelEntry.registrant ?? 'entry'}`,
2989
+ fallback: undefined,
2990
+ }, h(Occupant, { ...face, t: translate, ...labelOwnerProps }))
2991
+ }, [labelEntry, labelOwnerProps, props.labelFace, props.labelSeatFor])
2992
+
2993
+ /**
2994
+ * What the trigger draws when no label occupant answered: the catalog
2995
+ * name, plus the model's own `badge` chip.
2996
+ *
2997
+ * This is the shipped picker's own fallback (name + `Tag`), reproduced
2998
+ * rather than imported — the host's `Tag` lives in a primitives package
2999
+ * this plugin does not depend on, and the chip's geometry is four
3000
+ * declarations. Drawing the badge here matters even with an occupant
3001
+ * installed: an occupant that wins the cell but does not reproduce a
3002
+ * sibling's `model.badge` (or no occupant at all) would otherwise lose the
3003
+ * promotion marker the native trigger shows.
3004
+ */
3005
+ const triggerFallbackLabel = () => {
3006
+ const badge = triggerModel === undefined ? null : badgeOf(triggerModel)
3007
+ if (badge === null) return h('span', { className: 'dshmf-triggerName' }, modelLabel)
3008
+ return h('span', { className: 'dshmf-triggerLabel' },
3009
+ // The shipped fallback puts the badge BEFORE the name on the trigger
3010
+ // and after it in menus; the trigger is what this draws.
3011
+ h('span', { className: 'dshmf-triggerBadge', 'data-tone': badge.tone }, badge.label),
3012
+ h('span', { className: 'dshmf-triggerName' }, modelLabel),
3013
+ )
3014
+ }
3015
+
2419
3016
  /**
2420
3017
  * Split a model name into React children, lifting any "xN" multiplier
2421
3018
  * marker (rate/size tags like "x0.01", "x1.5", "X 2.2") into a smaller,
@@ -2665,7 +3262,8 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2665
3262
  },
2666
3263
  },
2667
3264
  svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
2668
- h('span', { className: 'dshmf-triggerText' }, modelLabel),
3265
+ h('span', { className: 'dshmf-triggerText' },
3266
+ labelNode === undefined ? triggerFallbackLabel() : labelNode),
2669
3267
  effortLabel === undefined ? null : h('span', { className: 'dshmf-triggerEffort' }, effortLabel),
2670
3268
  busy
2671
3269
  ? h('span', { className: 'dshmf-spin' })
@@ -2760,15 +3358,14 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2760
3358
  state.failures.length === 0 ? null : state.failures.map(renderFailure),
2761
3359
  ),
2762
3360
 
2763
- // ---- fixed: reasoning levels of the current model, rendered as
2764
- // one labelled group of mutually exclusive choices
2765
- // ---- fixed, bottom: the agent teamwork switch. `teamAvailable`
2766
- // is evaluated per render because the remote scope may bind late.
2767
- // Both live in ONE footer flex container: they share a line when
2768
- // they fit with the 30px gap, otherwise each takes its own line.
2769
- (effortChoices.length === 0 && (teamMode === undefined || !teamAvailable()))
2770
- ? null
2771
- : h('div', { className: 'dshmf-footer', ref: footerRef },
3361
+ // ---- fixed, bottom: the adaptive footer.
3362
+ // It ALWAYS renders now: the reasoning group, the teamwork row and
3363
+ // the display-mode button live in ONE flex container, and the mode
3364
+ // button is the one member that is always present (the other two
3365
+ // come and go with the model and the remote scope). They share a
3366
+ // line when they fit with the 30px gap, otherwise each takes its own
3367
+ // line; the mode button is pushed to the trailing edge.
3368
+ h('div', { className: 'dshmf-footer', ref: footerRef },
2772
3369
  effortChoices.length === 0 ? null : h('div', {
2773
3370
  className: 'dshmf-reason',
2774
3371
  ref: reasonRef,
@@ -2823,6 +3420,30 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2823
3420
  onClick: () => { changeTeamMode(!teamMode.enabled) },
2824
3421
  }, h('span', { className: 'dshmf-thumb' })),
2825
3422
  ),
3423
+
3424
+ // ---- display mode: hand the seat back to the Host's own
3425
+ // picker, or take it back. One button, `aria-pressed` carrying
3426
+ // the state. It is the footer's trailing member, so an auto left
3427
+ // margin pins it to the panel's bottom-right and the wrap rule
3428
+ // keeps every other member left-aligned.
3429
+ // Disabled while the Host ships no picker to hand over to — a
3430
+ // seat nobody would take must never empty the composer.
3431
+ // Clicking TOGGLES: a pressed button means native mode is in
3432
+ // effect, so it must offer the way back rather than re-assert
3433
+ // the mode that is already on.
3434
+ h('button', {
3435
+ type: 'button',
3436
+ className: 'dshmf-modeBtn',
3437
+ 'aria-pressed': modeIsNative,
3438
+ 'aria-label': modeIsNative
3439
+ ? t('native.back')
3440
+ : (canGoNative ? t('native.enter') : t('native.unavailable')),
3441
+ title: modeIsNative
3442
+ ? t('native.back')
3443
+ : (canGoNative ? t('native.enter') : t('native.unavailable')),
3444
+ disabled: !canGoNative,
3445
+ onClick: () => { switchMode(modeIsNative ? 'flat' : 'native') },
3446
+ }, t('native.label')),
2826
3447
  ),
2827
3448
 
2828
3449
  // ---- fixed: teamwork disable confirmation
@@ -2884,6 +3505,43 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2884
3505
  )
2885
3506
  }
2886
3507
 
3508
+ // ---------------------------------------------------------------------
3509
+ // Label boundary
3510
+ // ---------------------------------------------------------------------
3511
+ /**
3512
+ * Keep the trigger alive when the label cell's occupant fails.
3513
+ *
3514
+ * The occupant is another plugin's component, rendered here on the
3515
+ * documented owner-props contract but without the renderer's own per-entry
3516
+ * error boundary. It can therefore throw on a host whose props differ from
3517
+ * the ones it was written for, or on data it did not expect. Catching it
3518
+ * here costs the decoration, never the seat: the caller's `fallback` is
3519
+ * `undefined`, which makes the trigger draw the catalog name exactly as the
3520
+ * shipped picker does when the cell has no occupant.
3521
+ *
3522
+ * The report is once per failure, not once per render — a chip that throws
3523
+ * on every paint would otherwise flood the console.
3524
+ */
3525
+ class LabelBoundary extends React.Component {
3526
+ constructor(props) {
3527
+ super(props)
3528
+ this.state = { failed: false }
3529
+ }
3530
+
3531
+ static getDerivedStateFromError() {
3532
+ return { failed: true }
3533
+ }
3534
+
3535
+ componentDidCatch(error, info) {
3536
+ console.error('dsh-model-flatmap: the model label slot failed; the trigger falls back to the catalog name', error, info)
3537
+ }
3538
+
3539
+ render() {
3540
+ if (this.state.failed) return this.props.fallback ?? null
3541
+ return this.props.children
3542
+ }
3543
+ }
3544
+
2887
3545
  // ---------------------------------------------------------------------
2888
3546
  // Seat boundary
2889
3547
  // ---------------------------------------------------------------------
@@ -2965,6 +3623,68 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2965
3623
  h(ModelFlatMap, props))
2966
3624
  }
2967
3625
 
3626
+ /**
3627
+ * The way back out of native mode.
3628
+ *
3629
+ * Native mode DISPOSES this plugin's `conversation.input.model` entry, so
3630
+ * the footer button that switched it disappears with the panel. This row
3631
+ * rides the HOST picker's own root menu instead: `conversation.input.model.
3632
+ * options` is the list child slot the shipped `ModelSelect` declares and
3633
+ * renders right after its Model / Effort rows, so the control appears at
3634
+ * the bottom of the native picker's own panel — the one place that both
3635
+ * survives the seat's disposal AND belongs to the control it acts on. No
3636
+ * host component is rendered, imported or copied; the slot is a public
3637
+ * declaration of the installed package.
3638
+ *
3639
+ * It renders NOTHING unless native mode is in effect, so the shipped menu
3640
+ * looks exactly as shipped while the flat panel is the one on screen.
3641
+ *
3642
+ * Visually it is NOT a menu row. Every row above it answers "which value?";
3643
+ * this one performs an action, so it sits below a separator rule and is
3644
+ * drawn as an outlined push button. The slot gives us only a
3645
+ * `display: contents` anchor, so the whole box is ours to style.
3646
+ *
3647
+ * It reads the same page-wide store as the panel's footer button, so a
3648
+ * switch made here is the switch the panel sees when it comes back.
3649
+ * @param props - the slot's standard props plus the injected mode face.
3650
+ */
3651
+ function NativeMenuRow(props) {
3652
+ const {
3653
+ t, displayMode, setDisplayMode, nativeAvailable, dismiss,
3654
+ } = props
3655
+ const modeFace = displayMode ?? FLAT_DISPLAY_MODE
3656
+ const subscribeMode = useCallback(listener => modeFace.subscribe(listener), [modeFace])
3657
+ const getMode = useCallback(() => modeFace.getSnapshot(), [modeFace])
3658
+ const mode = useSyncExternalStore(subscribeMode, getMode, getMode)
3659
+ // The panel's own business: this row exists only while the Host's picker
3660
+ // is the seat on screen.
3661
+ if (mode !== 'native') return null
3662
+ const canGoFlat = typeof nativeAvailable !== 'function' || nativeAvailable() === true
3663
+ if (!canGoFlat) return null
3664
+ const translate = typeof t === 'function' ? t : key => key
3665
+ // A band whose top rule separates an ACTION from the value rows above it,
3666
+ // then a full-width outlined button inside. Keeping the anchor full-width
3667
+ // is what makes the rule span the menu; the button is what reads as
3668
+ // something to press rather than something to choose.
3669
+ return h('div', { className: 'dshmf-modeAction' },
3670
+ h('button', {
3671
+ type: 'button',
3672
+ className: 'dshmf-modeBtnRow',
3673
+ title: translate('native.back'),
3674
+ onClick: () => {
3675
+ // Let the host close its own menu before switching: the switch
3676
+ // disposes the entry that owns this menu, and closing on the host's
3677
+ // own terms (its state, its listener cleanup, its focus handoff)
3678
+ // beats having a portaled menu unmount underneath the click.
3679
+ if (typeof dismiss === 'function') dismiss()
3680
+ if (typeof setDisplayMode === 'function') setDisplayMode('flat')
3681
+ else modeFace.set('flat')
3682
+ },
3683
+ },
3684
+ svg(PATH_GRID, 13, { className: 'dshmf-modeBtnRowIcon' }),
3685
+ h('span', { className: 'dshmf-modeBtnRowLabel' }, translate('native.back'))))
3686
+ }
3687
+
2968
3688
  // ---------------------------------------------------------------------
2969
3689
  // Plugin
2970
3690
  // ---------------------------------------------------------------------
@@ -2979,9 +3699,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2979
3699
  // Pure layout and grouping derivations plus the component itself,
2980
3700
  // exposed for `node --test` so grouping, filtering and the column
2981
3701
  // algorithm are pinned without a browser. The shell never reads this key.
2982
- __test: { marqueePassGeometry, nameClippedBy, MARQUEE_DELAY, MARQUEE_PX_PER_SECOND, MARQUEE_MIN_MS, familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, STRIKE_TTL, markIsLive, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore },
3702
+ __test: { marqueePassGeometry, nameClippedBy, MARQUEE_DELAY, MARQUEE_PX_PER_SECOND, MARQUEE_MIN_MS, familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, readDisplayMode, writeDisplayMode, createDisplayModeStore, DISPLAY_MODE_KEY, DISPLAY_MODE_DEFAULT, FLAT_DISPLAY_MODE, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, STRIKE_TTL, markIsLive, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore, quantizeMetric, metricChanged, METRIC_HYSTERESIS, LabelBoundary, readLabelEntry, labelSeat, labelSlotSource, LABEL_SLOT, providerLabel },
2983
3703
  __component: ModelFlatMap,
2984
3704
  __seat: Seat,
3705
+ __nativeMenuRow: NativeMenuRow,
2985
3706
  apply(ctx) {
2986
3707
  const read = name => {
2987
3708
  try {
@@ -3063,6 +3784,72 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
3063
3784
  markStore = undefined
3064
3785
  }
3065
3786
 
3787
+ // ---- the display-mode store (flat panel vs the Host's own picker)
3788
+ //
3789
+ // One store per activation, shared by the panel's footer button and the
3790
+ // composer-row chip that outlives it. `readDisplayMode` already returned
3791
+ // `flat` for a missing or unreadable store, so a persistence failure
3792
+ // costs the memory of the choice, never the control.
3793
+ let modeStore
3794
+ try {
3795
+ modeStore = createDisplayModeStore({
3796
+ readStored: readDisplayMode,
3797
+ writeStored: writeDisplayMode,
3798
+ })
3799
+ ctx.effect(() => () => { modeStore.dispose?.() }, 'dsh-model-flatmap: display mode')
3800
+ } catch (error) {
3801
+ console.error('dsh-model-flatmap: the display mode store failed to start', error)
3802
+ modeStore = createDisplayModeStore({ readStored: () => DISPLAY_MODE_DEFAULT })
3803
+ }
3804
+
3805
+ /**
3806
+ * Every OTHER live registration in the model seat.
3807
+ *
3808
+ * A `single` slot keeps every registration and elects the highest
3809
+ * priority; this plugin only owns the cell because it registers at a
3810
+ * negative priority. Disposing our own entry therefore hands the cell to
3811
+ * whatever is underneath — normally the shipped `ModelSelect` — with no
3812
+ * copy, import or re-mount, so an upgraded DSH shows its own newest
3813
+ * picker (DEC-027).
3814
+ * @returns the competing entries, or `undefined` when the Host cannot
3815
+ * enumerate them (an older version): unverifiable means "do not step
3816
+ * aside", never a blank composer.
3817
+ */
3818
+ const otherEntries = () => {
3819
+ try {
3820
+ if (typeof slots.entries !== 'function') return undefined
3821
+ const all = slots.entries('conversation.input.model')
3822
+ if (!Array.isArray(all)) return undefined
3823
+ return all.filter(entry => entry?.component !== Seat)
3824
+ } catch {
3825
+ return undefined
3826
+ }
3827
+ }
3828
+ /**
3829
+ * May the panel hand the seat over? Only when another live entry exists
3830
+ * to inherit it. Deliberately does NOT require the shipped package name:
3831
+ * a composed profile may legitimately elect another plugin's seat, and
3832
+ * that is exactly what the user asked to see.
3833
+ * @returns whether disposing this entry leaves a model control behind.
3834
+ */
3835
+ const canStepAside = () => {
3836
+ const others = otherEntries()
3837
+ return others !== undefined && others.length > 0
3838
+ }
3839
+ /** Report the inheriting entry once, so a support log names the target. */
3840
+ let inheritReported = false
3841
+ const reportInherit = () => {
3842
+ if (inheritReported) return
3843
+ inheritReported = true
3844
+ try {
3845
+ const others = otherEntries()
3846
+ const names = (others ?? []).map(entry => entry?.registrant ?? entry?.options?.id ?? 'unknown')
3847
+ console.info(`dsh-model-flatmap: the model seat is handed to ${names.join(', ') || 'no entry'}`)
3848
+ } catch {
3849
+ /* diagnostics only */
3850
+ }
3851
+ }
3852
+
3066
3853
  /**
3067
3854
  * Build the injected face for one Session.
3068
3855
  * @param sessionId - the Session this seat renders for.
@@ -3150,6 +3937,37 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
3150
3937
  ? Promise.resolve(undefined)
3151
3938
  : service.setEnabled(sessionId, enabled)
3152
3939
  },
3940
+ // Display mode: the ONE page-wide store, read by both this seat's
3941
+ // footer button and the composer-row chip that takes over while
3942
+ // this seat is disposed.
3943
+ displayMode: modeStore,
3944
+ setDisplayMode: value => { modeStore.set(value) },
3945
+ // Lazy for the same reason as `teamAvailable`: the shipped entry can
3946
+ // register AFTER this face is built, and the button has to enable
3947
+ // itself without a reload.
3948
+ nativeAvailable: canStepAside,
3949
+ // The trigger's label: the entry that owns the shipped picker's
3950
+ // model-label cell, plus the two seats it needs to render (its own
3951
+ // locale namespace and its business inject). Resolved per call so a
3952
+ // connection plugin that registers a moment after boot still gets
3953
+ // its chip on the trigger without a reload (DEC-030).
3954
+ labelEntry: () => readLabelEntry(safeRead('slots')),
3955
+ labelSeatFor: namespace => labelSeat(safeRead('locale'), namespace),
3956
+ labelFace: entry => {
3957
+ if (entry === undefined || typeof entry.inject !== 'function') return {}
3958
+ try {
3959
+ // The renderer's own contract: a session-scoped entry's inject
3960
+ // receives the Session id and is merged UNDER the owner props.
3961
+ const face = entry.inject(sessionId)
3962
+ return face !== null && typeof face === 'object' ? face : {}
3963
+ } catch (error) {
3964
+ console.error('dsh-model-flatmap: the label slot face failed to build', error)
3965
+ return {}
3966
+ }
3967
+ },
3968
+ // A stable subscribe/getSnapshot pair so the trigger re-reads the
3969
+ // winning entry when a connection plugin registers or disposes.
3970
+ labelSlot: labelSlotSource(safeRead('slots')),
3153
3971
  }
3154
3972
  }
3155
3973
 
@@ -3182,6 +4000,13 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
3182
4000
  // unstruck. It shares the module-level empty snapshot so the
3183
4001
  // identity stays stable across renders.
3184
4002
  insufficient: EMPTY_INSUFFICIENT,
4003
+ // Same principle for the mode: a stable flat snapshot and a
4004
+ // no-op setter keep the footer's hook shape intact. Stepping aside
4005
+ // is refused outright — a face this broken must not hand the
4006
+ // composer to anything.
4007
+ displayMode: FLAT_DISPLAY_MODE,
4008
+ setDisplayMode: () => {},
4009
+ nativeAvailable: () => false,
3185
4010
  }
3186
4011
  }
3187
4012
  }
@@ -3208,11 +4033,108 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
3208
4033
  return () => {}
3209
4034
  }
3210
4035
 
4036
+ // ---- the mode controller
4037
+ //
4038
+ // Flat mode keeps this plugin's entry registered (it shadows the
4039
+ // shipped one at a negative priority). Native mode DISPOSES that entry,
4040
+ // which elects the shipped picker underneath. Mount and unmount are
4041
+ // therefore the two halves of one switch, and both are idempotent: the
4042
+ // notify path re-enters `syncMode` through our own slot mutations.
4043
+ let seatDispose
4044
+ /** Guard against re-entry from our own registration changes. */
4045
+ let syncing = false
4046
+ const mountSeat = () => {
4047
+ if (seatDispose !== undefined) return
4048
+ seatDispose = registerSeat()
4049
+ }
4050
+ const unmountSeat = () => {
4051
+ const dispose = seatDispose
4052
+ if (dispose === undefined) return
4053
+ seatDispose = undefined
4054
+ try {
4055
+ dispose()
4056
+ } catch (error) {
4057
+ console.error('dsh-model-flatmap: the model seat did not dispose cleanly', error)
4058
+ }
4059
+ }
4060
+ /**
4061
+ * Reconcile the registration with the store. Native mode only takes
4062
+ * effect when another entry can inherit the cell — otherwise the panel
4063
+ * stays, so a profile without the shipped picker never loses its model
4064
+ * control.
4065
+ */
4066
+ const syncMode = () => {
4067
+ if (syncing) return
4068
+ syncing = true
4069
+ try {
4070
+ const wantsNative = modeStore.getSnapshot() === 'native'
4071
+ if (wantsNative && canStepAside()) {
4072
+ reportInherit()
4073
+ unmountSeat()
4074
+ } else {
4075
+ mountSeat()
4076
+ }
4077
+ } finally {
4078
+ syncing = false
4079
+ }
4080
+ }
4081
+
3211
4082
  try {
3212
- slots.inject('conversation.input.model', registerSeat)
4083
+ slots.inject('conversation.input.model', () => {
4084
+ syncMode()
4085
+ const offMode = modeStore.subscribe(syncMode)
4086
+ // The shipped entry may register after ours, and a competing entry
4087
+ // may unload: both change whether stepping aside is possible, so the
4088
+ // button's availability rides the same reconcile.
4089
+ const offSlot = typeof slots.subscribe === 'function'
4090
+ ? slots.subscribe('conversation.input.model', syncMode)
4091
+ : undefined
4092
+ return () => {
4093
+ if (typeof offMode === 'function') offMode()
4094
+ if (typeof offSlot === 'function') offSlot()
4095
+ unmountSeat()
4096
+ }
4097
+ })
3213
4098
  } catch (error) {
3214
4099
  console.error('dsh-model-flatmap: slots.inject failed for the model seat', error)
3215
4100
  }
4101
+
4102
+ // ---- the chip that brings the panel back from native mode
4103
+ //
4104
+ // Native mode disposes the seat, and with it the footer button that
4105
+ // switched. The way back rides the HOST picker's OWN root menu instead:
4106
+ // `conversation.input.model.options` is the list child slot the shipped
4107
+ // `ModelSelect` declares and renders after its Model / Effort rows, so
4108
+ // our row appears at the bottom of the native panel itself. It survives
4109
+ // the seat's disposal (it belongs to the shipped entry, not to ours) and
4110
+ // renders nothing while the flat panel is the seat on screen.
4111
+ try {
4112
+ if (typeof slots.inject === 'function') {
4113
+ slots.inject('conversation.input.model.options', () => {
4114
+ if (typeof slots.register !== 'function') return () => {}
4115
+ try {
4116
+ return slots.register({
4117
+ name: 'conversation.input.model.options',
4118
+ id: 'dsh-model-flatmap.native',
4119
+ order: 50,
4120
+ locale: NS,
4121
+ inject: () => ({
4122
+ displayMode: modeStore,
4123
+ setDisplayMode: value => { modeStore.set(value) },
4124
+ nativeAvailable: canStepAside,
4125
+ }),
4126
+ }, NativeMenuRow)
4127
+ } catch (error) {
4128
+ console.error('dsh-model-flatmap: the native-mode menu row was not registered', error)
4129
+ return () => {}
4130
+ }
4131
+ })
4132
+ }
4133
+ } catch (error) {
4134
+ // Losing the row costs the way BACK, never the way in: the panel and
4135
+ // its footer button still work, and a reload restores flat mode.
4136
+ console.error('dsh-model-flatmap: slots.inject failed for the native-mode menu row', error)
4137
+ }
3216
4138
  },
3217
4139
  }
3218
4140
  },