dsh-model-flatmap 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,29 +48,42 @@ levels, and the agent-teamwork switch on one surface:
48
48
  `hy3` → `HY3`, `hy4free` / `hy4std` / `hy4-preview` → `HY4`, and a bare `hy`
49
49
  → `HY`. Unregistered brands still collapse their version tail; dictionary
50
50
  families (GPT, Qwen…) are never split.
51
- - **Rows are provider-primary.** Under *By family* the heading already says what
52
- the models are, so the family name is not repeated in a row: the primary text
53
- is the provider name. Only when one provider offers several models in the same
54
- family does a short id-derived suffix differentiate them. The full model name
55
- and description stay in the tooltip.
51
+ - **Rows are provider-primary, with a model tail.** Under *By family* the
52
+ heading already says what the models are, so the family name is not repeated
53
+ in a row: the primary text is the provider name. What neither the heading nor
54
+ the provider can say — which model of the family the row is, and the
55
+ catalog's rate multiplier — is drawn as a quieter tail at the end of the row,
56
+ with the family name and the provider label stripped off: `Qwen3.8-27B` under
57
+ *Qwen* reads `3.8-27B`, and `Deepseek-V4.1-Flash · x0.01` under *DeepSeek*
58
+ reads `V4.1-Flash · x0.01` with the multiplier drawn smaller and bold. The
59
+ full model name and description stay in the tooltip.
60
+ - **Long names and long headings scroll on hover.** A clipped model name or
61
+ group heading runs one eased leftward pass after the pointer rests on it for
62
+ 0.5 s, then stops; leaving resets it instantly. Only the text moves — the
63
+ trailing badge and the title's band never do.
56
64
  - **Reasoning effort.** The current model's reasoning levels render as an
57
65
  exclusive, keyboard-operable group ("推理等级" / "Reasoning effort") driven by
58
66
  radio semantics.
59
67
  - **Adaptive footer.** The reasoning group and the agent-teamwork switch share
60
68
  one line when both fit with a 30 px gap, and fall back to one row each when
61
69
  they do not — the break is CSS flex wrapping, not a measured pixel threshold.
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
70
+ - **Out-of-balance models are struck through — everywhere, for a day.** A model
71
+ whose last call failed on insufficient balance, credit, or quota keeps a
72
+ `line-through` on its row text until a later call for that same model returns
73
+ anything else, and until the mark has gone 24 hours without a new attempt —
74
+ whichever comes first. A balance is topped up and a quota resets daily, so a
75
+ strike nobody refreshed is stale information, not a warning. The strike
76
+ follows the model the failing request actually used, and covers the
66
77
  Host's `QUOTA` / `ACCOUNT_QUOTA` codes, a provider `402`, and a `429` whose
67
78
  body reports an empty balance — a plain rate limit never strikes. Because a
68
79
  balance is a property of the model route and its account, **not of one
69
80
  conversation**, the marks are global: a failure seen in any Session strikes
70
81
  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.
82
+ `localStorage` so it survives a reload. Every mark keeps the time of the
83
+ observation that made it, so opening the list a day later draws that row
84
+ plain. Observations are ordered by that same time, so the newest one wins and
85
+ replaying older history cannot resurrect a strike that has already ended —
86
+ by a later call or by age.
74
87
  - **Theme-aware.** Styled with host theme tokens only; looks correct in both
75
88
  light and dark themes.
76
89
 
package/README.zh.md CHANGED
@@ -41,21 +41,29 @@
41
41
  - **generation 要区分开。** 已注册品牌按 generation 分节:`hy3` 归 `HY3`、`hy4free` /
42
42
  `hy4std` / `hy4-preview` 归 `HY4`,裸 `hy` 仍是 `HY`;未注册品牌仍折叠版本后缀。
43
43
  词典家族(GPT、Qwen…)不拆 generation。
44
- - **row content 以 provider 为主。** *按 family* 下标题已经说明模型是什么,所以行里
45
- 不再重复家族名,主文本改为 provider name;仅当同一 provider 在同一家族下有多个
46
- 模型时,才补一个由 id 推出的短后缀区分。完整模型名/描述保留在 tooltip。
44
+ - **row content 以 provider 为主,末尾补型号尾标。** *按 family* 下标题已经说明模型
45
+ 是什么,所以行里不再重复家族名,主文本改为 provider name。标题和 provider 都说不出
46
+ 的信息——这一行究竟是家族里的哪个型号、以及目录给的倍数——放在行**末尾**一个更安静的
47
+ 尾标里:把家族名和 provider label 从模型名里剥掉,`Qwen` 下的 `Qwen3.8-27B` 显示为
48
+ `3.8-27B`,`DeepSeek` 下的 `Deepseek-V4.1-Flash · x0.01` 显示为
49
+ `V4.1-Flash · x0.01`,其中倍数照旧用更小更粗的字重。完整模型名/描述保留在 tooltip。
50
+ - **长名称与长标题 hover 跑马灯。** 显示不全的模型名或分组标题,在鼠标停留 0.5 s 后
51
+ 向左跑**一趟**(ease-in-out)即停,移开立刻复位。只有文字在动——右侧的 badge 和
52
+ 标题所在的色带本身都不动。
47
53
  - **推理等级。** 当前模型的推理等级渲染为一个互斥、可键盘操作的分组("推理等级",
48
54
  基于 radio 语义)。
49
55
  - **自适应底栏。** 推理等级组与智能体协作开关在放得下(中间至少 30 px)时共用一行,
50
56
  放不下时各自占一行回到原来的两行布局——换行由 CSS flex 换行决定,不靠 JS 量像素。
51
- - **额度不足的模型加删除线,且全局生效。** 某个模型的调用因额度/余额/信用不足而失败后,
52
- 该模型的行文字会带 `line-through` 删除线,直到同一个模型的下一次调用返回非额度不足的
53
- 结果才去掉。标记跟随失败那次请求实际使用的模型;覆盖宿主的 `QUOTA` / `ACCOUNT_QUOTA`
54
- 代码、provider 的 `402`,以及**正文说明余额不足的 `429`**——纯限流的 429 不会划线。
55
- 因为「还有没有额度」是模型路由(及其账号)的属性、**与单个会话无关**,所以标记是全局的:
56
- 在任意会话里观察到的失败,会在所有会话与工作区里同样划线;状态持久化在 `localStorage`
57
- 中,重载后仍然保留。观察按事件时间排序,最新的一条说了算,因此回放更早的历史不会让一条
58
- 已被后续调用清除的划线复活。
57
+ - **额度不足的模型加删除线,全局生效,且只生效一天。** 某个模型的调用因额度/余额/信用不足
58
+ 而失败后,该模型的行文字会带 `line-through` 删除线,直到同一个模型的下一次调用返回非额度
59
+ 不足的结果,或者这条标记已经 24 小时没有新尝试——以先到者为准。余额会被充值、额度会按日
60
+ 重置,所以一条没人更新的划线是**过时的信息**,不是警告。标记跟随失败那次请求实际使用的
61
+ 模型;覆盖宿主的 `QUOTA` / `ACCOUNT_QUOTA` 代码、provider 的 `402`,以及**正文说明余额
62
+ 不足的 `429`**——纯限流的 429 不会划线。因为「还有没有额度」是模型路由(及其账号)的属性、
63
+ **与单个会话无关**,所以标记是全局的:在任意会话里观察到的失败,会在所有会话与工作区里
64
+ 同样划线;状态持久化在 `localStorage` 中,重载后仍然保留。每条标记都记下「这次判定发生的
65
+ 时间」,因此一天之后再打开列表,那一行就是干净的。观察按同一个时间排序,最新的一条说了算,
66
+ 所以回放更早的历史既不能让一条已被后续调用清除的划线复活,也不能让一条已经过期的复活。
59
67
  - **跟随主题。** 仅使用宿主主题令牌着色,在浅色与深色主题下都正确。
