dsh-model-flatmap 1.0.0 → 1.0.1

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
@@ -118,16 +118,32 @@ window.__ModuleLoader__.load({
118
118
  color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
119
119
  }
120
120
 
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; }
121
+ /* The only scrolling region. Its "align-items: start" keeps the text columns
122
+ at their natural height; a divider laid out beside them opts back into
123
+ stretch so it spans the tallest column instead (see .dshmf-colDivider).
124
+ Every child is placed explicitly by grid-column (and the divider by
125
+ grid-row) — see the inline style on each one. Auto-placement must NOT be
126
+ relied on here: a divider that pins its row but not its column is placed
127
+ first, stealing column 1, and the columns then overflow to columns 2 and 3,
128
+ squashing the first text column. */
129
+ .dshmf-grid {
130
+ flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto;
131
+ display: grid; align-items: start;
132
+ }
123
133
  .dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
124
134
  /* When more than one column is laid out, separate adjacent columns with a
125
135
  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; }
136
+ The hairline is its OWN grid item, a sibling of the columns rather than a
137
+ border on one of them: as a border it could only ever be as tall as that
138
+ column's content. Here it sits in a dedicated gutter track and stretches to
139
+ the grid's full height, with a small margin top and bottom.
140
+ Only gutters that actually sit between two columns get one (see the grid
141
+ children), so a single-column layout stays borderless. */
142
+ .dshmf-colDivider {
143
+ grid-row: 1; align-self: stretch; justify-self: center;
144
+ width: 1px; margin: 6px 0;
145
+ background: var(--dsw-alias-border-l3);
146
+ }
131
147
  /* Provider / family label: a filled, rounded band that reads as a section
132
148
  header, clearly separated from the model rows below it. Bare host
133
149
  group-header fill is only a few levels from the translucent panel surface.
@@ -240,30 +256,28 @@ window.__ModuleLoader__.load({
240
256
  No JS width guessing, so it stays correct across panel resize, theme and
241
257
  font changes. A max-width of 100% lets a child that ends up alone on a line
242
258
  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. */
259
+ The divider sits on this container, not on a child (DEC-016): the two groups
260
+ can share a line or stack, and either way the rule belongs above the whole
261
+ footer block, never between them, so no child carries a border. Host border
262
+ tokens are faint by design, so the line uses explicit greys: medium grey on
263
+ the light panel, a lighter near-white grey on the dark panel. */
246
264
  .dshmf-footer {
247
265
  flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
248
266
  column-gap: 30px; row-gap: 4px;
267
+ border-top: 1px solid #9CA3AF;
268
+ padding-top: 6px;
249
269
  }
270
+ body[data-ds-dark-theme] .dshmf-footer,
271
+ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
250
272
 
