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/README.md +22 -0
- package/README.zh.md +14 -0
- package/client.js +964 -42
- package/docs/DETAIL.md +136 -4
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
// `
|
|
1528
|
-
//
|
|
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
|
|
1539
|
-
* Session has no retained binding (an unopened Session has
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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' },
|
|
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:
|
|
2764
|
-
//
|
|
2765
|
-
//
|
|
2766
|
-
// is
|
|
2767
|
-
//
|
|
2768
|
-
// they fit with the 30px gap, otherwise each takes its own
|
|
2769
|
-
|
|
2770
|
-
|
|
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',
|
|
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
|
},
|