60
68
 
61
69
  完整行为规格见 [`docs/DETAIL.md`](./docs/DETAIL.md),设计决策见
package/client.js CHANGED
@@ -169,9 +169,10 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
169
169
  stay borderless, so the dense flow across columns is kept (see DEC-003,
170
170
  DEC-005). */
171
171
  .dshmf-groupTitle {
172
- align-self: flex-start; max-width: 100%;
172
+ align-self: flex-start; max-width: 100%; min-width: 0;
173
173
  box-sizing: border-box; height: 20px; padding: 0 8px 0 6px;
174
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
174
+ display: flex; align-items: center;
175
+ overflow: hidden; white-space: nowrap;
175
176
  border: 1px solid var(--dsw-alias-border-l2);
176
177
  border-left: 3px solid var(--dsw-alias-border-l3);
177
178
  border-radius: var(--dsw-radius-xs);
@@ -180,6 +181,20 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
180
181
  color: var(--dsw-alias-label-primary);
181
182
  font-size: 10px; line-height: 18px; font-weight: 700; letter-spacing: .02em;
182
183
  }
184
+ /* The label gets the SAME window/track split as a model row (DEC-021): the
185
+ band is the frame, and the text inside it is a clipped window holding a
186
+ track that can be wider than the window. That is what lets a long heading
187
+ ("ModelScope Token Plan") run the same one-pass hover marquee as a long
188
+ model name, while the band's frame, fill and shadow stay put (DEC-023). */
189
+ .dshmf-groupTitleWindow {
190
+ flex: 1 1 auto; min-width: 0; display: block;
191
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
192
+ }
193
+ .dshmf-groupTitleTrack { width: max-content; max-width: none; white-space: nowrap; }
194
+ .dshmf-groupTitleTrack[data-running="true"] {
195
+ display: inline-block;
196
+ animation: dshmf-rowNameMarquee var(--dshmf-marquee-duration, 3000ms) ease-in-out 1 forwards;
197
+ }
183
198
  .dshmf-row {
184
199
  box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%;
185
200
  height: 26px; padding: 0 6px; border: 0; border-radius: var(--dsw-radius-sm);
@@ -209,9 +224,42 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
209
224
  background: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) 22%, transparent);
210
225
  color: inherit;
211
226
  }
