dsh-model-flatmap 1.0.0 → 1.0.2

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 CHANGED
@@ -7,8 +7,9 @@
7
7
 
8
8
  ## What it is
9
9
 
10
- `dsh-model-flatmap` is a **browser-only** DSH plugin. It replaces the composer's
11
- `conversation.input.model` seat with a single flat panel rendered in the Web GUI.
10
+ `dsh-model-flatmap` is a **client-side** DSH plugin that runs in both the **Web GUI and the
11
+ Desktop app**. It replaces the composer's `conversation.input.model` seat with a single flat
12
+ panel rendered in the client UI.
12
13
  Instead of nested popovers, the whole catalogue is one searchable grid where the
13
14
  model grid, the current model's reasoning levels, and the agent-teamwork switch
14
15
  are all visible at once.
@@ -58,12 +59,18 @@ levels, and the agent-teamwork switch on one surface:
58
59
  - **Adaptive footer.** The reasoning group and the agent-teamwork switch share
59
60
  one line when both fit with a 30 px gap, and fall back to one row each when
60
61
  they do not — the break is CSS flex wrapping, not a measured pixel threshold.
61
- - **Out-of-balance models are struck through.** A model whose last call failed
62
- on insufficient balance, credit, or quota keeps a `line-through` on its row
63
- text until a later call for that same model returns anything else. The strike
64
- follows the model the failing request actually used, and covers the Host's
65
- `QUOTA` / `ACCOUNT_QUOTA` codes, a provider `402`, and a `429` whose body
66
- reports an empty balance — a plain rate limit never strikes.
62
+ - **Out-of-balance models are struck through — everywhere.** A model whose last
63
+ call failed on insufficient balance, credit, or quota keeps a `line-through`
64
+ on its row text until a later call for that same model returns anything else.
65
+ The strike follows the model the failing request actually used, and covers the
66
+ Host's `QUOTA` / `ACCOUNT_QUOTA` codes, a provider `402`, and a `429` whose
67
+ body reports an empty balance — a plain rate limit never strikes. Because a
68
+ balance is a property of the model route and its account, **not of one
69
+ conversation**, the marks are global: a failure seen in any Session strikes
70
+ the model in every Session and workspace, and the state is persisted to
71
+ `localStorage` so it survives a reload. Observations are ordered by event
72
+ time, so the newest one wins and replaying older history cannot resurrect a
73
+ strike that a later call already cleared.
67
74
  - **Theme-aware.** Styled with host theme tokens only; looks correct in both
68
75
  light and dark themes.
69
76
 
package/README.zh.md CHANGED
@@ -7,8 +7,8 @@
7
7
 
8
8
  ## 这是什么
9
9
 
10
- `dsh-model-flatmap` 是一个**纯浏览器端**的 DSH 插件。它把会话输入框中的
11
- `conversation.input.model` 控件替换成一个平铺面板,渲染在 Web GUI 内。面板没有
10
+ `dsh-model-flatmap` 是一个**客户端** DSH 插件,在 **Web GUI 与桌面端** 均生效。它把会话输入框中的
11
+ `conversation.input.model` 控件替换成一个平铺面板,渲染在客户端界面中。面板没有
12
12
  嵌套的二级菜单:模型网格、当前模型的推理等级,以及智能体协作开关,全部同时可见。
13
13
 
14
14
  插件**只通过公开的 Client API 读写数据**——每个会话的模型目录、协作设置、以及宿主
@@ -48,10 +48,14 @@
48
48
  基于 radio 语义)。
49
49
  - **自适应底栏。** 推理等级组与智能体协作开关在放得下(中间至少 30 px)时共用一行,
50
50
  放不下时各自占一行回到原来的两行布局——换行由 CSS flex 换行决定,不靠 JS 量像素。
51
- - **额度不足的模型加删除线。** 某个模型的调用因额度/余额/信用不足而失败后,该模型的
52
- 行文字会带 `line-through` 删除线,直到同一个模型的下一次调用返回非额度不足的结果
53
- 才去掉。标记跟随失败那次请求实际使用的模型;覆盖宿主的 `QUOTA` / `ACCOUNT_QUOTA`
51
+ - **额度不足的模型加删除线,且全局生效。** 某个模型的调用因额度/余额/信用不足而失败后,
52
+ 该模型的行文字会带 `line-through` 删除线,直到同一个模型的下一次调用返回非额度不足的
53
+ 结果才去掉。标记跟随失败那次请求实际使用的模型;覆盖宿主的 `QUOTA` / `ACCOUNT_QUOTA`
54
54
  代码、provider 的 `402`,以及**正文说明余额不足的 `429`**——纯限流的 429 不会划线。
55
+ 因为「还有没有额度」是模型路由(及其账号)的属性、**与单个会话无关**,所以标记是全局的:
56
+ 在任意会话里观察到的失败,会在所有会话与工作区里同样划线;状态持久化在 `localStorage`
57
+ 中,重载后仍然保留。观察按事件时间排序,最新的一条说了算,因此回放更早的历史不会让一条
58
+ 已被后续调用清除的划线复活。
55
59
  - **跟随主题。** 仅使用宿主主题令牌着色,在浅色与深色主题下都正确。
56
60
 
57
61
  完整行为规格见 [`docs/DETAIL.md`](./docs/DETAIL.md),设计决策见