251
273
  /* Reasoning levels: a caption and a multi-segmentation control on one line;
252
274
  the track wraps below the caption only when the row cannot fit. The
253
275
  segment visual mirrors the header's grouping control, so the panel has
254
276
  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
277
  .dshmf-reason {
260
278
  flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
261
279
  gap: 2px 6px; max-width: 100%;
262
- border-top: 1px solid #9CA3AF;
263
- padding-top: 6px;
264
280
  }
265
- body[data-ds-dark-theme] .dshmf-reason,
266
- html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
267
281
  .dshmf-reasonLabel {
268
282
  flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
269
283
  white-space: nowrap;
@@ -568,37 +582,88 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
568
582
  const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
569
583
 
570
584
  /**
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.
585
+ * Wall-clock time one durable event was recorded at. The Client feed keeps
586
+ * the Host's `time` (`Date.now()` at append), which is what lets marks
587
+ * gathered from DIFFERENT Sessions be ordered against each other — a Session
588
+ * feed has no cross-Session sequence number.
589
+ * @param {unknown} event - one durable Session event.
590
+ * @returns {number} the event time, or 0 when the feed carried none.
591
+ */
592
+ function eventTimeOf(event) {
593
+ const time = event?.time
594
+ return typeof time === 'number' && Number.isFinite(time) ? time : 0
595
+ }
596
+
597
+ /**
598
+ * Decide what ONE durable event says about a model route's balance, with no
599
+ * state involved. This is the whole semantic core of the feature, kept
600
+ * separate from the bookkeeping so it can be pinned directly.
574
601
  *
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.
602
+ * - A `turn/end` that failed on insufficient balance → `true` (mark).
603
+ * - A `turn/end` that proves the model answered otherwise → `false`
604
+ * (clear): a completed turn, a `max-tokens` turn, or a turn that failed
605
+ * for a different reason.
606
+ * - A cancellation (`aborted`), a `blocked` turn, and the synthetic
607
+ * `interrupted` / `forked` closers → `null` (no decision), because the
608
+ * model never answered and the account state is unknown.
609
+ * - Anything that is not a `turn/end`, or has no route → `null`.
610
+ * @param {unknown} event - one durable Session event.
611
+ * @param {{ provider: string, model: string } | null} route - the route that request used.
612
+ * @returns {boolean | null} mark, clear, or no decision.
586
613
  */
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
614
+ function decideMark(event, route) {
615
+ if (event === null || typeof event !== 'object') return null
616
+ if (event.type !== 'turn/end' || route === null || route === undefined) return null
590
617
  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
618
+ if (reason === null || typeof reason !== 'object') return null
619
+ if (reason.kind === 'error') return isInsufficientBalance(reason.error)
620
+ return CLEARING_TURN_ENDS.has(reason.kind) ? false : null
621
+ }
622
+
623
+ /**
624
+ * Fold ONE decision into the mark map, last-writer-wins by event time.
625
+ *
626
+ * The time watermark is what makes the state GLOBAL and replay-safe: marks
627
+ * arrive from every Session's feed (and from storage written by an earlier
628
+ * page load), so the newest observation wins no matter which Session it came
629
+ * from. Re-walking an old window therefore cannot resurrect a mark that a
630
+ * later call already cleared, and a cleared route keeps a watermark so a
631
+ * stale replay stays a no-op.
632
+ * @param {Map<string, {marked: boolean, at: number}>} entries - state, mutated in place.
633
+ * @param {string} key - the route key.
634
+ * @param {boolean} marked - the decision this event carries.
635
+ * @param {number} at - the event's time.
636
+ * @returns {{ visible: boolean, stored: boolean }} whether the visible mark
637
+ * set changed, and whether the underlying record changed.
638
+ */
639
+ function foldMarkEntry(entries, key, marked, at) {
640
+ const previous = entries.get(key)
641
+ if (previous !== undefined && at < previous.at) {
642
+ return { visible: false, stored: false }
597
643
  }
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
644
+ if (previous !== undefined && previous.marked === marked && previous.at === at) {
645
+ return { visible: false, stored: false }
646
+ }
647
+ entries.set(key, { marked, at })
648
+ return { visible: previous === undefined ? marked : previous.marked !== marked, stored: true }
649
+ }
650
+
651
+ /**
652
+ * Fold one durable event into the mark map. Pure helper kept for the tests
653
+ * and for callers that hold their own map; the live store uses
654
+ * {@link decideMark} + {@link foldMarkEntry} so it can batch persistence.
655
+ * @param {Map<string, {marked: boolean, at: number}>} state - state, mutated in place.
656
+ * @param {unknown} event - one durable Session event.
657
+ * @param {{ provider: string, model: string } | null} route - the route that request used.
658
+ * @param {number} [at] - the event's time; defaults to `event.time`.
659
+ * @returns {boolean} whether the visible mark set changed.
660
+ */
661
+ function foldInsufficient(state, event, route, at) {
662
+ const marked = decideMark(event, route)
663
+ if (marked === null) return false
664
+ const key = routeKeyOf(route.provider, route.model)
665
+ const time = at === undefined ? eventTimeOf(event) : at
666
+ return foldMarkEntry(state, key, marked, time).visible
602
667
  }
603
668
 