212
- .dshmf-rowName { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
227
+ .dshmf-rowNameWindow {
228
+ flex: 1 1 auto; min-width: 0; display: block;
229
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
230
+ }
231
+ /* The name itself never ellipsises on its own: the window clips it, and the
232
+ track is what slides when the pointer rests on a truncated row (DEC-021).
233
+ width: max-content is what lets the track be wider than the window, which is
234
+ both the overflow signal (scrollWidth) and what the marquee reveals. */
235
+ .dshmf-rowName {
236
+ width: max-content; max-width: none;
237
+ white-space: nowrap;
238
+ }
239
+ /* Resting the pointer on a row whose name does not fit starts ONE marquee
240
+ pass: the name glides left until its last glyph reaches the window, then
241
+ STOPS there. The easing is ease-in-out, so the start and the arrival are
242
+ soft rather than a constant crawl. Only the name TRACK moves — the window
243
+ clips it, so the right-aligned badge (限时免费, ×0.1) and the pending
244
+ spinner, which sit as siblings AFTER the window, never shift. There is no
245
+ return trip and no iteration: fill-mode forwards holds the end state, and
246
+ leaving the row drops the animation entirely, which snaps back with no
247
+ transition. */
248
+ @keyframes dshmf-rowNameMarquee {
249
+ from { transform: translateX(0); }
250
+ to { transform: translateX(var(--dshmf-marquee-shift)); }
251
+ }
252
+ .dshmf-rowNameTrack[data-running="true"] {
253
+ display: inline-block;
254
+ animation: dshmf-rowNameMarquee var(--dshmf-marquee-duration, 3000ms) ease-in-out 1 forwards;
255
+ }
256
+ @media (prefers-reduced-motion: reduce) {
257
+ .dshmf-rowNameTrack[data-running="true"],
258
+ .dshmf-groupTitleTrack[data-running="true"] { animation: none; }
259
+ }
213
260
  /* The model's last call failed on insufficient balance / credit / quota, and no
214
- later call for the same model has cleared it. The row text is struck through
261
+ later call for the same model has cleared it, nor has the mark aged past
262
+ STRIKE_TTL (24h) without a fresh attempt. The row text is struck through
215
263
  rather than recoloured: the strike survives both themes and the selected
216
264
  row's blue band, and it does not compete with the error strip's own red.
217
265
  The decoration is applied to the row's text only (name + variant tag), not
@@ -227,17 +275,25 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
227
275
  multiplier reads as an aside without disturbing the row's text rhythm
228
276
  (see renderRow's nameRuns). */
229
277
  .dshmf-rowNameMark { font-size: 11px; line-height: 18px; font-weight: 700; letter-spacing: normal; }
230
- /* Secondary text in a family-grouped row. The primary text is the provider,
231
- so this is only a short variant tail shown when one provider offers several
232
- models in the same family. Quieter than the primary text (caption colour,
233
- 11px) and shrinks before it does. No width cap: a cap truncates a perfectly
234
- readable label ("CodeBuddy …") in a column that had room for it. */
278
+ /* The model-specific tail of a family-grouped row, e.g. "3.8-Flash-Next",
279
+ "V4.1-Flash · x0.01", "2.1 Turbo". The primary text is the provider and the
280
+ heading names the family, so this is what is LEFT after both are removed —
281
+ the型号 and the multiplier the heading cannot carry (DEC-023). It sits at
282
+ the END of the row and is quieter than the primary text (caption colour,
283
+ 11px), shrinks before it, and never wraps. No width cap: a cap truncates a
284
+ perfectly readable label ("CodeBuddy …") in a column that had room for it. */
235
285
  .dshmf-rowProvider {
236
286
  flex: 0 1 auto; min-width: 0;
237
287
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
238
288
  color: var(--dsw-alias-label-caption);
239
289
  font-size: 11px; line-height: 16px; font-weight: 400;
240
290
  }
291
+ /* A multiplier inside that tail ("x0.01") keeps the same "smaller and bolder"
292
+ treatment it gets inside a model name in *By provider* (see
293
+ .dshmf-rowNameMark and renderRow's nameRuns), so the rate reads identically
294
+ in both grouping modes. The base rule already makes it bold and 11px; inside
295
+ the 11px tail it steps down once more, to 10px, to stay visibly apart. */
296
+ .dshmf-rowProvider .dshmf-rowNameMark { font-size: 10px; line-height: 16px; }
241
297
  .dshmf-rowTail { display: grid; place-items: center; flex: 0 0 14px; color: inherit; }
242
298
 
243
299
  /* Catalog badge (for example a third-party rate multiplier). Rendered only
@@ -493,6 +549,15 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
493
549
  // The wording pass is deliberately narrow: a bare "402"/"429" only counts
494
550
  // when it reads as the leading status, never as a number inside another
495
551
  // message, and every example below is taken from a real recorded session.
552
+ //
553
+ // A strike also EXPIRES. Every record keeps the wall-clock moment the
554
+ // observation was made, which is what lets the panel tell "this model is out
555
+ // of balance" apart from "this model has not been tried since that verdict".
556
+ // Quotas reset daily and balances get topped up, so a strike that nobody
557
+ // refreshed stops being drawn after STRIKE_TTL; a retry that fails again
558
+ // re-stamps the record and restarts the window, and a retry that answers
559
+ // clears it outright. The window is therefore always "24h since the newest
560
+ // thing we learned about this route", not "24h since the row was drawn".
496
561
  // ---------------------------------------------------------------------
497
562
 
498
563
  /** Failure codes that mean "this account has no usable balance left". */
@@ -593,13 +658,18 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
593
658
  * Wall-clock time one durable event was recorded at. The Client feed keeps
594
659
  * the Host's `time` (`Date.now()` at append), which is what lets marks
595
660
  * gathered from DIFFERENT Sessions be ordered against each other — a Session
596
- * feed has no cross-Session sequence number.
661
+ * feed has no cross-Session sequence number — and it is the same clock the
662
+ * strike lifetime is measured against.
663
+ *
664
+ * An event with no stamp is an observation made NOW, not one made at the
665
+ * epoch: folding it as `0` would file a fact we just learned as 56 years old
666
+ * and a strike could never be drawn for it.
597
667
  * @param {unknown} event - one durable Session event.
598
- * @returns {number} the event time, or 0 when the feed carried none.
668
+ * @returns {number} the event time, or the current time when the feed carried none.
599
669
  */
600
670
  function eventTimeOf(event) {
601
671
  const time = event?.time
602
- return typeof time === 'number' && Number.isFinite(time) ? time : 0
672
+ return typeof time === 'number' && Number.isFinite(time) ? time : Date.now()
603
673
  }
604
674
 
605
675
  /**
@@ -922,22 +992,86 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
922
992
  }
923
993
 
924
994
  /**
925
- * The distinguishing part of one model for a family-grouped row: the id's
926
- * variant tail first, then any catalog-name words that are not just the
927
- * family heading or a bare version number. Returns '' when the model adds
928
- * nothing beyond what the family heading already says.
995
+ * Separators a strip leaves dangling at either end of a tail. `·` is the
996
+ * catalog's own "name · multiplier" separator (`Hy4 · x0.00`).
997
+ */
998
+ const TAIL_EDGE = /^[\s·・.,:;|/\\_-]+|[\s·・.,:;|/\\_-]+$/g
999
+
1000
+ /**
1001
+ * Remove every occurrence of a family heading from a text, as a SUBSTRING.
1002
+ *
1003
+ * A family is glued to its version tail in real ids and names — `Qwen3.8-…`,
1004
+ * `Deepseek-V4.1-…`, `MiniMax-M3` — so a whole-word match would miss the
1005
+ * very case this exists for. The gap left behind becomes a space, so
1006
+ * removing a prefix never glues two words together.
1007
+ * @param {string} text - the model name.
1008
+ * @param {string} needle - the family heading.
1009
+ * @returns {string} the text with the heading removed.
1010
+ */
1011
+ function dropSubstring(text, needle) {
1012
+ const lower = needle.toLowerCase()
1013
+ if (lower.length < 2) return text
1014
+ let out = text
1015
+ for (;;) {
1016
+ const at = out.toLowerCase().indexOf(lower)
1017
+ if (at === -1) return out
1018
+ out = `${out.slice(0, at)} ${out.slice(at + lower.length)}`
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Remove whole words from a text (case-insensitive).
1024
+ *
1025
+ * Used for the provider's own label, which is the row's PRIMARY text — so a
1026
+ * tail that repeats it would print the same word twice. Whole-word matching
1027
+ * is what keeps a short token from punching a hole in an unrelated word
1028
+ * (provider `AI` must not turn `Rain` into `Rn`). Word edges are any
1029
+ * non-letter/digit, so the CJK provider label `豆包` still matches inside
1030
+ * `豆包 2.1 Turbo`.
1031
+ * @param {string} text - the model name.
1032
+ * @param {string[]} words - provider label words; each needs 2+ characters.
1033
+ * @returns {string} the text with those words removed.
1034
+ */
1035
+ function dropWords(text, words) {
1036
+ let out = text
1037
+ for (const word of words) {
1038
+ if (word.length < 2) continue
1039
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1040
+ out = out.replace(
1041
+ new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'giu'),
1042
+ ' ',
1043
+ )
1044
+ }
1045
+ return out
1046
+ }
1047
+
1048
+ /**
1049
+ * The model-specific part of a family-grouped row: the model name with the
1050
+ * family heading and the provider's own label removed, so only what the
1051
+ * heading does NOT already say is left.
1052
+ *
1053
+ * `Qwen3.8-Flash-Next` under `Qwen` → `3.8-Flash-Next`, `Qwen3.8-27B` →
1054
+ * `3.8-27B`; `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` →
1055
+ * `V4.1-Flash · x0.00`; `豆包 2.1 Turbo` under provider `豆包(doubao)` →
1056
+ * `2.1 Turbo`. Version numbers are KEPT — they are the型号 the heading
1057
+ * lacks, unlike the id-derived tail that drops them.
1058
+ *
1059
+ * When the name adds nothing (`Hy4` under `HY4`), the id tail is used so a
1060
+ * variant like `hy4free` still reads `free`. An empty result is valid: the
1061
+ * row then draws no tail at all.
929
1062
  * @param {{id: string, name: string}} model - catalog entry.
930
1063
  * @param {string} family - the resolved family heading.
931
- * @returns {string} a short, non-family supplement; possibly ''.
1064
+ * @param {string} [providerLabel] - the row's primary text; stripped when it repeats.
1065
+ * @returns {string} the model-specific supplement; possibly ''.
932
1066
  */
933
- function rowVariantOf(model, family) {
934
- const fromId = idVariantOf(model, family)
935
- if (fromId !== '') return fromId
936
- const familyWords = new Set(family.toLowerCase().split(/[\s-]+/).filter(Boolean))
937
- return String(model.name ?? '').split(/[\s._-]+/).filter(word => {
938
- const lower = word.toLowerCase()
939
- return word !== '' && !familyWords.has(lower) && !/^\d+(\.\d+)?$/.test(lower)
940
- }).join(' ')
1067
+ function rowVariantOf(model, family, providerLabel = '') {
1068
+ const name = String(model.name ?? '')
1069
+ const providerWords = String(providerLabel).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
1070
+ const tail = dropWords(dropSubstring(name, family), providerWords)
1071
+ .replace(/\s+/g, ' ')
1072
+ .replace(TAIL_EDGE, '')
1073
+ if (tail !== '') return tail
1074
+ return idVariantOf(model, family)
941
1075
  }
942
1076
 
943
1077
  // ---------------------------------------------------------------------
@@ -1102,10 +1236,47 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1102
1236
  // the Host's `time`, and the newest observation of a route wins. That is
1103
1237
  // what lets a replayed window (or a stored entry) never override a newer
1104
1238
  // fact from another Session.
1239
+ //
1240
+ // The same stamp is what makes a strike go STALE: it is the time the verdict
1241
+ // was made, so a mark nobody refreshed stops being drawn after STRIKE_TTL
1242
+ // while the record stays behind as the watermark. See markIsLive.
1105
1243
  // ---------------------------------------------------------------------
1106
1244
  const MARKS_KEY = 'dshmf:insufficient'
1107
- /** Marks older than this are dropped on read, so a stale strike cannot live forever. */
1245
+ /**
1246
+ * How long a RECORD is kept. Records older than this are dropped on read,
1247
+ * so a stale verdict cannot live forever — and so a cleared route's
1248
+ * watermark (which shields it from a replayed older failure) has an end.
1249
+ */
1108
1250
  const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
1251
+ /**
1252
+ * How long a MARK is DRAWN. This is the one the user sees: if nothing new has
1253
+ * been observed for a route for a day, the user never retried it, and the
1254
+ * panel stops claiming the model is unusable — an exhausted balance gets
1255
+ * topped up and a quota resets daily, so a strike that outlives a whole day
1256
+ * of silence is stale information, not a warning. The record itself survives
1257
+ * (up to {@link MARKS_TTL}) as the watermark that keeps a replayed older
1258
+ * failure from re-stamping it.
1259
+ */
1260
+ const STRIKE_TTL = 24 * 60 * 60 * 1000
1261
+
1262
+ /**
1263
+ * Whether one record still justifies drawing a strike at `now`.
1264
+ *
1265
+ * `at` is the time of the LAST observation for the route, so the answer to
1266
+ * "has the user tried this model again?" is already in it: a retry that
1267
+ * failed on balance restamps `at` and restarts the window, a retry that
1268
+ * answered flips `marked`, and a route nobody touched just ages out.
1269
+ * @param {{ marked: boolean, at: number } | undefined} entry - one record.
1270
+ * @param {number} now - the current wall-clock time.
1271
+ * @param {number} [strikeTtl] - how long a mark is drawn; defaults to {@link STRIKE_TTL}.
1272
+ * @returns {boolean} true only for a mark that is set AND still fresh.
1273
+ */
1274
+ function markIsLive(entry, now, strikeTtl) {
1275
+ if (entry === undefined || entry.marked !== true) return false
1276
+ const life = typeof strikeTtl === 'number' && Number.isFinite(strikeTtl) ? strikeTtl : STRIKE_TTL
1277
+ // A stamp from the future (a clock that ran ahead) is fresh, never expired.
1278
+ return now - entry.at < life
1279
+ }
1109
1280
 
1110
1281
  /**
1111
1282
  * Parse a persisted mark record into the in-memory shape, dropping anything
@@ -1178,10 +1349,17 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1178
1349
  }
1179
1350
  }
1180
1351
 
1181
- /** Whether two visible mark maps hold the same entries. */
1352
+ /**
1353
+ * Whether two visible mark maps hold the same entries.
1354
+ *
1355
+ * Iterating a Map yields `[key, value]` PAIRS, so this walks `keys()` — a
1356
+ * `for (const key of map)` here compares a pair against `has()` and always
1357
+ * answers "different", which hands the seats a brand-new snapshot object on
1358
+ * every recompute and puts `useSyncExternalStore` into a render loop.
1359
+ */
1182
1360
  function sameMarks(left, right) {
1183
1361
  if (left.size !== right.size) return false
1184
- for (const key of left) {
1362
+ for (const key of left.keys()) {
1185
1363
  if (!right.has(key)) return false
1186
1364
  }
1187
1365
  return true
@@ -1196,16 +1374,32 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1196
1374
  * everywhere, including in a Session whose own feed was never opened in this
1197
1375
  * page load (its marks come from storage).
1198
1376
  *
1377
+ * The store is also the thing that ages strikes out. A mark retiring itself
1378
+ * is a state change no event announces, so the store watches the clock on its
1379
+ * own behalf — on every fold, on every seat mounting, and on a timer set for
1380
+ * the soonest deadline — instead of waiting for a fold that may never come.
1381
+ *
1199
1382
  * @param {object} deps - resolution hooks.
1200
1383
  * @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
1201
1384
  * @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
1202
1385
  * @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
1203
- * @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`.
1386
+ * @param {() => number} [deps.now] - the clock; defaults to `Date.now`.
1387
+ * @param {number} [deps.strikeTtl] - strike lifetime; defaults to {@link STRIKE_TTL}.
1388
+ * @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`, `watch`, `refresh`.
1204
1389
  */
1205
1390
  function createMarkStore(deps) {
1391
+ /**
1392
+ * The clock strikes age against. Injectable so a test does not have to sit
1393
+ * through a day; every read of the state takes it fresh, because a page
1394
+ * lives long enough for the wall clock to matter.
1395
+ */
1396
+ const nowFn = typeof deps.now === 'function' ? deps.now : () => Date.now()
1397
+ const strikeTtl = typeof deps.strikeTtl === 'number' && Number.isFinite(deps.strikeTtl)
1398
+ ? deps.strikeTtl
1399
+ : STRIKE_TTL
1206
1400
  /** Internal truth: route key → last decision and when it was made. */
1207
1401
  const entries = new Map(deps.readStored())
1208
- /** Visible subset the rows read: route key → true. */
1402
+ /** Visible subset the rows read: route key → true, the LIVE marks only. */
1209
1403
  let visible = new Map()
1210
1404
  let snapshot = { marks: visible }
1211
1405
  const listeners = new Set()
@@ -1219,12 +1413,19 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1219
1413
  let attachedSessions
1220
1414
  let stopList
1221
1415
  let disposed = false
1416
+ /** Timer for the soonest strike that is still being drawn. */
1417
+ let expiryTimer = null
1418
+
1419
+ const publish = () => {
1420
+ for (const listener of listeners) listener()
1421
+ }
1222
1422
 
1223
- /** Rebuild the visible map; report whether the rendered set changed. */
1423
+ /** Rebuild the visible map against the clock; report whether it changed. */
1224
1424
  const recompute = () => {
1425
+ const now = nowFn()
1225
1426
  const next = new Map()
1226
1427
  for (const [key, entry] of entries) {
1227
- if (entry.marked) next.set(key, true)
1428
+ if (markIsLive(entry, now, strikeTtl)) next.set(key, true)
1228
1429
  }
1229
1430
  if (sameMarks(visible, next)) return false
1230
1431
  visible = next
@@ -1232,12 +1433,56 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1232
1433
  return true
1233
1434
  }
1234
1435
 
1235
- recompute()
1436
+ /**
1437
+ * Arm the clock for the soonest live strike.
1438
+ *
1439
+ * A strike retiring is the one transition no event reports: the user
1440
+ * simply never came back to this model. A page left open across the
1441
+ * deadline therefore has to wake itself, or a stale line-through would sit
1442
+ * on the row until some unrelated fold happened to recompute it.
1443
+ */
1444
+ const scheduleExpiry = () => {
1445
+ if (expiryTimer !== null) {
1446
+ clearTimeout(expiryTimer)
1447
+ expiryTimer = null
1448
+ }
1449
+ if (disposed || typeof setTimeout !== 'function') return
1450
+ const now = nowFn()
1451
+ let earliest = Number.POSITIVE_INFINITY
1452
+ for (const entry of entries.values()) {
1453
+ if (!markIsLive(entry, now, strikeTtl)) continue
1454
+ const deadline = entry.at + strikeTtl
1455
+ if (deadline < earliest) earliest = deadline
1456
+ }
1457
+ if (!Number.isFinite(earliest)) return
1458
+ const handle = setTimeout(() => {
1459
+ expiryTimer = null
1460
+ refresh()
1461
+ }, Math.max(0, earliest - now))
1462
+ // A day-long timer must never be the reason a process stays alive (a
1463
+ // Node host, a test run): `unref` lets it fire while anything else is
1464
+ // running without holding the loop. A browser handle is a number and has
1465
+ // no `unref`, and there the page keeps its own clock.
1466
+ if (typeof handle?.unref === 'function') handle.unref()
1467
+ expiryTimer = handle
1468
+ }
1236
1469
 
1237
- const publish = () => {
1238
- for (const listener of listeners) listener()
1470
+ /**
1471
+ * Re-read the clock and tell the seats if anything aged out.
1472
+ * @returns {boolean} whether the rendered set changed.
1473
+ */
1474
+ const refresh = () => {
1475
+ if (disposed) return false
1476
+ const changed = recompute()
1477
+ // Re-arm first: a strike that just aged out may leave a later one live.
1478
+ scheduleExpiry()
1479
+ if (changed) publish()
1480
+ return changed
1239
1481
  }
1240
1482
 
1483
+ recompute()
1484
+ scheduleExpiry()
1485
+
1241
1486
  /** Fold one batch of durable entries for one Session. */
1242
1487
  const absorb = (sessionId, windowEntries) => {
1243
1488
  let stored = false
@@ -1284,7 +1529,9 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1284
1529
  return
1285
1530
  }
1286
1531
  if (result.stored) deps.writeStored(entries)
1287
- if (result.changed && recompute()) publish()
1532
+ // The clock — not the raw record — decides what the rows draw, and a new
1533
+ // record moves the soonest deadline, so re-read both after any fold.
1534
+ if (result.stored || result.changed) refresh()
1288
1535
  }
1289
1536
 
1290
1537
  /**
@@ -1368,6 +1615,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1368
1615
  return {
1369
1616
  /** The seat's `useSyncExternalStore` pair. */
1370
1617
  subscribe: listener => {
1618
+ // A seat mounting IS the model list being displayed, so this is where a
1619
+ // strike that aged out while nobody was looking gets retired — before
1620
+ // React reads the snapshot it is about to render from.
1621
+ refresh()
1371
1622
  listeners.add(listener)
1372
1623
  sync()
1373
1624
  return () => { listeners.delete(listener) }
@@ -1385,11 +1636,22 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1385
1636
  pinned.add(sessionId)
1386
1637
  sync()
1387
1638
  attachSession(sessionId)
1639
+ refresh()
1388
1640
  },
1641
+ /**
1642
+ * Re-read the clock: retire any mark older than the strike lifetime and
1643
+ * notify the seats that drew it. Call it whenever the list is displayed,
1644
+ * because that is the moment the answer must be current.
1645
+ */
1646
+ refresh,
1389
1647
  /** Release every feed subscription. The marks themselves stay persisted. */
1390
1648
  dispose: () => {
1391
1649
  if (disposed) return
1392
1650
  disposed = true
1651
+ if (expiryTimer !== null) {
1652
+ clearTimeout(expiryTimer)
1653
+ expiryTimer = null
1654
+ }
1393
1655
  for (const stop of stops.values()) stop()
1394
1656
  stops.clear()
1395
1657
  routes.clear()
@@ -1509,6 +1771,129 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1509
1771
  subscribe: () => () => {},
1510
1772
  getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
1511
1773
  watch: () => {},
1774
+ refresh: () => {},
1775
+ }
1776
+
1777
+ /**
1778
+ * How long the pointer must rest on a truncated model name before the
1779
+ * marquee starts. Long enough that sweeping across the grid never starts a
1780
+ * scroll, short enough to feel like a direct response (DEC-021).
1781
+ */
1782
+ const MARQUEE_DELAY = 500
1783
+ /**
1784
+ * One CSS pixel per 20ms — 50px/s. The trailing end of a long name scrolls
1785
+ * at the same pace, so a 300px overflow still finishes in ~6s instead of
1786
+ * crawling, and the speed reads as a considered crawl rather than a ticker.
1787
+ */
1788
+ const MARQUEE_PX_PER_SECOND = 50
1789
+ /** Shortest pass, so a barely-truncated name does not blink past. */
1790
+ const MARQUEE_MIN_MS = 900
1791
+
1792
+ /**
1793
+ * Does this element actually clip its text?
1794
+ *
1795
+ * The measured element is the row's name WINDOW, a plain block-level flex
1796
+ * item with `overflow: hidden; white-space: nowrap`, so `scrollWidth` is the
1797
+ * full width of the name and `clientWidth` the visible part. A one-pixel
1798
+ * slack keeps sub-pixel rounding from starting a marquee on a name that
1799
+ * fits exactly.
1800
+ * @param {Element | null | undefined} element - the name window.
1801
+ * @returns {number} the clipped amount in px; 0 when nothing is cut off.
1802
+ */
1803
+ const nameClippedBy = element => {
1804
+ if (element === null || element === undefined) return 0
1805
+ const clientWidth = Number(element.clientWidth) || 0
1806
+ if (clientWidth <= 0) return 0
1807
+ const clipped = (Number(element.scrollWidth) || 0) - clientWidth
1808
+ return clipped > 1 ? clipped : 0
1809
+ }
1810
+
1811
+ /**
1812
+ * The geometry of the single marquee pass (DEC-021).
1813
+ *
1814
+ * `shift` is how far the name travels for its LAST glyph to reach the
1815
+ * right edge of the window — exactly the clipped amount, so nothing scrolls
1816
+ * past the point of being readable. The pass is one-way and eased
1817
+ * (ease-in-out), which is symmetric, so its AVERAGE velocity is still the
1818
+ * pinned MARQUEE_PX_PER_SECOND: the duration is simply
1819
+ * `shift / MARQUEE_PX_PER_SECOND`. Easing makes the ends soften around that
1820
+ * average instead of changing it.
1821
+ * @param {number} clipped - pixels of the name hidden to the right.
1822
+ * @returns {{shift: number, duration: number}} geometry in px / ms.
1823
+ */
1824
+ const marqueePassGeometry = clipped => {
1825
+ const shift = Number.isFinite(clipped) && clipped > 0 ? Math.ceil(clipped) : 0
1826
+ if (shift === 0) return { shift: 0, duration: 0 }
1827
+ const duration = Math.max(MARQUEE_MIN_MS, Math.round((shift / MARQUEE_PX_PER_SECOND) * 1000))
1828
+ return { shift, duration }
1829
+ }
1830
+
1831
+ /**
1832
+ * Does the user ask the OS to reduce motion? The marquee is decorative, so
1833
+ * it is skipped entirely under that preference — the ellipsis and the row
1834
+ * tooltip already carry the full name.
1835
+ * @returns {boolean} true when motion should be suppressed.
1836
+ */
1837
+ const prefersReducedMotion = () => {
1838
+ try {
1839
+ return typeof window !== 'undefined'
1840
+ && typeof window.matchMedia === 'function'
1841
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
1842
+ } catch {
1843
+ return false
1844
+ }
1845
+ }
1846
+
1847
+ /**
1848
+ * The sliding part of a row's primary text.
1849
+ *
1850
+ * The row wraps it in the clipped window, so the pass runs on the track
1851
+ * alone: everything AFTER the window (the variant tail, the catalog badge,
1852
+ * the pending spinner) is right-aligned and never pushed along.
1853
+ * `shift`/`duration` arrive as inline values because they depend on this
1854
+ * row's measurement.
1855
+ * @param {object} props - `shift` (px), `duration` (ms), `children`.
1856
+ * @returns {object} the track element.
1857
+ */
1858
+ const renderNameTrack = props => {
1859
+ const { shift, duration, children } = props
1860
+ const running = shift > 0
1861
+ return h('span', {
1862
+ className: 'dshmf-rowNameTrack dshmf-rowName',
1863
+ 'data-running': running ? 'true' : undefined,
1864
+ style: running
1865
+ ? {
1866
+ '--dshmf-marquee-shift': `-${shift}px`,
1867
+ '--dshmf-marquee-duration': `${duration}ms`,
1868
+ }
1869
+ : undefined,
1870
+ }, children)
1871
+ }
1872
+
1873
+ /**
1874
+ * The sliding part of a group title (DEC-023).
1875
+ *
1876
+ * The band plays the role the row plays for a model name: it is the frame,
1877
+ * and it never moves. The text inside is a clipped window holding a track,
1878
+ * so a heading too long for its column (`ModelScope Token Plan`) runs the
1879
+ * SAME one-pass eased marquee a truncated model name does, while the band's
1880
+ * fill, frame, radius and elevation stay put.
1881
+ * @param {object} props - `shift` (px), `duration` (ms), `children`.
1882
+ * @returns {object} the track element.
1883
+ */
1884
+ const renderTitleTrack = props => {
1885
+ const { shift, duration, children } = props
1886
+ const running = shift > 0
1887
+ return h('span', {
1888
+ className: 'dshmf-groupTitleTrack',
1889
+ 'data-running': running ? 'true' : undefined,
1890
+ style: running
1891
+ ? {
1892
+ '--dshmf-marquee-shift': `-${shift}px`,
1893
+ '--dshmf-marquee-duration': `${duration}ms`,
1894
+ }
1895
+ : undefined,
1896
+ }, children)
1512
1897
  }
1513
1898
 
1514
1899
  // ---------------------------------------------------------------------
@@ -1579,6 +1964,70 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1579
1964
  const confirmRef = useRef(null)
1580
1965
  const id = useId()
1581
1966
 
1967
+ // ---- Model-name marquee (DEC-021)
1968
+ // A name wider than its row is ellipsised ("DeepSeek Reasone…"). Resting
1969
+ // the pointer on such a row for MARQUEE_DELAY starts the marquee; moving
1970
+ // away stops it. Only one row can run at a time, and the sliding element
1971
+ // is the name TRACK only, so right-aligned trailing content (the catalog
1972
+ // badge — 限时免费, ×0.1 — and the pending spinner) never moves.
1973
+ // A group title is the same shape one level up (DEC-023): the band is the
1974
+ // frame and its text slides inside it, so a truncated heading runs the
1975
+ // same pass. Rows and titles therefore share this ONE state machine and
1976
+ // are told apart only by their key prefix.
1977
+ const [marquee, setMarquee] = useState({ key: '', shift: 0, duration: 0 })
1978
+ const marqueeTimer = useRef(null)
1979
+ /** The row whose delay is still running; '' when nothing is armed. */
1980
+ const marqueeArmed = useRef('')
1981
+ const idleMarquee = { key: '', shift: 0, duration: 0 }
1982
+ const clearMarqueeTimer = () => {
1983
+ if (marqueeTimer.current !== null) {
1984
+ clearTimeout(marqueeTimer.current)
1985
+ marqueeTimer.current = null
1986
+ }
1987
+ }
1988
+ /** Drop an armed delay (the pointer left before it fired). */
1989
+ const disarmMarquee = () => {
1990
+ clearMarqueeTimer()
1991
+ marqueeArmed.current = ''
1992
+ }
1993
+ /**
1994
+ * Pointer arrived on a marquee-able element. Leave any other one first,
1995
+ * then arm the delay. The measurement happens when it fires — the element
1996
+ * is laid out by then, and a name that fits never moves.
1997
+ * @param {string} key - the row's route key or the title's block key (one runner at a time).
1998
+ * @param {Element | null} element - the row (or band) the pointer entered.
1999
+ * @param {string} [selector] - the clipped window inside it.
2000
+ */
2001
+ const enterRow = (key, element, selector = '.dshmf-rowNameWindow') => {
2002
+ disarmMarquee()
2003
+ marqueeArmed.current = key
2004
+ // Leaving the previous row stops its pass immediately.
2005
+ setMarquee(previous => (previous.key === '' ? previous : idleMarquee))
2006
+ marqueeTimer.current = setTimeout(() => {
2007
+ marqueeTimer.current = null
2008
+ if (marqueeArmed.current !== key) return
2009
+ marqueeArmed.current = ''
2010
+ const nameWindow = typeof element?.querySelector === 'function'
2011
+ ? element.querySelector(selector)
2012
+ : null
2013
+ if (prefersReducedMotion()) return
2014
+ const geometry = marqueePassGeometry(nameClippedBy(nameWindow))
2015
+ if (geometry.shift === 0) return
2016
+ setMarquee({ key, shift: geometry.shift, duration: geometry.duration })
2017
+ }, MARQUEE_DELAY)
2018
+ }
2019
+ /**
2020
+ * Pointer left a row. An armed delay for THAT row is cancelled (so a
2021
+ * quick sweep never starts anything), and a running pass is dropped.
2022
+ * @param {string} key - the row the pointer left.
2023
+ */
2024
+ const leaveRow = key => {
2025
+ if (marqueeArmed.current === key) disarmMarquee()
2026
+ setMarquee(previous => (previous.key === key ? idleMarquee : previous))
2027
+ }
2028
+ // Unmount safety: a pending timer must not outlive the seat.
2029
+ useEffect(() => () => { clearTimeout(marqueeTimer.current) }, [])
2030
+
1582
2031
  // Standard props always carry these three hooks for a session-scope slot;
1583
2032
  // the fallbacks keep the component renderable (rather than throwing) in a
1584
2033
  // composition that omits one, and they are plain values so the hook order
@@ -1695,10 +2144,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1695
2144
  for (const family of families) {
1696
2145
  family.scored.sort((left, right) => left.score - right.score)
1697
2146
  // Under family grouping each row's primary text is its provider and
1698
- // the family name is NOT repeated (the heading already says it). Only
1699
- // when ONE provider offers several models in this family do the rows
1700
- // need a short distinguishing tail, so the same provider name is not
1701
- // printed twice with no way to tell the models apart.
2147
+ // the family name is NOT repeated (the heading already says it). The
2148
+ // heading is only half the picture though: it cannot say WHICH model
2149
+ // the row stands for (`Qwen` holds 3.8-Flash-Next and 3.8-27B), nor
2150
+ // carry the catalog's rate multiplier (`Deepseek-V4.1-Flash · x0.00`).
2151
+ // Both move into a tail at the END of the row, drawn from the model
2152
+ // name with the family heading and the provider's own label removed.
1702
2153
  const byProvider = new Map()
1703
2154
  for (const item of family.scored) {
1704
2155
  const list = byProvider.get(item.entry.providerId) ?? []
@@ -1706,17 +2157,21 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
1706
2157
  byProvider.set(item.entry.providerId, list)
1707
2158
  }
1708
2159
  for (const list of byProvider.values()) {
1709
- if (list.length <= 1) continue
1710
2160
  const used = new Map()
1711
2161
  for (const item of list) {
1712
- // An empty variant is intentional: the plain/base model of this
1713
- // family carries no tag at all. Only a real collision (two rows
1714
- // with the same non-empty variant, or two rows both empty) needs
1715
- // a distinguishing fallback — the model id, which is unique.
1716
- const variant = rowVariantOf(item.entry.model, family.title)
2162
+ // An empty tail is valid: a model whose name says nothing beyond
2163
+ // the family and the provider draws no tail at all.
2164
+ const variant = rowVariantOf(
2165
+ item.entry.model,
2166
+ family.title,
2167
+ item.entry.providerLabel,
2168
+ )
1717
2169
  item.entry.rowVariant = variant
1718
2170
  used.set(variant, (used.get(variant) ?? 0) + 1)
1719
2171
  }
2172
+ // Two rows of one provider that still read alike (the same name
2173
+ // under different ids) fall back to the unique model id, so no two
2174
+ // rows in the grid are indistinguishable.
1720
2175
  if ([...used.values()].some(count => count > 1)) {
1721
2176
  for (const item of list) {
1722
2177
  if (used.get(item.entry.rowVariant) > 1) {
@@ -2011,8 +2466,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2011
2466
  // yet: the row text is struck through until a later call for the same
2012
2467
  // model route returns anything else.
2013
2468
  const insufficientRoute = marks.marks.get(routeKeyOf(providerId, model.id)) === true
2469
+ // The marquee runs one row at a time, keyed by the model route so a
2470
+ // re-render cannot start the wrong row (DEC-021).
2471
+ const routeKey = `${providerId}/${model.id}`
2472
+ const running = marquee.key === routeKey
2014
2473
  return h('button', {
2015
- key: `${providerId}/${model.id}`,
2474
+ key: routeKey,
2016
2475
  type: 'button',
2017
2476
  className: 'dshmf-row',
2018
2477
  'aria-pressed': selected,
@@ -2020,15 +2479,37 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2020
2479
  title: rowTitle,
2021
2480
  disabled: busy,
2022
2481
  onClick: () => { chooseModel(providerId, model) },
2482
+ // The pointer must REST for MARQUEE_DELAY before anything moves, so
2483
+ // sweeping across the grid never triggers it. Measuring happens in
2484
+ // the timeout, when the row is laid out and the panel is settled.
2485
+ onMouseEnter: event => { enterRow(routeKey, event?.currentTarget) },
2486
+ onMouseLeave: () => { leaveRow(routeKey) },
2487
+ // Keyboard focus counts as "resting on the row" for the same reason
2488
+ // the tooltip does: a focused row is one the user is looking at.
2489
+ onFocus: event => { enterRow(routeKey, event?.currentTarget) },
2490
+ onBlur: () => { leaveRow(routeKey) },
2023
2491
  },
2024
2492
  // `nameRuns` (xN multiplier markers) is applied to whatever the
2025
2493
  // primary text is: the model name under provider grouping, or the
2026
- // provider name under family grouping.
2027
- h('span', { className: 'dshmf-rowName' }, ...nameRuns(primary)),
2494
+ // provider name under family grouping. Only the track (inside the
2495
+ // clipped window) moves; the variant tail, the badge and the spinner
2496
+ // are siblings AFTER the window and stay put on the right.
2497
+ h('span', { className: 'dshmf-rowNameWindow' },
2498
+ running
2499
+ ? renderNameTrack({ shift: marquee.shift, duration: marquee.duration, children: nameRuns(primary) })
2500
+ : h('span', { className: 'dshmf-rowNameTrack dshmf-rowName' }, ...nameRuns(primary)),
2501
+ ),
2502
+ // The model-specific tail sits at the END of the row, after the
2503
+ // primary text: the heading already names the family, and the
2504
+ // provider is the primary text, so what is LEFT is the型号 and the
2505
+ // multiplier (`3.8-Flash-Next`, `V4.1-Flash · x0.01`). It runs
2506
+ // through `nameRuns` too, so a multiplier in the tail keeps the
2507
+ // same two-sizes-smaller treatment it gets inside a model name.
2508
+ // It shrinks before the primary text and never wraps.
2028
2509
  showVariant ? h('span', {
2029
2510
  className: 'dshmf-rowProvider',
2030
2511
  title: rowVariant,
2031
- }, rowVariant) : null,
2512
+ }, ...nameRuns(rowVariant)) : null,
2032
2513
  badge === null ? null : h('span', {
2033
2514
  className: 'dshmf-badge',
2034
2515
  'data-tone': badge.tone,
@@ -2042,12 +2523,34 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2042
2523
  ? 'status.loading'
2043
2524
  : totalModels === 0 ? 'empty.models' : 'empty.search'
2044
2525
 
2045
- const renderBlock = block => (block.kind === 'title'
2046
- ? h('div', {
2526
+ // A group title is a marquee-able band like a model row (DEC-023): the
2527
+ // band itself is the frame and never moves, while its text sits in a
2528
+ // clipped window around a track that slides on hover when the heading
2529
+ // does not fit its column. Titles and rows share one state machine, keyed
2530
+ // by `block.key` (`t:…`) versus a row's route key, so only one thing in
2531
+ // the whole panel scrolls at a time. The band keeps its `title` tooltip
2532
+ // for the hover case the OS tooltip covers.
2533
+ const renderTitle = block => {
2534
+ const running = marquee.key === block.key
2535
+ return h('div', {
2047
2536
  className: 'dshmf-groupTitle',
2048
2537
  key: block.key,
2049
2538
  title: block.label,
2050
- }, block.label)
2539
+ // A heading has no action of its own: these handlers only arm the
2540
+ // marquee, so keyboard focus is deliberately NOT a trigger.
2541
+ onMouseEnter: event => { enterRow(block.key, event?.currentTarget, '.dshmf-groupTitleWindow') },
2542
+ onMouseLeave: () => { leaveRow(block.key) },
2543
+ },
2544
+ h('span', { className: 'dshmf-groupTitleWindow' },
2545
+ running
2546
+ ? renderTitleTrack({ shift: marquee.shift, duration: marquee.duration, children: block.label })
2547
+ : h('span', { className: 'dshmf-groupTitleTrack' }, block.label),
2548
+ ),
2549
+ )
2550
+ }
2551
+
2552
+ const renderBlock = block => (block.kind === 'title'
2553
+ ? renderTitle(block)
2051
2554
  : renderRow(block.entry))
2052
2555
 
2053
2556
  // Each child is placed EXPLICITLY (`grid-column` below), never by
@@ -2154,6 +2657,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2154
2657
  }
2155
2658
  setSelectionFocus(false)
2156
2659
  triggerRef.current?.focus()
2660
+ // Opening the panel IS showing the models: ask the store to re-read
2661
+ // the clock first, so a strike that went stale while the panel was
2662
+ // closed is gone from the rows this click draws (DEC-024).
2663
+ if (typeof markFace.refresh === 'function') markFace.refresh()
2157
2664
  setOpen(true)
2158
2665
  },
2159
2666
  },
@@ -2354,14 +2861,20 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2354
2861
  }, teamFailure),
2355
2862
 
2356
2863
  // ---- measurement probe (never painted). It mirrors a family-
2357
- // grouped row (provider primary + variant tag), the widest row
2358
- // shape, so the measured row height covers every mode. It carries
2359
- // no trailing tail: the selected row is a filled band now and the
2360
- // spinner is only drawn for a pending selection.
2864
+ // grouped row (provider primary + model-specific tail) and a group
2865
+ // title, the two shapes the grid can draw, so the measured row and
2866
+ // head heights cover every mode. Both now use the window/track
2867
+ // split (DEC-021, DEC-023), so the probe mirrors that too — a track
2868
+ // inside a clipped window is what the grid actually sizes. It
2869
+ // carries no badge and no spinner: the selected row is a filled band
2870
+ // now and the spinner is only drawn for a pending selection.
2361
2871
  h('div', { className: 'dshmf-probe', 'aria-hidden': 'true' },
2362
- h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' }, 'X'),
2872
+ h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' },
2873
+ h('span', { className: 'dshmf-groupTitleWindow' },
2874
+ h('span', { className: 'dshmf-groupTitleTrack' }, 'X'))),
2363
2875
  h('div', { ref: probeRowRef, className: 'dshmf-row' },
2364
- h('span', { className: 'dshmf-rowName' }, 'X'),
2876
+ h('span', { className: 'dshmf-rowNameWindow' },
2877
+ h('span', { className: 'dshmf-rowNameTrack dshmf-rowName' }, 'X')),
2365
2878
  h('span', { className: 'dshmf-rowProvider' }, 'X'),
2366
2879
  ),
2367
2880
  ),
@@ -2466,7 +2979,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
2466
2979
  // Pure layout and grouping derivations plus the component itself,
2467
2980
  // exposed for `node --test` so grouping, filtering and the column
2468
2981
  // algorithm are pinned without a browser. The shell never reads this key.
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 },
2982
+ __test: { marqueePassGeometry, nameClippedBy, MARQUEE_DELAY, MARQUEE_PX_PER_SECOND, MARQUEE_MIN_MS, familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, STRIKE_TTL, markIsLive, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore },
2470
2983
  __component: ModelFlatMap,
2471
2984
  __seat: Seat,
2472
2985
  apply(ctx) {
package/docs/DETAIL.md CHANGED
@@ -47,16 +47,23 @@ and the agent teamwork switch are all visible at once.
47
47
  `acme-7b` are one `Acme`), and dictionary families are never split by
48
48
  generation. A provider prefix does not hide the brand (`codebuddy-hy3` →
49
49
  `HY3`).
50
- - **Family-grouped rows are provider-primary.** A family heading says what the
51
- models *are*, so the family name is not repeated in a row. The row's primary
52
- text is the provider name — the one thing a heading cannot tell you when a
53
- family spans providers. The full model name and description stay in the row
54
- tooltip. Only when one provider offers several models in the same family do
55
- those rows carry a short secondary variant derived from the id tail (e.g.
56
- `hy4free` → `free`, `hy3-x` → `x`); the plain base model carries no tag.
57
- Colliding variants fall back to the unique model id. Under *By provider* the
58
- heading already is the provider, so the primary text stays the model name and
59
- no secondary tag is drawn.
50
+ - **Family-grouped rows are provider-primary, with a model tail.** A family
51
+ heading says what the models *are*, so the family name is not repeated in a
52
+ row. The row's primary text is the provider name — the one thing a heading
53
+ cannot tell you when a family spans providers. The full model name and
54
+ description stay in the row tooltip. What the heading *and* the provider
55
+ cannot say — which model of the family this row is, and the catalog's rate
56
+ multiplier — is drawn as a quieter tail at the END of the row. The tail is
57
+ the model name with the family heading and the provider's own label removed,
58
+ so `Qwen3.8-Flash-Next` under `Qwen` reads `3.8-Flash-Next`, `Qwen3.8-27B`
59
+ reads `3.8-27B`, `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` reads
60
+ `V4.1-Flash · x0.00`, and `豆包 2.1 Turbo` under provider `豆包(doubao)` reads
61
+ `2.1 Turbo`. Version numbers are KEPT (they are the型号 the heading lacks),
62
+ and a multiplier inside the tail is drawn two sizes smaller and bold, exactly
63
+ as it is inside a model name under *By provider*. Two rows of one provider
64
+ that still read alike fall back to the unique model id; a model whose name
65
+ adds nothing draws no tail. Under *By provider* the heading already is the
66
+ provider, so the primary text stays the model name and no tail is drawn.
60
67
  - **Adaptive columns.** The panel measures one group title and one model row,
61
68
  subtracts the fixed sections from the 320 px cap, and picks the smallest
62
69
  column count from 1 to 3 that fits. If even three columns overflow, it keeps
@@ -97,9 +104,10 @@ and the agent teamwork switch are all visible at once.
97
104
  translucent white chip so it stays legible on the blue fill.
98
105
  - **Out-of-balance models are struck through.** When a model call fails because
99
106
  the account has no usable balance, credit, or quota left, that model's row
100
- text is drawn with a `line-through` decoration, and the strike stays until a
107
+ text is drawn with a `line-through` decoration. The strike stays until a
101
108
  later call for the same model returns something other than an
102
- insufficient-balance failure. The row is not recoloured, so the strike reads
109
+ insufficient-balance failure, **or** until it has gone 24 hours without a new
110
+ attempt — whichever comes first. The row is not recoloured, so the strike reads
103
111
  the same on the selected row's blue band and in both themes, and it does not
104
112
  compete with the error strip's red. Only the row's text (the name and a
105
113
  family variant tag) is struck; the badge chip and the pending spinner are
@@ -137,6 +145,21 @@ and the agent teamwork switch are all visible at once.
137
145
  that failed for a different reason. A cancellation (`aborted`), a `blocked`
138
146
  turn, and the synthetic `interrupted` / `forked` closers clear nothing —
139
147
  the model never answered, so they say nothing about the account.
148
+ - **How long it is drawn without one.** Every record keeps the wall-clock
149
+ time of the observation that made it, which is what separates "this model is
150
+ out of balance" from "this model has not been tried since that verdict". A
151
+ mark older than 24 hours is not drawn: an exhausted balance gets topped up
152
+ and a quota resets daily, so a strike nobody refreshed has stopped being
153
+ information. A retry restamps the record and restarts the window, so the
154
+ rule is "24 hours since the newest thing we learned about this route", not
155
+ "24 hours since the row was painted". Retiring a strike is the one transition
156
+ no event reports, so the store watches the clock itself: on every fold, when
157
+ a seat subscribes, when the panel is opened, and on a timer set for the
158
+ soonest deadline (that handle is `unref`'d, so a day-long wait never holds
159
+ the host process). The record SURVIVES its own expiry, as the watermark for
160
+ the route — which is why re-folding the very failure that made it, after a
161
+ reload or when a second Session's older history is walked, cannot put the
162
+ decoration back.
140
163
  - **The marks are global, not per conversation.** Whether a model has quota
141
164
  left is a fact about the model route and its account, so the state is NOT
142
165
  scoped to the Session a panel happens to render for. One module-level store
@@ -147,8 +170,12 @@ and the agent teamwork switch are all visible at once.
147
170
  - **It survives a reload.** The marks are persisted to `localStorage`
148
171
  (origin-scoped, like the grouping choice), so a fresh page load starts from
149
172
  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.
173
+ silently when storage is unavailable. Two lifetimes apply to that record and
174
+ they are not the same one: a mark stops being DRAWN after 24 hours without a
175
+ new observation, while the record itself is kept for 7 days, because it is
176
+ also the watermark that keeps an older replayed failure from restamping the
177
+ route. So a reload of a day-old verdict restores the row as plain text, and
178
+ the fold that re-reads the failure that produced it changes nothing.
152
179
  - **The newest observation wins.** A Session feed carries no cross-Session
153
180
  sequence number, but every durable event keeps the Host's wall-clock `time`.
154
181
  Each route keeps the time of its last decision, and an observation older
@@ -182,7 +209,11 @@ Everything goes through public Client APIs:
182
209
  Session the Session list names (`ctx.sessions.list`) to derive the global
183
210
  per-model insufficient-balance marks, persists them to `localStorage`, and
184
211
  every seat subscribes through `useSyncExternalStore` so a mark transition
185
- repaints the panel while every other event stays quiet.
212
+ repaints the panel while every other event stays quiet. The same store also
213
+ re-reads the clock on subscribe, on panel open and on a self-armed timer,
214
+ because a strike reaching its 24-hour age is a transition no feed will ever
215
+ report. A snapshot is replaced only when the set of drawn marks actually
216
+ changed, so those extra reads cannot churn the panel.
186
217
  - `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
187
218
  - `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
188
219
  host's own `agent-team` wording when that namespace is registered.
@@ -231,6 +262,25 @@ The band also carries a low-key frame: a hairline border
231
262
  group start clearly without boxing the group's rows — a group's rows may flow
232
263
  across columns, so only its label is framed (see DEC-003).
233
264
 
265
+ ### Long text: one-pass hover marquee
266
+
267
+ A model name, or a group heading, that does not fit its column is ellipsised.
268
+ Resting the pointer on it for 0.5 s starts a single, eased, leftward pass that
269
+ stops when the last glyph reaches the window's right edge; leaving resets it
270
+ immediately with no animation. The pass runs ONCE, is `ease-in-out` rather than
271
+ `linear`, and honours `prefers-reduced-motion: reduce` by not running at all.
272
+
273
+ Both a model row and a group title use the same structure, so only the text
274
+ slides and the frame never does: a clipped **window** (`overflow: hidden` +
275
+ `text-overflow: ellipsis`) wraps a **track** (`width: max-content`) that is
276
+ allowed to be wider than the window. For a row the frame is the row itself, so
277
+ the right-aligned tail, badge (`限时免费`, `x0.1`) and spinner never move; for a
278
+ title the frame is the band, so its fill, frame, radius and elevation stay put.
279
+ One state machine drives both — keyed by the row's route or the title's block
280
+ key — so only one thing in the panel scrolls at a time. The travel distance is
281
+ the measured overflow, and the duration is that distance divided by 50 px/s
282
+ (see DEC-021, DEC-022, DEC-023).
283
+
234
284
  ## Limitations
235
285
 
236
286
  - The panel replaces the composer seat only; the `/model` popup is untouched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-flatmap",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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": {