package/client.js CHANGED
@@ -59,12 +59,15 @@ window.__ModuleLoader__.load({
59
59
  .dshmf-chevronOpen { transform: rotate(180deg); }
60
60
 
61
61
  /* Panel: the host menu surface (menu fill + blur + prominent elevation + lg
62
- corners), capped at 320px; only the grid scrolls. */
62
+ corners), capped at 320px; only the grid scrolls. A 1px light-grey frame
63
+ traces the panel edge in both themes — explicitly NOT a border token: the
64
+ border-* tokens are near-invisible hairlines (see DEC-011/DEC-012), and the
65
+ frame has to be readable on the blurred translucent surface. */
63
66
  .dshmf-panel {
64
67
  box-sizing: border-box; position: fixed; z-index: 1100;
65
68
  display: flex; flex-direction: column; gap: 4px; padding: 6px;
66
69
  max-height: 320px; overflow: hidden;
67
- border: 0; border-radius: var(--dsw-radius-lg);
70
+ border: 1px solid #D1D5DB; border-radius: var(--dsw-radius-lg);
68
71
  background: var(--dsw-menu-surface-fill);
69
72
  -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, none);
70
73
  backdrop-filter: var(--dsw-menu-backdrop-filter, none);
@@ -74,6 +77,11 @@ window.__ModuleLoader__.load({
74
77
  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
75
78
  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
76
79
  }
80
+ /* The same light-grey frame, stepped to the footer divider's dark-theme grey
81
+ (#6B7280, DEC-012) so it stays a light frame on the near-black surface
82
+ without glaring: the width stays 1px in both themes. */
83
+ body[data-ds-dark-theme] .dshmf-panel,
84
+ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
77
85
 
78
86
  /* Fixed sections. */
79
87
  .dshmf-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 28px; }
@@ -118,16 +126,32 @@ window.__ModuleLoader__.load({
118
126
  color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
119
127
  }
120
128
 
121
- /* The only scrolling region. */
122
- .dshmf-grid { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; display: grid; align-items: start; }
129
+ /* The only scrolling region. Its "align-items: start" keeps the text columns
130
+ at their natural height; a divider laid out beside them opts back into
131
+ stretch so it spans the tallest column instead (see .dshmf-colDivider).
132
+ Every child is placed explicitly by grid-column (and the divider by
133
+ grid-row) — see the inline style on each one. Auto-placement must NOT be
134
+ relied on here: a divider that pins its row but not its column is placed
135
+ first, stealing column 1, and the columns then overflow to columns 2 and 3,
136
+ squashing the first text column. */
137
+ .dshmf-grid {
138
+ flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto;
139
+ display: grid; align-items: start;
140
+ }
123
141
  .dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
124
142
  /* When more than one column is laid out, separate adjacent columns with a
125
143
  faint vertical hairline so the eye can tell the dense model flows apart.
126
- The line uses the lightest border token to stay subtle, and the divided
127
- column keeps a little right padding so model text never hugs the line.
128
- Only columns that actually sit before another one get it (see renderColumn),
129
- so a single-column layout stays borderless. */
130
- .dshmf-col--divided { border-right: 1px solid var(--dsw-alias-border-l3); padding-right: 10px; }
144
+ The hairline is its OWN grid item, a sibling of the columns rather than a
145
+ border on one of them: as a border it could only ever be as tall as that
146
+ column's content. Here it sits in a dedicated gutter track and stretches to
147
+ the grid's full height, with a small margin top and bottom.
148
+ Only gutters that actually sit between two columns get one (see the grid
149
+ children), so a single-column layout stays borderless. */
150
+ .dshmf-colDivider {
151
+ grid-row: 1; align-self: stretch; justify-self: center;
152
+ width: 1px; margin: 6px 0;
153
+ background: var(--dsw-alias-border-l3);
154
+ }
131
155
  /* Provider / family label: a filled, rounded band that reads as a section
132
156
  header, clearly separated from the model rows below it. Bare host
133
157
  group-header fill is only a few levels from the translucent panel surface.
@@ -240,30 +264,28 @@ window.__ModuleLoader__.load({
240
264
  No JS width guessing, so it stays correct across panel resize, theme and
241
265
  font changes. A max-width of 100% lets a child that ends up alone on a line
242
266
  cap to the panel width and wrap internally instead of overflowing.
243
- The teamwork row carries no divider of its own (DEC-011 keeps the single
244
- grey rule above the reasoning group only), so a shared line needs no
245
- border juggling. */
267
+ The divider sits on this container, not on a child (DEC-016): the two groups
268
+ can share a line or stack, and either way the rule belongs above the whole
269
+ footer block, never between them, so no child carries a border. Host border
270
+ tokens are faint by design, so the line uses explicit greys: medium grey on
271
+ the light panel, a lighter near-white grey on the dark panel. */
246
272
  .dshmf-footer {
247
273
  flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
248
274
  column-gap: 30px; row-gap: 4px;
275
+ border-top: 1px solid #9CA3AF;
276
+ padding-top: 6px;
249
277
  }
278
+ body[data-ds-dark-theme] .dshmf-footer,
279
+ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
250
280
 
251
281
  /* Reasoning levels: a caption and a multi-segmentation control on one line;
252
282
  the track wraps below the caption only when the row cannot fit. The
253
283
  segment visual mirrors the header's grouping control, so the panel has
254
284
  one visual language for "one of many, exactly one active". */
255
- /* One obvious grey divider between the scrolling model grid and the fixed
256
- reasoning row. Host border tokens are faint by design, so this line uses
257
- explicit greys: medium grey on the light panel, a lighter near-white grey
258
- on the dark panel. The teamwork row below has no line (DEC-011). */
259
285
  .dshmf-reason {
260
286
  flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
261
287
  gap: 2px 6px; max-width: 100%;
262
- border-top: 1px solid #9CA3AF;
263
- padding-top: 6px;
264
288
  }
265
- body[data-ds-dark-theme] .dshmf-reason,
266
- html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
267
289
  .dshmf-reasonLabel {
268
290
  flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
269
291
  white-space: nowrap;
@@ -568,37 +590,88 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
568
590
  const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
569
591
 
570
592
  /**
571
- * Fold ONE durable session event into the per-route insufficient-balance
572
- * state. Pure and reference-preserving, so an unrelated event costs nothing
573
- * and the caller can compare identity to decide whether to republish.
593
+ * Wall-clock time one durable event was recorded at. The Client feed keeps
594
+ * the Host's `time` (`Date.now()` at append), which is what lets marks
595
+ * gathered from DIFFERENT Sessions be ordered against each other — a Session
596
+ * feed has no cross-Session sequence number.
597
+ * @param {unknown} event - one durable Session event.
598
+ * @returns {number} the event time, or 0 when the feed carried none.
599
+ */
600
+ function eventTimeOf(event) {
601
+ const time = event?.time
602
+ return typeof time === 'number' && Number.isFinite(time) ? time : 0
603
+ }
604
+
605
+ /**
606
+ * Decide what ONE durable event says about a model route's balance, with no
607
+ * state involved. This is the whole semantic core of the feature, kept
608
+ * separate from the bookkeeping so it can be pinned directly.
574
609
  *
575
- * A `turn/end` carrying a balance failure marks the route that request used.
576
- * A later `turn/end` for the same route that proves the model answered
577
- * without a balance failure clears the mark — a completed turn, or a turn
578
- * that failed for another reason. A cancellation or a blocked turn clears
579
- * nothing, because the model never answered. The route a `turn/end` belongs
580
- * to is the latest `request/header` seen before it, which is exactly the
581
- * model the failing request was dispatched to.
582
- * @param {Map<string, boolean>} state - current marks, mutated in place.
583
- * @param {unknown} event - one durable Session event (`{ type, data }`).
584
- * @param {{ provider: string, model: string } | null} route - route resolved from the request header, or null.
585
- * @returns {boolean} whether the state changed.
610
+ * - A `turn/end` that failed on insufficient balance → `true` (mark).
611
+ * - A `turn/end` that proves the model answered otherwise → `false`
612
+ * (clear): a completed turn, a `max-tokens` turn, or a turn that failed
613
+ * for a different reason.
614
+ * - A cancellation (`aborted`), a `blocked` turn, and the synthetic
615
+ * `interrupted` / `forked` closers → `null` (no decision), because the
616
+ * model never answered and the account state is unknown.
617
+ * - Anything that is not a `turn/end`, or has no route → `null`.
618
+ * @param {unknown} event - one durable Session event.
619
+ * @param {{ provider: string, model: string } | null} route - the route that request used.
620
+ * @returns {boolean | null} mark, clear, or no decision.
586
621
  */
587
- function foldInsufficient(state, event, route) {
588
- if (event === null || typeof event !== 'object') return false
589
- if (event.type !== 'turn/end' || route === null) return false
622
+ function decideMark(event, route) {
623
+ if (event === null || typeof event !== 'object') return null
624
+ if (event.type !== 'turn/end' || route === null || route === undefined) return null
590
625
  const reason = event.data?.reason
591
- if (reason === null || typeof reason !== 'object') return false
592
- const key = routeKeyOf(route.provider, route.model)
593
- if (reason.kind === 'error' && isInsufficientBalance(reason.error)) {
594
- if (state.get(key) === true) return false
595
- state.set(key, true)
596
- return true
626
+ if (reason === null || typeof reason !== 'object') return null
627
+ if (reason.kind === 'error') return isInsufficientBalance(reason.error)
628
+ return CLEARING_TURN_ENDS.has(reason.kind) ? false : null
629
+ }
630
+
631
+ /**
632
+ * Fold ONE decision into the mark map, last-writer-wins by event time.
633
+ *
634
+ * The time watermark is what makes the state GLOBAL and replay-safe: marks
635
+ * arrive from every Session's feed (and from storage written by an earlier
636
+ * page load), so the newest observation wins no matter which Session it came
637
+ * from. Re-walking an old window therefore cannot resurrect a mark that a
638
+ * later call already cleared, and a cleared route keeps a watermark so a
639
+ * stale replay stays a no-op.
640
+ * @param {Map<string, {marked: boolean, at: number}>} entries - state, mutated in place.
641
+ * @param {string} key - the route key.
642
+ * @param {boolean} marked - the decision this event carries.
643
+ * @param {number} at - the event's time.
644
+ * @returns {{ visible: boolean, stored: boolean }} whether the visible mark
645
+ * set changed, and whether the underlying record changed.
646
+ */
647
+ function foldMarkEntry(entries, key, marked, at) {
648
+ const previous = entries.get(key)
649
+ if (previous !== undefined && at < previous.at) {
650
+ return { visible: false, stored: false }
597
651
  }
598
- if (!CLEARING_TURN_ENDS.has(reason.kind)) return false
599
- if (state.get(key) !== true) return false
600
- state.delete(key)
601
- return true
652
+ if (previous !== undefined && previous.marked === marked && previous.at === at) {
653
+ return { visible: false, stored: false }
654
+ }
655
+ entries.set(key, { marked, at })
656
+ return { visible: previous === undefined ? marked : previous.marked !== marked, stored: true }
657
+ }
658
+
659
+ /**
660
+ * Fold one durable event into the mark map. Pure helper kept for the tests
661
+ * and for callers that hold their own map; the live store uses
662
+ * {@link decideMark} + {@link foldMarkEntry} so it can batch persistence.
663
+ * @param {Map<string, {marked: boolean, at: number}>} state - state, mutated in place.
664
+ * @param {unknown} event - one durable Session event.
665
+ * @param {{ provider: string, model: string } | null} route - the route that request used.
666
+ * @param {number} [at] - the event's time; defaults to `event.time`.
667
+ * @returns {boolean} whether the visible mark set changed.
668
+ */
669
+ function foldInsufficient(state, event, route, at) {
670
+ const marked = decideMark(event, route)
671
+ if (marked === null) return false
672
+ const key = routeKeyOf(route.provider, route.model)
673
+ const time = at === undefined ? eventTimeOf(event) : at
674
+ return foldMarkEntry(state, key, marked, time).visible
602
675
  }
603
676
 
604
677
  /**
@@ -1014,6 +1087,320 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1014
1087
  }
1015
1088
  }
1016
1089
 
1090
+ // ---------------------------------------------------------------------
1091
+ // Insufficient-balance marks are GLOBAL, not Session-scoped.
1092
+ //
1093
+ // Whether a model has any quota left is a fact about the model route (and
1094
+ // the account behind it), not about one conversation: a failure observed in
1095
+ // one Session must strike the same model everywhere — another Session,
1096
+ // another workspace, and after a reload. So the marks live in ONE
1097
+ // module-level store shared by every seat of this plugin generation, and
1098
+ // are persisted to `localStorage` (origin-scoped, exactly like the grouping
1099
+ // choice) so a fresh page load starts from the last known state.
1100
+ //
1101
+ // Ordering across Sessions is by wall-clock time: each durable event keeps
1102
+ // the Host's `time`, and the newest observation of a route wins. That is
1103
+ // what lets a replayed window (or a stored entry) never override a newer
1104
+ // fact from another Session.
1105
+ // ---------------------------------------------------------------------
1106
+ const MARKS_KEY = 'dshmf:insufficient'
1107
+ /** Marks older than this are dropped on read, so a stale strike cannot live forever. */
1108
+ const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
1109
+
1110
+ /**
1111
+ * Parse a persisted mark record into the in-memory shape, dropping anything
1112
+ * malformed or older than {@link MARKS_TTL}. Pure, so the storage format is
1113
+ * testable without a `localStorage` shim.
1114
+ * @param {unknown} raw - the stored JSON value (already parsed).
1115
+ * @param {number} now - current time, injected for testability.
1116
+ * @returns {Map<string, {marked: boolean, at: number}>} the parsed entries.
1117
+ */
1118
+ function parseMarks(raw, now) {
1119
+ const entries = new Map()
1120
+ if (raw === null || typeof raw !== 'object') return entries
1121
+ for (const [key, value] of Object.entries(raw)) {
1122
+ if (typeof key !== 'string' || key === '') continue
1123
+ if (value === null || typeof value !== 'object') continue
1124
+ const at = typeof value.at === 'number' && Number.isFinite(value.at) ? value.at : 0
1125
+ if (typeof value.marked !== 'boolean') continue
1126
+ if (now - at > MARKS_TTL) continue
1127
+ entries.set(key, { marked: value.marked, at })
1128
+ }
1129
+ return entries
1130
+ }
1131
+
1132
+ /**
1133
+ * Serialise the in-memory entries for storage, dropping anything older than
1134
+ * {@link MARKS_TTL} so a long-lived page cannot grow the record forever. A
1135
+ * cleared route keeps its watermark only while it can still protect against
1136
+ * a replayed older failure, which is the same window.
1137
+ * @param {Map<string, {marked: boolean, at: number}>} entries - the state.
1138
+ * @param {number} [now] - current time, injected for testability.
1139
+ * @returns {Record<string, {marked: boolean, at: number}>} a JSON-ready record.
1140
+ */
1141
+ function serializeMarks(entries, now) {
1142
+ const time = now === undefined ? Date.now() : now
1143
+ const out = {}
1144
+ for (const [key, value] of entries) {
1145
+ if (time - value.at > MARKS_TTL) continue
1146
+ out[key] = { marked: value.marked, at: value.at }
1147
+ }
1148
+ return out
1149
+ }
1150
+
1151
+ /**
1152
+ * Read the persisted marks, or an empty map when storage is unavailable,
1153
+ * holds garbage, or throws (private mode, sandboxed frame, quota).
1154
+ * @returns {Map<string, {marked: boolean, at: number}>} the stored entries.
1155
+ */
1156
+ function readMarks() {
1157
+ try {
1158
+ if (typeof localStorage === 'undefined') return new Map()
1159
+ const text = localStorage.getItem(MARKS_KEY)
1160
+ if (text === null || text === '') return new Map()
1161
+ return parseMarks(JSON.parse(text), Date.now())
1162
+ } catch {
1163
+ return new Map()
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * Persist the marks. A write failure is swallowed: the in-memory store still
1169
+ * drives every open panel in this page.
1170
+ * @param {Map<string, {marked: boolean, at: number}>} entries - the state.
1171
+ */
1172
+ function writeMarks(entries) {
1173
+ try {
1174
+ if (typeof localStorage === 'undefined') return
1175
+ localStorage.setItem(MARKS_KEY, JSON.stringify(serializeMarks(entries)))
1176
+ } catch {
1177
+ /* storage unavailable — keep the in-memory state only */
1178
+ }
1179
+ }
1180
+
1181
+ /** Whether two visible mark maps hold the same entries. */
1182
+ function sameMarks(left, right) {
1183
+ if (left.size !== right.size) return false
1184
+ for (const key of left) {
1185
+ if (!right.has(key)) return false
1186
+ }
1187
+ return true
1188
+ }
1189
+
1190
+ /**
1191
+ * Create the one global mark store for this module generation.
1192
+ *
1193
+ * It owns the state, the listeners, and the Session-feed subscriptions; the
1194
+ * seats only read it. Observing EVERY Session (not just the one a seat
1195
+ * renders for) is what makes a failure recorded anywhere visible
1196
+ * everywhere, including in a Session whose own feed was never opened in this
1197
+ * page load (its marks come from storage).
1198
+ *
1199
+ * @param {object} deps - resolution hooks.
1200
+ * @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
1201
+ * @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
1202
+ * @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
1203
+ * @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`.
1204
+ */
1205
+ function createMarkStore(deps) {
1206
+ /** Internal truth: route key → last decision and when it was made. */
1207
+ const entries = new Map(deps.readStored())
1208
+ /** Visible subset the rows read: route key → true. */
1209
+ let visible = new Map()
1210
+ let snapshot = { marks: visible }
1211
+ const listeners = new Set()
1212
+ /** Per-Session route of the latest request header (a route is Session-local). */
1213
+ const routes = new Map()
1214
+ /** Per-Session stop handle for its event feed. */
1215
+ const stops = new Map()
1216
+ /** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
1217
+ const pinned = new Set()
1218
+ /** Last `sessions` service seen, so a reloaded service is re-attached. */
1219
+ let attachedSessions
1220
+ let stopList
1221
+ let disposed = false
1222
+
1223
+ /** Rebuild the visible map; report whether the rendered set changed. */
1224
+ const recompute = () => {
1225
+ const next = new Map()
1226
+ for (const [key, entry] of entries) {
1227
+ if (entry.marked) next.set(key, true)
1228
+ }
1229
+ if (sameMarks(visible, next)) return false
1230
+ visible = next
1231
+ snapshot = { marks: visible }
1232
+ return true
1233
+ }
1234
+
1235
+ recompute()
1236
+
1237
+ const publish = () => {
1238
+ for (const listener of listeners) listener()
1239
+ }
1240
+
1241
+ /** Fold one batch of durable entries for one Session. */
1242
+ const absorb = (sessionId, windowEntries) => {
1243
+ let stored = false
1244
+ let changed = false
1245
+ for (const entry of windowEntries) {
1246
+ const event = entry?.event
1247
+ if (event === null || event === undefined) continue
1248
+ const header = routeOfHeader(event)
1249
+ if (header !== null) {
1250
+ routes.set(sessionId, header)
1251
+ continue
1252
+ }
1253
+ if (event.type !== 'turn/end') continue
1254
+ const route = routes.get(sessionId) ?? null
1255
+ const marked = decideMark(event, route)
1256
+ if (marked === null) continue
1257
+ const result = foldMarkEntry(
1258
+ entries, routeKeyOf(route.provider, route.model), marked, eventTimeOf(event),
1259
+ )
1260
+ if (result.stored) stored = true
1261
+ if (result.visible) changed = true
1262
+ }
1263
+ return { stored, changed }
1264
+ }
1265
+
1266
+ /** Read a window's entries defensively. */
1267
+ const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
1268
+
1269
+ /** Apply one feed window for one Session. */
1270
+ const applyWindow = (sessionId, window, full) => {
1271
+ const change = window?.change
1272
+ let result
1273
+ if (full || change?.kind === 'replace') {
1274
+ // The window is that Session's whole truth, so its route restarts and
1275
+ // its entries are re-walked. Global watermarks keep a newer fact from
1276
+ // another Session from being overwritten by this replay.
1277
+ routes.delete(sessionId)
1278
+ result = absorb(sessionId, entriesOf(window))
1279
+ } else if (change?.kind === 'append') {
1280
+ result = absorb(sessionId, change.entries)
1281
+ } else {
1282
+ // `prepend` pages in OLDER history and `settle-assistant` only swaps a
1283
+ // live attempt for its durable form; neither can change a mark.
1284
+ return
1285
+ }
1286
+ if (result.stored) deps.writeStored(entries)
1287
+ if (result.changed && recompute()) publish()
1288
+ }
1289
+
1290
+ /**
1291
+ * Attach to one Session's durable feed. Idempotent, and a no-op when the
1292
+ * Session has no retained binding (an unopened Session has no feed; its
1293
+ * marks can only come from storage).
1294
+ */
1295
+ const attachSession = sessionId => {
1296
+ if (disposed || stops.has(sessionId)) return
1297
+ const sessions = deps.sessionsOf()
1298
+ let binding
1299
+ try {
1300
+ binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
1301
+ } catch {
1302
+ binding = undefined
1303
+ }
1304
+ const source = binding?.eventSource
1305
+ if (source === undefined || typeof source.subscribe !== 'function'
1306
+ || typeof source.getSnapshot !== 'function') return
1307
+ try {
1308
+ applyWindow(sessionId, source.getSnapshot(), true)
1309
+ } catch {
1310
+ /* an unreadable feed leaves the store as it was */
1311
+ }
1312
+ const listener = () => {
1313
+ try {
1314
+ applyWindow(sessionId, source.getSnapshot(), false)
1315
+ } catch {
1316
+ /* a fold failure must not escape into the slot */
1317
+ }
1318
+ }
1319
+ try {
1320
+ const stop = source.subscribe(listener)
1321
+ stops.set(sessionId, typeof stop === 'function' ? stop : () => {})
1322
+ } catch {
1323
+ /* subscription refused: leave this Session unattached */
1324
+ }
1325
+ }
1326
+
1327
+ /** Attach to every Session the list currently names, and drop the rest. */
1328
+ const sync = () => {
1329
+ if (disposed) return
1330
+ const sessions = deps.sessionsOf()
1331
+ if (sessions === undefined) return
1332
+ // A client reload replaces the service; re-attach from the new one.
1333
+ if (sessions !== attachedSessions) {
1334
+ for (const stop of stops.values()) stop()
1335
+ stops.clear()
1336
+ routes.clear()
1337
+ if (typeof stopList === 'function') stopList()
1338
+ stopList = undefined
1339
+ attachedSessions = sessions
1340
+ try {
1341
+ if (sessions.list !== undefined && typeof sessions.list.subscribe === 'function') {
1342
+ stopList = sessions.list.subscribe(() => { sync() })
1343
+ }
1344
+ } catch {
1345
+ stopList = undefined
1346
+ }
1347
+ }
1348
+ let ids = []
1349
+ try {
1350
+ const list = sessions.list?.getSnapshot?.()
1351
+ ids = Array.isArray(list?.ids) ? list.ids : []
1352
+ } catch {
1353
+ ids = []
1354
+ }
1355
+ for (const id of ids) attachSession(id)
1356
+ // Drop feeds for Sessions the list no longer names, unless a mounted
1357
+ // seat is currently reading that Session (it may not be listed yet).
1358
+ for (const id of [...stops.keys()]) {
1359
+ if (pinned.has(id)) continue
1360
+ if (!ids.includes(id)) {
1361
+ stops.get(id)?.()
1362
+ stops.delete(id)
1363
+ routes.delete(id)
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ return {
1369
+ /** The seat's `useSyncExternalStore` pair. */
1370
+ subscribe: listener => {
1371
+ listeners.add(listener)
1372
+ sync()
1373
+ return () => { listeners.delete(listener) }
1374
+ },
1375
+ getSnapshot: () => snapshot,
1376
+ /** Re-attach (services may bind after the plugin activated). */
1377
+ sync,
1378
+ /**
1379
+ * Pin and immediately attach one Session's feed, for a mounted seat.
1380
+ * The list may not name this Session yet (or at all), so it is attached
1381
+ * directly instead of waiting for a list sync.
1382
+ */
1383
+ watch: sessionId => {
1384
+ if (disposed) return
1385
+ pinned.add(sessionId)
1386
+ sync()
1387
+ attachSession(sessionId)
1388
+ },
1389
+ /** Release every feed subscription. The marks themselves stay persisted. */
1390
+ dispose: () => {
1391
+ if (disposed) return
1392
+ disposed = true
1393
+ for (const stop of stops.values()) stop()
1394
+ stops.clear()
1395
+ routes.clear()
1396
+ pinned.clear()
1397
+ if (typeof stopList === 'function') stopList()
1398
+ stopList = undefined
1399
+ listeners.clear()
1400
+ },
1401
+ }
1402
+ }
1403
+
1017
1404
  const dict = {
1018
1405
  zh: {
1019
1406
  'provider.account': 'DeepSeek 账号',
@@ -1101,7 +1488,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1101
1488
 
1102
1489
  /**
1103
1490
  * Shared empty mark map. One frozen instance keeps `getSnapshot` referentially
1104
- * stable for every seat that has no Session feed, so an unmarked panel never
1491
+ * stable for every seat that has no store, so an unmarked panel never
1105
1492
  * re-renders because of this feature.
1106
1493
  */
1107
1494
  const EMPTY_INSUFFICIENT_MARKS = new Map()
@@ -1115,13 +1502,13 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1115
1502
  const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
1116
1503
 
1117
1504
  /**
1118
- * Stand-in mark face used before a Session's feed resolves (or when the face
1119
- * degraded). It never notifies and always answers the same empty snapshot.
1505
+ * Stand-in mark face used when the store degraded. It never notifies and
1506
+ * always answers the same empty snapshot.
1120
1507
  */
1121
1508
  const EMPTY_INSUFFICIENT = {
1122
1509
  subscribe: () => () => {},
1123
1510
  getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
1124
- dispose: () => {},
1511
+ watch: () => {},
1125
1512
  }
1126
1513
 
1127
1514
  // ---------------------------------------------------------------------
@@ -1150,18 +1537,20 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1150
1537
  const getSnapshot = useCallback(() => store.getSnapshot(), [store])
1151
1538
  const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
1152
1539
 
1153
- // Per-model insufficient-balance marks, folded from the Session's own
1154
- // durable event feed by the face. The fallback keeps the hook shape stable
1155
- // for a composition that omits the face.
1540
+ // Per-model insufficient-balance marks, read from the ONE global store so
1541
+ // a failure recorded in any Session or workspace strikes the model here
1542
+ // too. The fallback keeps the hook shape stable when the face is omitted.
1156
1543
  const markFace = insufficient ?? EMPTY_INSUFFICIENT
1157
1544
  const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
1158
1545
  const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
1159
1546
  const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
1160
1547
 
1161
- // The mark face observes the Session feed for as long as this seat is
1162
- // mounted. Detaching on unmount cannot lose a mark: a remount refolds from
1163
- // the full durable window, so the state is rebuilt rather than remembered.
1164
- useEffect(() => () => { markFace.dispose() }, [markFace])
1548
+ // Keep the store attached to this Session's feed while the seat is mounted.
1549
+ // It is shared, so unmounting must NOT dispose it — the marks belong to the
1550
+ // page, not to this seat.
1551
+ useEffect(() => {
1552
+ if (typeof markFace.watch === 'function') markFace.watch(sessionId)
1553
+ }, [markFace, sessionId])
1165
1554
 
1166
1555
  const [open, setOpen] = useState(false)
1167
1556
  const [query, setQuery] = useState('')
@@ -1661,11 +2050,44 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1661
2050
  }, block.label)
1662
2051
  : renderRow(block.entry))
1663
2052
 
2053
+ // Each child is placed EXPLICITLY (`grid-column` below), never by
2054
+ // auto-placement: a divider that pinned its row but not its column was
2055
+ // laid out first, took column 1, and pushed the columns into columns 2
2056
+ // and 3 — which squashed the first text column to 1px and stacked the
2057
+ // columns on top of each other. Track 2k+1 holds column k (0-based) and
2058
+ // track 2k+2 the gutter that follows it, so a 3-column layout is
2059
+ // col gutter col gutter col.
1664
2060
  const renderColumn = (column, columnIndex) => h('div', {
1665
- className: 'dshmf-col' + (plan.count > 1 && columnIndex < plan.count - 1 ? ' dshmf-col--divided' : ''),
2061
+ className: 'dshmf-col',
1666
2062
  key: `col-${columnIndex}`,
2063
+ style: { gridColumn: String(columnIndex * 2 + 1), gridRow: '1' },
1667
2064
  }, column.map(unit => unit.blocks.map(renderBlock)))
1668
2065
 
2066
+ // Vertical separators are siblings of the columns, not a border on one
2067
+ // of them: a border can only ever be as tall as its own column, while a
2068
+ // grid item can stretch to the whole grid height (see .dshmf-colDivider).
2069
+ // Called with the index of the column that PRECEDES the gutter.
2070
+ const renderDivider = dividerIndex => h('div', {
2071
+ className: 'dshmf-colDivider',
2072
+ role: 'presentation',
2073
+ key: `gutter-${dividerIndex}`,
2074
+ style: { gridColumn: String(dividerIndex * 2), gridRow: '1' },
2075
+ })
2076
+
2077
+ const renderColumns = () => {
2078
+ const children = []
2079
+ plan.columns.forEach((column, columnIndex) => {
2080
+ if (columnIndex > 0) children.push(renderDivider(columnIndex))
2081
+ children.push(renderColumn(column, columnIndex))
2082
+ })
2083
+ return children
2084
+ }
2085
+
2086
+ // `minmax(0, 1fr)` per column, a hairline track per gutter between them.
2087
+ const gridTemplateColumns = plan.columns
2088
+ .map(() => 'minmax(0, 1fr)')
2089
+ .join(' 1px ')
2090
+
1669
2091
  const renderFailure = failure => h('div', {
1670
2092
  className: 'dshmf-failure',
1671
2093
  key: `failure-${failure.id}`,
@@ -1819,13 +2241,15 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1819
2241
  h('div', {
1820
2242
  className: 'dshmf-grid',
1821
2243
  style: {
1822
- gridTemplateColumns: `repeat(${plan.count}, minmax(0, 1fr))`,
2244
+ gridTemplateColumns: showEmptyState
2245
+ ? 'minmax(0, 1fr)'
2246
+ : gridTemplateColumns,
1823
2247
  columnGap: `${COL_GAP}px`,
1824
2248
  },
1825
2249
  },
1826
2250
  showEmptyState
1827
2251
  ? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
1828
- : plan.columns.map(renderColumn),
2252
+ : renderColumns(),
1829
2253
  state.failures.length === 0 ? null : state.failures.map(renderFailure),
1830
2254
  ),
1831
2255
 
@@ -2042,7 +2466,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2042
2466
  // Pure layout and grouping derivations plus the component itself,
2043
2467
  // exposed for `node --test` so grouping, filtering and the column
2044
2468
  // algorithm are pinned without a browser. The shell never reads this key.
2045
- __test: { familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, foldInsufficient, routeOfHeader, routeKeyOf },
2469
+ __test: { 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, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore },
2046
2470
  __component: ModelFlatMap,
2047
2471
  __seat: Seat,
2048
2472
  apply(ctx) {
@@ -2099,6 +2523,33 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2099
2523
  }
2100
2524
  }
2101
2525
 
2526
+ // ---- the GLOBAL insufficient-balance store
2527
+ //
2528
+ // Created once per activation and shared by every seat, because the
2529
+ // balance state belongs to the model route (and its account), not to one
2530
+ // conversation or workspace. It observes every Session the list names,
2531
+ // persists to `localStorage`, and starts from what the last page load
2532
+ // recorded. A failure to build it degrades to an empty face rather than
2533
+ // costing the control.
2534
+ let markStore
2535
+ try {
2536
+ markStore = createMarkStore({
2537
+ sessionsOf: () => {
2538
+ try {
2539
+ return typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
2540
+ } catch {
2541
+ return undefined
2542
+ }
2543
+ },
2544
+ readStored: readMarks,
2545
+ writeStored: writeMarks,
2546
+ })
2547
+ ctx.effect(() => () => { markStore.dispose() }, 'dsh-model-flatmap: balance marks')
2548
+ } catch (error) {
2549
+ console.error('dsh-model-flatmap: the balance mark store failed to start', error)
2550
+ markStore = undefined
2551
+ }
2552
+
2102
2553
  /**
2103
2554
  * Build the injected face for one Session.
2104
2555
  * @param sessionId - the Session this seat renders for.
@@ -2137,146 +2588,27 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2137
2588
  }
2138
2589
  const first = resolve()
2139
2590
 
2140
- // ---- insufficient-balance marks, folded from the live event feed
2141
- //
2142
- // `sessions.binding(id).eventSource` is the Client's contiguous
2143
- // durable event window (the same feed the Chat reads `turn/end` from).
2144
- // The face keeps ONE immutable snapshot object and replaces it only
2145
- // when a fold actually changes the marks, so `useSyncExternalStore`
2146
- // re-renders the panel on a real transition and stays quiet on every
2147
- // other event.
2148
- const insufficientListeners = new Set()
2149
- let insufficientMarks = new Map()
2150
- let insufficientSnapshot = { marks: insufficientMarks }
2151
- // The route of the latest request header: a `turn/end` belongs to the
2152
- // model that request was dispatched to.
2153
- let pendingRoute = null
2154
- let stopInsufficient = null
2155
-
2156
- /** Publish a new snapshot only when the mark set really changed. */
2157
- const publishInsufficient = () => {
2158
- insufficientSnapshot = { marks: insufficientMarks }
2159
- for (const listener of insufficientListeners) listener()
2160
- }
2161
-
2162
- /** Read the durable entries out of the current window snapshot. */
2163
- const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
2164
-
2165
- /**
2166
- * Fold one batch of durable entries into the marks. Live chunks carry
2167
- * no `turn/end`, so they are skipped by type inside the fold.
2168
- * @param entries - durable event entries from the feed.
2169
- * @returns whether any mark changed.
2170
- */
2171
- const absorb = entries => {
2172
- let changed = false
2173
- for (const entry of entries) {
2174
- const event = entry?.event
2175
- if (event === null || event === undefined) continue
2176
- const header = routeOfHeader(event)
2177
- if (header !== null) {
2178
- pendingRoute = header
2179
- continue
2180
- }
2181
- if (event.type !== 'turn/end') continue
2182
- if (foldInsufficient(insufficientMarks, event, pendingRoute)) changed = true
2183
- }
2184
- return changed
2185
- }
2186
-
2187
- /**
2188
- * Attach the feed listener. A `replace` (initial load, history
2189
- * replacement) refolds from scratch, because the window it describes
2190
- * is the whole truth; an `append` folds only the new entries. A
2191
- * `prepend` loads OLDER history at the front and cannot change a mark
2192
- * (marks come from the tail), so it is ignored rather than refolded —
2193
- * refolding would also re-walk history on every page-in.
2194
- */
2195
- const startInsufficientFeed = () => {
2196
- if (stopInsufficient !== null) return
2197
- let binding
2591
+ // Attach this Session's feed as soon as its seat exists, not only
2592
+ // when a subscriber appears: the seat always subscribes, but the
2593
+ // face is built before the first render, and a feed that is already
2594
+ // live should contribute its history immediately.
2595
+ if (markStore !== undefined) {
2198
2596
  try {
2199
- binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
2597
+ markStore.watch(sessionId)
2200
2598
  } catch {
2201
- binding = undefined
2202
- }
2203
- const source = binding?.eventSource
2204
- if (source === undefined || typeof source.subscribe !== 'function'
2205
- || typeof source.getSnapshot !== 'function') return
2206
- /** Whether two mark maps hold the same entries. */
2207
- const sameMarks = (left, right) => {
2208
- if (left.size !== right.size) return false
2209
- for (const [key, value] of left) {
2210
- if (right.get(key) !== value) return false
2211
- }
2212
- return true
2213
- }
2214
- const applyWindow = (window, full) => {
2215
- const change = window?.change
2216
- if (full || change?.kind === 'replace') {
2217
- const previous = insufficientMarks
2218
- const next = new Map()
2219
- insufficientMarks = next
2220
- pendingRoute = null
2221
- absorb(entriesOf(window))
2222
- // Adopt the refolded map as the snapshot either way, so the
2223
- // snapshot and the live state never diverge; only NOTIFY when
2224
- // the marks really changed, so a history replacement that
2225
- // re-derives the same marks does not repaint the panel.
2226
- insufficientSnapshot = { marks: next }
2227
- if (!sameMarks(previous, next)) publishInsufficient()
2228
- return
2229
- }
2230
- if (change?.kind === 'append' && absorb(change.entries)) publishInsufficient()
2231
- }
2232
- try {
2233
- applyWindow(source.getSnapshot(), true)
2234
- } catch {
2235
- /* an unreadable feed leaves the panel unmarked, never broken */
2236
- }
2237
- const listener = () => {
2238
- try {
2239
- applyWindow(source.getSnapshot(), false)
2240
- } catch {
2241
- /* a fold failure must not escape into the slot */
2242
- }
2243
- }
2244
- try {
2245
- const stop = source.subscribe(listener)
2246
- stopInsufficient = typeof stop === 'function' ? stop : () => {}
2247
- } catch {
2248
- stopInsufficient = null
2599
+ /* the store degrades to storage-only state */
2249
2600
  }
2250
2601
  }
2251
2602
 
2252
- startInsufficientFeed()
2253
-
2254
- /**
2255
- * The seat's read face for the marks. `subscribe` and `getSnapshot`
2256
- * are the `useSyncExternalStore` pair; the snapshot object is stable
2257
- * between real transitions.
2258
- */
2259
- const insufficient = {
2260
- subscribe: listener => {
2261
- insufficientListeners.add(listener)
2262
- // The binding may have materialized after the face was built.
2263
- startInsufficientFeed()
2264
- return () => { insufficientListeners.delete(listener) }
2265
- },
2266
- getSnapshot: () => insufficientSnapshot,
2267
- /** Stop observing the feed; called when the seat's owner disposes. */
2268
- dispose: () => {
2269
- if (stopInsufficient !== null) stopInsufficient()
2270
- stopInsufficient = null
2271
- insufficientListeners.clear()
2272
- },
2273
- }
2274
-
2275
2603
  return {
2276
2604
  sessionId,
2277
2605
  resolveDirectory: resolve,
2278
2606
  safeRead,
2279
- insufficient,
2607
+ // The GLOBAL mark store, shared by every seat of this plugin
2608
+ // generation. `subscribe` additionally attaches the store to this
2609
+ // Session's feed, so a Session that binds late (or after the page
2610
+ // loaded) still contributes its events.
2611
+ insufficient: markStore ?? EMPTY_INSUFFICIENT,
2280
2612
  // `available: false` only for an addressed subagent Session, which
2281
2613
  // the shipped seat also hides.
2282
2614
  available: first.addressed !== true,
@@ -2336,11 +2668,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2336
2668
  // component's hook shape never changes and the rows simply stay
2337
2669
  // unstruck. It shares the module-level empty snapshot so the
2338
2670
  // identity stays stable across renders.
2339
- insufficient: {
2340
- subscribe: () => () => {},
2341
- getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
2342
- dispose: () => {},
2343
- },
2671
+ insufficient: EMPTY_INSUFFICIENT,
2344
2672
  }
2345
2673
  }
2346
2674
  }
package/docs/DETAIL.md CHANGED
@@ -77,8 +77,9 @@ and the agent teamwork switch are all visible at once.
77
77
  side, otherwise the footer wraps back to the original two-line stack. The
78
78
  break is decided by CSS flex wrapping and the 30 px `column-gap`, never by a
79
79
  JS width threshold, so it stays correct across panel resizes, font loads and
80
- both themes. The single grey rule above the reasoning group stays where it
81
- is in both states; the teamwork row draws no divider of its own (DEC-011).
80
+ both themes. The single grey rule belongs to the footer container, so it sits
81
+ above the whole footer block and stays put in both states; neither group
82
+ draws a divider of its own (DEC-016).
82
83
  The fixed height budget measures the footer container once, not its two
83
84
  children, so a shared line is not double-counted against the grid.
84
85
  - **Selected model.** The current model reads as a solid deep-blue band with
@@ -136,11 +137,31 @@ and the agent teamwork switch are all visible at once.
136
137
  that failed for a different reason. A cancellation (`aborted`), a `blocked`
137
138
  turn, and the synthetic `interrupted` / `forked` closers clear nothing —
138
139
  the model never answered, so they say nothing about the account.
139
- - **Where it comes from.** The seat folds the Session's own durable event
140
- feed (`sessions.binding(id).eventSource`), which is the same source the
141
- Chat reads its quota notice from. No Host change is involved. A history
142
- replacement refolds from scratch; paging in older history cannot change a
143
- mark and is ignored.
140
+ - **The marks are global, not per conversation.** Whether a model has quota
141
+ left is a fact about the model route and its account, so the state is NOT
142
+ scoped to the Session a panel happens to render for. One module-level store
143
+ per plugin generation observes the durable feed of **every** Session the
144
+ Session list names, and every seat reads that one store. A failure recorded
145
+ in any Session therefore strikes the same model in every Session and
146
+ workspace, including in a Session whose feed this page never opened.
147
+ - **It survives a reload.** The marks are persisted to `localStorage`
148
+ (origin-scoped, like the grouping choice), so a fresh page load starts from
149
+ the last known state instead of an empty one. Reads and writes degrade
150
+ silently when storage is unavailable, and records older than 7 days are
151
+ dropped so a stale strike cannot live forever.
152
+ - **The newest observation wins.** A Session feed carries no cross-Session
153
+ sequence number, but every durable event keeps the Host's wall-clock `time`.
154
+ Each route keeps the time of its last decision, and an observation older
155
+ than the stored one is ignored. This is what makes the global state safe:
156
+ replaying an older window (a history replacement, or a second Session whose
157
+ history is older) cannot resurrect a strike that a newer call already
158
+ cleared, and a cleared route keeps its watermark for the same reason.
159
+ - **Where it comes from.** The store folds each Session's durable event feed
160
+ (`sessions.binding(id).eventSource`), which is the same source the Chat
161
+ reads its quota notice from, and re-attaches when the `sessions` service is
162
+ replaced. No Host change is involved. A history replacement refolds that
163
+ Session from scratch; `prepend` pages in older history and cannot change a
164
+ mark, so it is ignored.
144
165
  - **Agent teamwork.** The switch keeps the host's semantics: the same label,
145
166
  the same confirmation before disabling it mid-conversation, and no optimistic
146
167
  update — the state comes from the `agentTeam` projection. The row lives in
@@ -157,9 +178,11 @@ Everything goes through public Client APIs:
157
178
  - The directory store is subscribed through `useSyncExternalStore`, so a
158
179
  catalog update — including one caused by a selection — repaints the panel.
159
180
  - `ctx.sessions.binding(sessionId).eventSource` is the Session's contiguous
160
- durable event window. The seat folds it to derive the per-model
161
- insufficient-balance marks, and subscribes through `useSyncExternalStore` so
162
- a mark transition repaints the panel while every other event stays quiet.
181
+ durable event window. A single module-level store folds the feed of **every**
182
+ Session the Session list names (`ctx.sessions.list`) to derive the global
183
+ per-model insufficient-balance marks, persists them to `localStorage`, and
184
+ every seat subscribes through `useSyncExternalStore` so a mark transition
185
+ repaints the panel while every other event stays quiet.
163
186
  - `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
164
187
  - `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
165
188
  host's own `agent-team` wording when that namespace is registered.
Binary file
package/icon.svg CHANGED
@@ -1,11 +1,11 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Flat model picker">
2
2
  <title>Flat model picker</title>
3
- <rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="currentColor" stroke-width="3" opacity="0.85"/>
4
- <rect x="11" y="15" width="20" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
5
- <rect x="35" y="15" width="18" height="5" rx="2.5" fill="currentColor" opacity="0.25"/>
6
- <rect x="11" y="25" width="20" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
7
- <rect x="35" y="25" width="18" height="5" rx="2.5" fill="currentColor" opacity="0.25"/>
8
- <rect x="11" y="35" width="20" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
9
- <rect x="35" y="35" width="18" height="5" rx="2.5" fill="currentColor" opacity="0.25"/>
10
- <rect x="11" y="45" width="42" height="4" rx="2" fill="currentColor" opacity="0.18"/>
3
+ <rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="#3B82F6" stroke-width="3"/>
4
+ <rect x="11" y="15" width="20" height="5" rx="2.5" fill="#3B82F6"/>
5
+ <rect x="35" y="15" width="18" height="5" rx="2.5" fill="#60A5FA"/>
6
+ <rect x="11" y="25" width="20" height="5" rx="2.5" fill="#3B82F6"/>
7
+ <rect x="35" y="25" width="18" height="5" rx="2.5" fill="#60A5FA"/>
8
+ <rect x="11" y="35" width="20" height="5" rx="2.5" fill="#3B82F6"/>
9
+ <rect x="35" y="35" width="18" height="5" rx="2.5" fill="#60A5FA"/>
10
+ <rect x="11" y="45" width="42" height="4" rx="2" fill="#93C5FD"/>
11
11
  </svg>
package/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Host half of the flat model picker bundle.
3
3
  *
4
- * The plugin is browser-only: it replaces the composer's `conversation.input.model`
5
- * seat with a flat panel rendered in the Web GUI, and reads the Session's model
6
- * directory, projections and `session.selectModel` through public Client APIs.
4
+ * The plugin is client-side and runs in both the Web GUI and the Desktop app: it
5
+ * replaces the composer's `conversation.input.model` seat with a flat panel rendered
6
+ * in the client UI, and reads the Session's model directory, projections and
7
+ * `session.selectModel` through public Client APIs.
7
8
  * The Host therefore contributes no service, tool or event.
8
9
  */
9
10
  export const name = 'dsh-model-flatmap'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-flatmap",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Flat, two-level-free model picker for the DSH composer: every model and reasoning level on one surface.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,9 @@
19
19
  "icon.svg"
20
20
  ],
21
21
  "dsh": {
22
- "bundle": { "patch": "./cordis.patch.yml" },
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ },
23
25
  "client": {
24
26
  "platform": "web",
25
27
  "immediately": true