604
669
  /**
@@ -1014,6 +1079,320 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1014
1079
  }
1015
1080
  }
1016
1081
 
1082
+ // ---------------------------------------------------------------------
1083
+ // Insufficient-balance marks are GLOBAL, not Session-scoped.
1084
+ //
1085
+ // Whether a model has any quota left is a fact about the model route (and
1086
+ // the account behind it), not about one conversation: a failure observed in
1087
+ // one Session must strike the same model everywhere — another Session,
1088
+ // another workspace, and after a reload. So the marks live in ONE
1089
+ // module-level store shared by every seat of this plugin generation, and
1090
+ // are persisted to `localStorage` (origin-scoped, exactly like the grouping
1091
+ // choice) so a fresh page load starts from the last known state.
1092
+ //
1093
+ // Ordering across Sessions is by wall-clock time: each durable event keeps
1094
+ // the Host's `time`, and the newest observation of a route wins. That is
1095
+ // what lets a replayed window (or a stored entry) never override a newer
1096
+ // fact from another Session.
1097
+ // ---------------------------------------------------------------------
1098
+ const MARKS_KEY = 'dshmf:insufficient'
1099
+ /** Marks older than this are dropped on read, so a stale strike cannot live forever. */
1100
+ const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
1101
+
1102
+ /**
1103
+ * Parse a persisted mark record into the in-memory shape, dropping anything
1104
+ * malformed or older than {@link MARKS_TTL}. Pure, so the storage format is
1105
+ * testable without a `localStorage` shim.
1106
+ * @param {unknown} raw - the stored JSON value (already parsed).
1107
+ * @param {number} now - current time, injected for testability.
1108
+ * @returns {Map<string, {marked: boolean, at: number}>} the parsed entries.
1109
+ */
1110
+ function parseMarks(raw, now) {
1111
+ const entries = new Map()
1112
+ if (raw === null || typeof raw !== 'object') return entries
1113
+ for (const [key, value] of Object.entries(raw)) {
1114
+ if (typeof key !== 'string' || key === '') continue
1115
+ if (value === null || typeof value !== 'object') continue
1116
+ const at = typeof value.at === 'number' && Number.isFinite(value.at) ? value.at : 0
1117
+ if (typeof value.marked !== 'boolean') continue
1118
+ if (now - at > MARKS_TTL) continue
1119
+ entries.set(key, { marked: value.marked, at })
1120
+ }
1121
+ return entries
1122
+ }
1123
+
1124
+ /**
1125
+ * Serialise the in-memory entries for storage, dropping anything older than
1126
+ * {@link MARKS_TTL} so a long-lived page cannot grow the record forever. A
1127
+ * cleared route keeps its watermark only while it can still protect against
1128
+ * a replayed older failure, which is the same window.
1129
+ * @param {Map<string, {marked: boolean, at: number}>} entries - the state.
1130
+ * @param {number} [now] - current time, injected for testability.
1131
+ * @returns {Record<string, {marked: boolean, at: number}>} a JSON-ready record.
1132
+ */
1133
+ function serializeMarks(entries, now) {
1134
+ const time = now === undefined ? Date.now() : now
1135
+ const out = {}
1136
+ for (const [key, value] of entries) {
1137
+ if (time - value.at > MARKS_TTL) continue
1138
+ out[key] = { marked: value.marked, at: value.at }
1139
+ }
1140
+ return out
1141
+ }
1142
+
1143
+ /**
1144
+ * Read the persisted marks, or an empty map when storage is unavailable,
1145
+ * holds garbage, or throws (private mode, sandboxed frame, quota).
1146
+ * @returns {Map<string, {marked: boolean, at: number}>} the stored entries.
1147
+ */
1148
+ function readMarks() {
1149
+ try {
1150
+ if (typeof localStorage === 'undefined') return new Map()
1151
+ const text = localStorage.getItem(MARKS_KEY)
1152
+ if (text === null || text === '') return new Map()
1153
+ return parseMarks(JSON.parse(text), Date.now())
1154
+ } catch {
1155
+ return new Map()
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Persist the marks. A write failure is swallowed: the in-memory store still
1161
+ * drives every open panel in this page.
1162
+ * @param {Map<string, {marked: boolean, at: number}>} entries - the state.
1163
+ */
1164
+ function writeMarks(entries) {
1165
+ try {
1166
+ if (typeof localStorage === 'undefined') return
1167
+ localStorage.setItem(MARKS_KEY, JSON.stringify(serializeMarks(entries)))
1168
+ } catch {
1169
+ /* storage unavailable — keep the in-memory state only */
1170
+ }
1171
+ }
1172
+
1173
+ /** Whether two visible mark maps hold the same entries. */
1174
+ function sameMarks(left, right) {
1175
+ if (left.size !== right.size) return false
1176
+ for (const key of left) {
1177
+ if (!right.has(key)) return false
1178
+ }
1179
+ return true
1180
+ }
1181
+
1182
+ /**
1183
+ * Create the one global mark store for this module generation.
1184
+ *
1185
+ * It owns the state, the listeners, and the Session-feed subscriptions; the
1186
+ * seats only read it. Observing EVERY Session (not just the one a seat
1187
+ * renders for) is what makes a failure recorded anywhere visible
1188
+ * everywhere, including in a Session whose own feed was never opened in this
1189
+ * page load (its marks come from storage).
1190
+ *
1191
+ * @param {object} deps - resolution hooks.
1192
+ * @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
1193
+ * @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
1194
+ * @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
1195
+ * @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`.
1196
+ */
1197
+ function createMarkStore(deps) {
1198
+ /** Internal truth: route key → last decision and when it was made. */
1199
+ const entries = new Map(deps.readStored())
1200
+ /** Visible subset the rows read: route key → true. */
1201
+ let visible = new Map()
1202
+ let snapshot = { marks: visible }
1203
+ const listeners = new Set()
1204
+ /** Per-Session route of the latest request header (a route is Session-local). */
1205
+ const routes = new Map()
1206
+ /** Per-Session stop handle for its event feed. */
1207
+ const stops = new Map()
1208
+ /** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
1209
+ const pinned = new Set()
1210
+ /** Last `sessions` service seen, so a reloaded service is re-attached. */
1211
+ let attachedSessions
1212
+ let stopList
1213
+ let disposed = false
1214
+
1215
+ /** Rebuild the visible map; report whether the rendered set changed. */
1216
+ const recompute = () => {
1217
+ const next = new Map()
1218
+ for (const [key, entry] of entries) {
1219
+ if (entry.marked) next.set(key, true)
1220
+ }
1221
+ if (sameMarks(visible, next)) return false
1222
+ visible = next
1223
+ snapshot = { marks: visible }
1224
+ return true
1225
+ }
1226
+
1227
+ recompute()
1228
+
1229
+ const publish = () => {
1230
+ for (const listener of listeners) listener()
1231
+ }
1232
+
1233
+ /** Fold one batch of durable entries for one Session. */
1234
+ const absorb = (sessionId, windowEntries) => {
1235
+ let stored = false
1236
+ let changed = false
1237
+ for (const entry of windowEntries) {
1238
+ const event = entry?.event
1239
+ if (event === null || event === undefined) continue
1240
+ const header = routeOfHeader(event)
1241
+ if (header !== null) {
1242
+ routes.set(sessionId, header)
1243
+ continue
1244
+ }
1245
+ if (event.type !== 'turn/end') continue
1246
+ const route = routes.get(sessionId) ?? null
1247
+ const marked = decideMark(event, route)
1248
+ if (marked === null) continue
1249
+ const result = foldMarkEntry(
1250
+ entries, routeKeyOf(route.provider, route.model), marked, eventTimeOf(event),
1251
+ )
1252
+ if (result.stored) stored = true
1253
+ if (result.visible) changed = true
1254
+ }
1255
+ return { stored, changed }
1256
+ }
1257
+
1258
+ /** Read a window's entries defensively. */
1259
+ const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
1260
+
1261
+ /** Apply one feed window for one Session. */
1262
+ const applyWindow = (sessionId, window, full) => {
1263
+ const change = window?.change
1264
+ let result
1265
+ if (full || change?.kind === 'replace') {
1266
+ // The window is that Session's whole truth, so its route restarts and
1267
+ // its entries are re-walked. Global watermarks keep a newer fact from
1268
+ // another Session from being overwritten by this replay.
1269
+ routes.delete(sessionId)
1270
+ result = absorb(sessionId, entriesOf(window))
1271
+ } else if (change?.kind === 'append') {
1272
+ result = absorb(sessionId, change.entries)
1273
+ } else {
1274
+ // `prepend` pages in OLDER history and `settle-assistant` only swaps a
1275
+ // live attempt for its durable form; neither can change a mark.
1276
+ return
1277
+ }
1278
+ if (result.stored) deps.writeStored(entries)
1279
+ if (result.changed && recompute()) publish()
1280
+ }
1281
+
1282
+ /**
1283
+ * Attach to one Session's durable feed. Idempotent, and a no-op when the
1284
+ * Session has no retained binding (an unopened Session has no feed; its
1285
+ * marks can only come from storage).
1286
+ */
1287
+ const attachSession = sessionId => {
1288
+ if (disposed || stops.has(sessionId)) return
1289
+ const sessions = deps.sessionsOf()
1290
+ let binding
1291
+ try {
1292
+ binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
1293
+ } catch {
1294
+ binding = undefined
1295
+ }
1296
+ const source = binding?.eventSource
1297
+ if (source === undefined || typeof source.subscribe !== 'function'
1298
+ || typeof source.getSnapshot !== 'function') return
1299
+ try {
1300
+ applyWindow(sessionId, source.getSnapshot(), true)
1301
+ } catch {
1302
+ /* an unreadable feed leaves the store as it was */
1303
+ }
1304
+ const listener = () => {
1305
+ try {
1306
+ applyWindow(sessionId, source.getSnapshot(), false)
1307
+ } catch {
1308
+ /* a fold failure must not escape into the slot */
1309
+ }
1310
+ }
1311
+ try {
1312
+ const stop = source.subscribe(listener)
1313
+ stops.set(sessionId, typeof stop === 'function' ? stop : () => {})
1314
+ } catch {
1315
+ /* subscription refused: leave this Session unattached */
1316
+ }
1317
+ }
1318
+
1319
+ /** Attach to every Session the list currently names, and drop the rest. */
1320
+ const sync = () => {
1321
+ if (disposed) return
1322
+ const sessions = deps.sessionsOf()
1323
+ if (sessions === undefined) return
1324
+ // A client reload replaces the service; re-attach from the new one.
1325
+ if (sessions !== attachedSessions) {
1326
+ for (const stop of stops.values()) stop()
1327
+ stops.clear()
1328
+ routes.clear()
1329
+ if (typeof stopList === 'function') stopList()
1330
+ stopList = undefined
1331
+ attachedSessions = sessions
1332
+ try {
1333
+ if (sessions.list !== undefined && typeof sessions.list.subscribe === 'function') {
1334
+ stopList = sessions.list.subscribe(() => { sync() })
1335
+ }
1336
+ } catch {
1337
+ stopList = undefined
1338
+ }
1339
+ }
1340
+ let ids = []
1341
+ try {
1342
+ const list = sessions.list?.getSnapshot?.()
1343
+ ids = Array.isArray(list?.ids) ? list.ids : []
1344
+ } catch {
1345
+ ids = []
1346
+ }
1347
+ for (const id of ids) attachSession(id)
1348
+ // Drop feeds for Sessions the list no longer names, unless a mounted
1349
+ // seat is currently reading that Session (it may not be listed yet).
1350
+ for (const id of [...stops.keys()]) {
1351
+ if (pinned.has(id)) continue
1352
+ if (!ids.includes(id)) {
1353
+ stops.get(id)?.()
1354
+ stops.delete(id)
1355
+ routes.delete(id)
1356
+ }
1357
+ }
1358
+ }
1359
+
1360
+ return {
1361
+ /** The seat's `useSyncExternalStore` pair. */
1362
+ subscribe: listener => {
1363
+ listeners.add(listener)
1364
+ sync()
1365
+ return () => { listeners.delete(listener) }
1366
+ },
1367
+ getSnapshot: () => snapshot,
1368
+ /** Re-attach (services may bind after the plugin activated). */
1369
+ sync,
1370
+ /**
1371
+ * Pin and immediately attach one Session's feed, for a mounted seat.
1372
+ * The list may not name this Session yet (or at all), so it is attached
1373
+ * directly instead of waiting for a list sync.
1374
+ */
1375
+ watch: sessionId => {
1376
+ if (disposed) return
1377
+ pinned.add(sessionId)
1378
+ sync()
1379
+ attachSession(sessionId)
1380
+ },
1381
+ /** Release every feed subscription. The marks themselves stay persisted. */
1382
+ dispose: () => {
1383
+ if (disposed) return
1384
+ disposed = true
1385
+ for (const stop of stops.values()) stop()
1386
+ stops.clear()
1387
+ routes.clear()
1388
+ pinned.clear()
1389
+ if (typeof stopList === 'function') stopList()
1390
+ stopList = undefined
1391
+ listeners.clear()
1392
+ },
1393
+ }
1394
+ }
1395
+
1017
1396
  const dict = {
1018
1397
  zh: {
1019
1398
  'provider.account': 'DeepSeek 账号',
@@ -1101,7 +1480,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1101
1480
 
1102
1481
  /**
1103
1482
  * 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
1483
+ * stable for every seat that has no store, so an unmarked panel never
1105
1484
  * re-renders because of this feature.
1106
1485
  */
1107
1486
  const EMPTY_INSUFFICIENT_MARKS = new Map()
@@ -1115,13 +1494,13 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1115
1494
  const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
1116
1495
 
1117
1496
  /**
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.
1497
+ * Stand-in mark face used when the store degraded. It never notifies and
1498
+ * always answers the same empty snapshot.
1120
1499
  */
1121
1500
  const EMPTY_INSUFFICIENT = {
1122
1501
  subscribe: () => () => {},
1123
1502
  getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
1124
- dispose: () => {},
1503
+ watch: () => {},
1125
1504
  }
1126
1505
 
1127
1506
  // ---------------------------------------------------------------------
@@ -1150,18 +1529,20 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1150
1529
  const getSnapshot = useCallback(() => store.getSnapshot(), [store])
1151
1530
  const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
1152
1531
 
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.
1532
+ // Per-model insufficient-balance marks, read from the ONE global store so
1533
+ // a failure recorded in any Session or workspace strikes the model here
1534
+ // too. The fallback keeps the hook shape stable when the face is omitted.
1156
1535
  const markFace = insufficient ?? EMPTY_INSUFFICIENT
1157
1536
  const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
1158
1537
  const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
1159
1538
  const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
1160
1539
 
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])
1540
+ // Keep the store attached to this Session's feed while the seat is mounted.
1541
+ // It is shared, so unmounting must NOT dispose it — the marks belong to the
1542
+ // page, not to this seat.
1543
+ useEffect(() => {
1544
+ if (typeof markFace.watch === 'function') markFace.watch(sessionId)
1545
+ }, [markFace, sessionId])
1165
1546
 
1166
1547
  const [open, setOpen] = useState(false)
1167
1548
  const [query, setQuery] = useState('')
@@ -1661,11 +2042,44 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1661
2042
  }, block.label)
1662
2043
  : renderRow(block.entry))
1663
2044
 
2045
+ // Each child is placed EXPLICITLY (`grid-column` below), never by
2046
+ // auto-placement: a divider that pinned its row but not its column was
2047
+ // laid out first, took column 1, and pushed the columns into columns 2
2048
+ // and 3 — which squashed the first text column to 1px and stacked the
2049
+ // columns on top of each other. Track 2k+1 holds column k (0-based) and
2050
+ // track 2k+2 the gutter that follows it, so a 3-column layout is
2051
+ // col gutter col gutter col.
1664
2052
  const renderColumn = (column, columnIndex) => h('div', {
1665
- className: 'dshmf-col' + (plan.count > 1 && columnIndex < plan.count - 1 ? ' dshmf-col--divided' : ''),
2053
+ className: 'dshmf-col',
1666
2054
  key: `col-${columnIndex}`,
2055
+ style: { gridColumn: String(columnIndex * 2 + 1), gridRow: '1' },
1667
2056
  }, column.map(unit => unit.blocks.map(renderBlock)))
1668
2057
 
2058
+ // Vertical separators are siblings of the columns, not a border on one
2059
+ // of them: a border can only ever be as tall as its own column, while a
2060
+ // grid item can stretch to the whole grid height (see .dshmf-colDivider).
2061
+ // Called with the index of the column that PRECEDES the gutter.
2062
+ const renderDivider = dividerIndex => h('div', {
2063
+ className: 'dshmf-colDivider',
2064
+ role: 'presentation',
2065
+ key: `gutter-${dividerIndex}`,
2066
+ style: { gridColumn: String(dividerIndex * 2), gridRow: '1' },
2067
+ })
2068
+
2069
+ const renderColumns = () => {
2070
+ const children = []
2071
+ plan.columns.forEach((column, columnIndex) => {
2072
+ if (columnIndex > 0) children.push(renderDivider(columnIndex))
2073
+ children.push(renderColumn(column, columnIndex))
2074
+ })
2075
+ return children
2076
+ }
2077
+
2078
+ // `minmax(0, 1fr)` per column, a hairline track per gutter between them.
2079
+ const gridTemplateColumns = plan.columns
2080
+ .map(() => 'minmax(0, 1fr)')
2081
+ .join(' 1px ')
2082
+
1669
2083
  const renderFailure = failure => h('div', {
1670
2084
  className: 'dshmf-failure',
1671
2085
  key: `failure-${failure.id}`,
@@ -1819,13 +2233,15 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
1819
2233
  h('div', {
1820
2234
  className: 'dshmf-grid',
1821
2235
  style: {
1822
- gridTemplateColumns: `repeat(${plan.count}, minmax(0, 1fr))`,
2236
+ gridTemplateColumns: showEmptyState
2237
+ ? 'minmax(0, 1fr)'
2238
+ : gridTemplateColumns,
1823
2239
  columnGap: `${COL_GAP}px`,
1824
2240
  },
1825
2241
  },
1826
2242
  showEmptyState
1827
2243
  ? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
1828
- : plan.columns.map(renderColumn),
2244
+ : renderColumns(),
1829
2245
  state.failures.length === 0 ? null : state.failures.map(renderFailure),
1830
2246
  ),
1831
2247
 
@@ -2042,7 +2458,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2042
2458
  // Pure layout and grouping derivations plus the component itself,
2043
2459
  // exposed for `node --test` so grouping, filtering and the column
2044
2460
  // 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 },
2461
+ __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
2462
  __component: ModelFlatMap,
2047
2463
  __seat: Seat,
2048
2464
  apply(ctx) {
@@ -2099,6 +2515,33 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2099
2515
  }
2100
2516
  }
2101
2517
 
2518
+ // ---- the GLOBAL insufficient-balance store
2519
+ //
2520
+ // Created once per activation and shared by every seat, because the
2521
+ // balance state belongs to the model route (and its account), not to one
2522
+ // conversation or workspace. It observes every Session the list names,
2523
+ // persists to `localStorage`, and starts from what the last page load
2524
+ // recorded. A failure to build it degrades to an empty face rather than
2525
+ // costing the control.
2526
+ let markStore
2527
+ try {
2528
+ markStore = createMarkStore({
2529
+ sessionsOf: () => {
2530
+ try {
2531
+ return typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
2532
+ } catch {
2533
+ return undefined
2534
+ }
2535
+ },
2536
+ readStored: readMarks,
2537
+ writeStored: writeMarks,
2538
+ })
2539
+ ctx.effect(() => () => { markStore.dispose() }, 'dsh-model-flatmap: balance marks')
2540
+ } catch (error) {
2541
+ console.error('dsh-model-flatmap: the balance mark store failed to start', error)
2542
+ markStore = undefined
2543
+ }
2544
+
2102
2545
  /**
2103
2546
  * Build the injected face for one Session.
2104
2547
  * @param sessionId - the Session this seat renders for.
@@ -2137,146 +2580,27 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2137
2580
  }
2138
2581
  const first = resolve()
2139
2582
 
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
2583
+ // Attach this Session's feed as soon as its seat exists, not only
2584
+ // when a subscriber appears: the seat always subscribes, but the
2585
+ // face is built before the first render, and a feed that is already
2586
+ // live should contribute its history immediately.
2587
+ if (markStore !== undefined) {
2198
2588
  try {
2199
- binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
2589
+ markStore.watch(sessionId)
2200
2590
  } 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
2591
+ /* the store degrades to storage-only state */
2249
2592
  }
2250
2593
  }
2251
2594
 
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
2595
  return {
2276
2596
  sessionId,
2277
2597
  resolveDirectory: resolve,
2278
2598
  safeRead,
2279
- insufficient,
2599
+ // The GLOBAL mark store, shared by every seat of this plugin
2600
+ // generation. `subscribe` additionally attaches the store to this
2601
+ // Session's feed, so a Session that binds late (or after the page
2602
+ // loaded) still contributes its events.
2603
+ insufficient: markStore ?? EMPTY_INSUFFICIENT,
2280
2604
  // `available: false` only for an addressed subagent Session, which
2281
2605
  // the shipped seat also hides.
2282
2606
  available: first.addressed !== true,
@@ -2336,11 +2660,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
2336
2660
  // component's hook shape never changes and the rows simply stay
2337
2661
  // unstruck. It shares the module-level empty snapshot so the
2338
2662
  // identity stays stable across renders.
2339
- insufficient: {
2340
- subscribe: () => () => {},
2341
- getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
2342
- dispose: () => {},
2343
- },
2663
+ insufficient: EMPTY_INSUFFICIENT,
2344
2664
  }
2345
2665
  }
2346
2666
  }
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.
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.1",
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": {