dsh-sidecard-ask 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +62 -16
  3. package/client.js +505 -103
  4. package/index.js +457 -68
  5. package/package.json +69 -66
package/client.js CHANGED
@@ -35,7 +35,77 @@ window.__ModuleLoader__.load({
35
35
  // ─────────────────────────────────────────────────────────────────────
36
36
 
37
37
  const PLUGIN_ID = 'dsh-sidecard-ask'
38
- const API = '/sidecard-ask/api'
38
+ /**
39
+ * Base path of this plugin's Host API.
40
+ *
41
+ * Resolved against `document.baseURI` rather than hard-coded as the
42
+ * root-absolute `/sidecard-ask/api`, which is what the Host's own Client
43
+ * does for its transport base (`dsh-client-connection/lib/client.browser.js`:
44
+ * `const base = baseUrl ?? document.baseURI`). A root-absolute path drops
45
+ * the deployment prefix, so a DSH served under a path prefix
46
+ * (`dsh web --public-url https://host/dsh/`) sent every request to
47
+ * `/sidecard-ask/api/*` and got a 404. The Electron shell also loads the app
48
+ * from `dsh-app://app/`, where only a document-relative path is meaningful.
49
+ *
50
+ * At the document root (both the default Web deployment and the Electron
51
+ * shell) this evaluates to exactly the previous `/sidecard-ask/api`, so no
52
+ * existing deployment changes behaviour. `document.baseURI` already carries
53
+ * the trailing slash the Host normalises its public root to; the explicit
54
+ * `replace` below keeps that true even if a `<base>` without one is used.
55
+ */
56
+ const API = (() => {
57
+ try {
58
+ const base = typeof document === 'undefined' ? undefined : document.baseURI
59
+ if (typeof base !== 'string' || base === '') return '/sidecard-ask/api'
60
+ return `${new URL('.', base).pathname.replace(/\/$/, '')}/sidecard-ask/api`
61
+ } catch {
62
+ return '/sidecard-ask/api'
63
+ }
64
+ })()
65
+
66
+ /** Root-absolute base — the retry target when the prefix guess misses. */
67
+ const API_ROOT = '/sidecard-ask/api'
68
+
69
+ /**
70
+ * The base actually used from now on.
71
+ *
72
+ * `new URL('.', base)` treats the last path segment as a FILE name, so a
73
+ * deployment whose public URL has no trailing slash (`https://host/dsh`)
74
+ * resolves to the document root and loses the prefix. There is no way to
75
+ * tell `/dsh` (directory) from `/dsh` (file) without asking the server, so
76
+ * the first `404` retries once against the root-absolute base: 404 is the
77
+ * only unambiguous signal here — auth answers `401`, a foreign origin
78
+ * `403`, and `404` means the route is not mounted at this path at all.
79
+ */
80
+ let apiBase = API
81
+
82
+ /**
83
+ * The base to use after one response.
84
+ * @param {number} status - HTTP status of the attempt.
85
+ * @param {string} current - the base that produced it.
86
+ * @returns {string} the base for the next attempt (unchanged unless 404).
87
+ */
88
+ function apiBaseAfter(status, current) {
89
+ return status === 404 && current !== API_ROOT ? API_ROOT : current
90
+ }
91
+
92
+ /**
93
+ * `fetch` against the plugin API, retrying once on a 404 with the other
94
+ * base. A 404 body is never consumed here: the caller's own error path
95
+ * reads it (or ignores it), exactly as before.
96
+ * @param {string} path - `/state` or `/ask`, appended to the base.
97
+ * @param {RequestInit} [init] - fetch options (body must be replayable).
98
+ * @returns {Promise<Response>} the response of whichever attempt answered.
99
+ */
100
+ async function fetchApi(path, init) {
101
+ const attempted = apiBase
102
+ let response = await fetch(`${attempted}${path}`, init)
103
+ if (response.status === 404 && attempted !== API_ROOT) {
104
+ apiBase = apiBaseAfter(response.status, attempted)
105
+ response = await fetch(`${apiBase}${path}`, init)
106
+ }
107
+ return response
108
+ }
39
109
  /** Slot ids contributed by this plugin (also the overlay entry ids). */
40
110
  const IDS = {
41
111
  overlay: 'sidecard-ask',
@@ -47,7 +117,7 @@ window.__ModuleLoader__.load({
47
117
  * the Host and the module's `api.version` both read it, so a report can
48
118
  * never claim a generation the browser is not actually running.
49
119
  */
50
- const CLIENT_VERSION = '2.0.1'
120
+ const CLIENT_VERSION = '2.1.0'
51
121
 
52
122
  /**
53
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -77,6 +147,41 @@ window.__ModuleLoader__.load({
77
147
  */
78
148
  const HISTORY_TURNS = 6
79
149
 
150
+ // Per-turn wire caps mirroring the Host's prompt-side limits: a long earlier
151
+ // answer must neither bloat the request body past the 1 MiB limit nor push
152
+ // the current question out of the child prompt.
153
+ const HISTORY_TURN_Q_CHARS = 500
154
+ const HISTORY_TURN_A_CHARS = 4000
155
+
156
+ /**
157
+ * Streaming flush cadence.
158
+ *
159
+ * 50 ms is the cadence that reads as "live"; a 32 k+ answer costs 1.2–2.0 ms
160
+ * of markdown parsing per frame (measured, both Chromium 150 and 155), so
161
+ * past that length the cadence doubles — the eye cannot tell, and the work
162
+ * halves.
163
+ */
164
+ const STREAM_FLUSH_MS = 50
165
+ const STREAM_FLUSH_SLOW_MS = 100
166
+ const STREAM_SLOW_CHARS = 32_000
167
+
168
+ /**
169
+ * How many cards may stay mounted at once. Past this the oldest finished
170
+ * card is hidden (not deleted): its body unmounts, its data stays, and the
171
+ * durable history still restores it.
172
+ */
173
+ const MAX_LIVE_CARDS = 20
174
+
175
+ /**
176
+ * How many characters of history a page load may re-render unfolded.
177
+ *
178
+ * Replay used to restore every saved card `open:true`, bounded only by the
179
+ * card count (default 10) and a 64 k per-field limit — a worst case of
180
+ * ~640 k characters of markdown parsed and mounted before the page is
181
+ * usable. Past this budget the older cards come back collapsed.
182
+ */
183
+ const REPLAY_OPEN_BUDGET_CHARS = 200_000
184
+
80
185
  /**
81
186
  * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
82
187
  * before `/state` answers (first paint) and when the Host is unreachable;
@@ -508,6 +613,8 @@ window.__ModuleLoader__.load({
508
613
  errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
509
614
  errorAborted: '作答被取消或超时',
510
615
  errorUnreachableBody: '宿主接口不可达',
616
+ errorUnauthorizedBody: '需要先通过 DSH 登录(宿主会话未认证)',
617
+ errorForbiddenBody: '请求来源不受信任(Host / Origin 不匹配)',
511
618
  errorBadResponseBody: '宿主返回了无法解析的响应',
512
619
  errorStreamBrokenBody: '流式连接中断',
513
620
  errorStreamEndedBody: '流式连接意外结束',
@@ -776,6 +883,8 @@ window.__ModuleLoader__.load({
776
883
  errorNoParentBody: 'No active session agent to answer independently',
777
884
  errorAborted: 'The answer was cancelled or timed out',
778
885
  errorUnreachableBody: 'The host API is unreachable',
886
+ errorUnauthorizedBody: 'Sign in to DSH first (the host session is not authenticated)',
887
+ errorForbiddenBody: 'Untrusted request origin (Host / Origin mismatch)',
779
888
  errorBadResponseBody: 'The host returned an unreadable response',
780
889
  errorStreamBrokenBody: 'The stream connection broke',
781
890
  errorStreamEndedBody: 'The stream ended without a terminal event',
@@ -1049,6 +1158,11 @@ window.__ModuleLoader__.load({
1049
1158
  'no-parent': { key: 'errorNoParentBody' },
1050
1159
  aborted: { key: 'errorAborted' },
1051
1160
  unreachable: { key: 'errorUnreachableBody' },
1161
+ // The Host now gates these routes on the DSH browser session, so both are
1162
+ // fully known Client-side: "log in" and "untrusted origin" are actionable
1163
+ // in a way a generic unreachable message is not.
1164
+ unauthorized: { key: 'errorUnauthorizedBody' },
1165
+ forbidden: { key: 'errorForbiddenBody' },
1052
1166
  'bad-response': { key: 'errorBadResponseBody' },
1053
1167
  'stream-broken': { key: 'errorStreamBrokenBody' },
1054
1168
  'stream-ended': { key: 'errorStreamEndedBody' },
@@ -1080,7 +1194,7 @@ window.__ModuleLoader__.load({
1080
1194
  async function postJson(method, payload) {
1081
1195
  let response
1082
1196
  try {
1083
- response = await fetch(`${API}/${method}`, {
1197
+ response = await fetchApi(`/${method}`, {
1084
1198
  method: 'POST',
1085
1199
  headers: { 'content-type': 'application/json' },
1086
1200
  body: JSON.stringify(payload ?? {}),
@@ -1102,10 +1216,24 @@ window.__ModuleLoader__.load({
1102
1216
 
1103
1217
  /** GET the plugin state (`/state` also answers POST, GET keeps it cacheable-free). */
1104
1218
  async function getState() {
1105
- const response = await fetch(`${API}/state`, { method: 'GET' })
1106
- const body = await response.json()
1107
- if (body?.ok !== true) throw Object.assign(new Error('state failed'), { code: 'bad-response' })
1108
- return body.value
1219
+ let response
1220
+ try {
1221
+ response = await fetchApi('/state', { method: 'GET' })
1222
+ } catch (error) {
1223
+ throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
1224
+ }
1225
+ let body
1226
+ try {
1227
+ body = await response.json()
1228
+ } catch {
1229
+ throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'bad-response' })
1230
+ }
1231
+ // Keep the Host's own code: a 401 ("log in first") and a 403 ("untrusted
1232
+ // origin") are actionable in a way that a generic bad-response is not.
1233
+ if (body?.ok === true) return body.value
1234
+ const failure = new Error(body?.error?.message ?? `HTTP ${response.status}`)
1235
+ failure.code = body?.error?.code ?? 'bad-response'
1236
+ throw failure
1109
1237
  }
1110
1238
 
1111
1239
  /**
@@ -1118,7 +1246,7 @@ window.__ModuleLoader__.load({
1118
1246
  async function streamAsk(payload, handlers) {
1119
1247
  let response
1120
1248
  try {
1121
- response = await fetch(`${API}/ask`, {
1249
+ response = await fetchApi('/ask', {
1122
1250
  method: 'POST',
1123
1251
  headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
1124
1252
  body: JSON.stringify(payload),
@@ -1130,7 +1258,18 @@ window.__ModuleLoader__.load({
1130
1258
  return
1131
1259
  }
1132
1260
  if (!response.ok || response.body === null) {
1133
- handlers.onError({ code: `http-${response.status}`, message: `HTTP ${response.status}` })
1261
+ // Non-200 answers are JSON errors ({ok:false,error:{code,message}}),
1262
+ // not SSE; surface the server's localized code instead of a bare
1263
+ // "HTTP 400".
1264
+ let wire = null
1265
+ try {
1266
+ const rawBody = await response.text()
1267
+ const parsed = rawBody ? JSON.parse(rawBody) : null
1268
+ if (parsed?.error !== null && typeof parsed?.error === 'object') wire = parsed.error
1269
+ } catch { wire = null }
1270
+ handlers.onError(wire && typeof wire.code === 'string'
1271
+ ? wire
1272
+ : { code: `http-${response.status}`, message: `HTTP ${response.status}` })
1134
1273
  return
1135
1274
  }
1136
1275
  const reader = response.body.getReader()
@@ -1257,6 +1396,57 @@ window.__ModuleLoader__.load({
1257
1396
  return store.state
1258
1397
  }
1259
1398
 
1399
+ /**
1400
+ * Per-card handler bundle with a STABLE identity.
1401
+ *
1402
+ * `store.set` bumps a revision on every streamed delta, and the floating
1403
+ * layer re-renders on each one. Handing `AnswerCard` a fresh arrow per
1404
+ * render makes every prop compare unequal, so `React.memo` could never bail
1405
+ * out. The bundle is therefore created once per card id and reads the card
1406
+ * through a ref at call time — a closure must never capture a stale card,
1407
+ * or "copy" would copy the text from two deltas ago.
1408
+ * @param {object} actions - the plugin's action table (stable for the
1409
+ * lifetime of `apply`).
1410
+ * @returns {(card: object) => object} handler bundle for one card.
1411
+ */
1412
+ function useCardHandlers(actions) {
1413
+ const latest = React.useRef(new Map())
1414
+ const cache = React.useRef(new Map())
1415
+ return React.useCallback((card) => {
1416
+ if (card === null || card === undefined) return null
1417
+ latest.current.set(card.id, card)
1418
+ let bundle = cache.current.get(card.id)
1419
+ if (bundle === undefined) {
1420
+ // The maps are keyed by card id and live for the whole session, so a
1421
+ // long session would keep one tiny bundle per card ever shown. Past a
1422
+ // generous ceiling both are dropped: the live cards simply get a
1423
+ // fresh (still stable) bundle and re-render once.
1424
+ if (cache.current.size > 64) {
1425
+ cache.current.clear()
1426
+ latest.current.clear()
1427
+ }
1428
+ const current = () => latest.current.get(card.id) ?? card
1429
+ bundle = {
1430
+ onCopy: element => actions.copy(current(), element),
1431
+ onFollowUp: text => actions.followUp(current(), text),
1432
+ onRetry: () => actions.retry(current()),
1433
+ onRetryEdit: text => actions.retryEdit(current(), text),
1434
+ onCancel: () => actions.cancel(current()),
1435
+ onSendToMain: () => { void actions.sendToMain(current()) },
1436
+ onClose: () => actions.close(current()),
1437
+ onLocate: target => actions.locateCard(target ?? current()),
1438
+ onExport: target => actions.exportCard(target ?? current()),
1439
+ onInsert: target => actions.insertAnswer(target ?? current()),
1440
+ onReplace: target => actions.insertAnswer(target ?? current(), { replace: true }),
1441
+ onViewVersion: index => actions.viewVersion(current(), index),
1442
+ onDelete: () => actions.deleteCard(current()),
1443
+ }
1444
+ cache.current.set(card.id, bundle)
1445
+ }
1446
+ return bundle
1447
+ }, [actions])
1448
+ }
1449
+
1260
1450
  // ─────────────────────────────────────────────────────────────────────
1261
1451
  // §4.5 family singleton
1262
1452
  //
@@ -1364,20 +1554,63 @@ window.__ModuleLoader__.load({
1364
1554
  return cards.find(card => card.id === id)
1365
1555
  }
1366
1556
 
1367
- /** Append text to one card's streaming answer. */
1368
- function appendCardText(id, delta) {
1369
- const cards = store.state.cards.map(card => (
1370
- card.id === id ? { ...card, text: card.text + delta } : card
1371
- ))
1372
- store.set({ cards })
1557
+ /**
1558
+ * Hide the oldest finished cards once a stack exceeds its cap.
1559
+ *
1560
+ * Pure so the rule can be asserted without a renderer: the newest card and
1561
+ * anything still streaming are never hidden, `open:false` cards are skipped
1562
+ * (already hidden), and the input array is not mutated.
1563
+ * @param {object[]} cards - newest first.
1564
+ * @param {number} [cap] - how many may stay visible.
1565
+ * @param {string|null} [keptId] - id that must survive this pass.
1566
+ * @returns {object[]} the same list, with overflow entries closed.
1567
+ */
1568
+ function capLiveCards(cards, cap = MAX_LIVE_CARDS, keptId = null) {
1569
+ if (!Array.isArray(cards) || cards.length <= cap) return cards
1570
+ const next = [...cards]
1571
+ let excess = next.length - cap
1572
+ for (let index = next.length - 1; index >= 0 && excess > 0; index -= 1) {
1573
+ const item = next[index]
1574
+ if (item === null || item === undefined) continue
1575
+ if (item.id === keptId || item.open === false) continue
1576
+ if (item.status === 'pending' || item.status === 'streaming') continue
1577
+ next[index] = { ...item, open: false }
1578
+ excess -= 1
1579
+ }
1580
+ return next
1373
1581
  }
1374
1582
 
1375
- /** Append reasoning text to one card. */
1376
- function appendCardReasoning(id, delta) {
1377
- const cards = store.state.cards.map(card => (
1378
- card.id === id ? { ...card, reasoning: card.reasoning + delta } : card
1379
- ))
1380
- store.set({ cards })
1583
+ /**
1584
+ * Whether replayed card `index` comes back unfolded.
1585
+ *
1586
+ * The newest replay always does — a page load that hides everything would
1587
+ * look like data loss — and the rest only while the running total stays
1588
+ * inside the budget.
1589
+ * @param {number} index - 0 = newest.
1590
+ * @param {number} replayedChars - characters replayed so far, inclusive.
1591
+ * @returns {boolean} true when the card should mount unfolded.
1592
+ */
1593
+ function replayCardOpen(index, replayedChars) {
1594
+ return index === 0 || replayedChars <= REPLAY_OPEN_BUDGET_CHARS
1595
+ }
1596
+
1597
+ /**
1598
+ * Prepend one card, hiding the oldest FINISHED card once the stack grows
1599
+ * past the live cap.
1600
+ *
1601
+ * Cards used to accumulate for the whole session: nothing capped
1602
+ * `store.state.cards`, so a long session kept every answer mounted (each
1603
+ * one a full React subtree). Past the cap the oldest finished card is
1604
+ * hidden — `open:false` keeps its data, unmounts its body, and the durable
1605
+ * history still offers it back. A card that is still streaming is never
1606
+ * hidden: that would orphan its stream.
1607
+ * @param {object} card - the card to put on top.
1608
+ * @param {object} [extra] - additional store fields to commit in the same
1609
+ * update (the carrier's surface label, for instance).
1610
+ */
1611
+ function pushCard(card, extra = null) {
1612
+ const cards = capLiveCards([card, ...store.state.cards], MAX_LIVE_CARDS, card.id)
1613
+ store.set(extra === null ? { cards } : { ...extra, cards })
1381
1614
  }
1382
1615
 
1383
1616
  /** Show a transient toast line. */
@@ -1531,14 +1764,25 @@ window.__ModuleLoader__.load({
1531
1764
  * Truncate an over-long selection for an outgoing prompt, keeping head and
1532
1765
  * tail. Mirrors the Host's `truncateSelection` (asserted by contract test):
1533
1766
  * code points, not UTF-16 units, so a surrogate pair is never split.
1767
+ *
1768
+ * The omission note is charged to the budget, not added on top of it: an
1769
+ * "inclusive cap" that the note itself overflows by ~19 characters is not a
1770
+ * cap, and the Host sizes its prompt budget from what a fixed prompt
1771
+ * leaves over.
1534
1772
  * @returns {{text: string, truncated: boolean, droppedChars: number}}
1535
1773
  */
1536
1774
  function truncateSelection(text, maxChars) {
1537
1775
  const source = typeof text === 'string' ? text : ''
1538
1776
  const points = Array.from(source)
1539
- if (points.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
1540
- const head = Math.max(1, Math.ceil(maxChars * 0.7))
1541
- const tail = Math.max(0, maxChars - head)
1777
+ const cap = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : points.length
1778
+ if (points.length <= cap) return { text: source, truncated: false, droppedChars: 0 }
1779
+ const noteLength = Array.from(`\n…(已省略中间 ${points.length} 个字符)…\n`).length
1780
+ const room = cap - noteLength
1781
+ if (room < 2) {
1782
+ return { text: points.slice(0, cap).join(''), truncated: true, droppedChars: points.length - cap }
1783
+ }
1784
+ const head = Math.max(1, Math.ceil(room * 0.7))
1785
+ const tail = Math.max(0, room - head)
1542
1786
  const dropped = points.length - head - tail
1543
1787
  return {
1544
1788
  text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
@@ -2525,14 +2769,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2525
2769
  return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
2526
2770
  }
2527
2771
 
2772
+ /**
2773
+ * Inline markdown-lite token scanner.
2774
+ *
2775
+ * Hoisted out of {@link inlineNodes} because a long answer calls it once per
2776
+ * line/paragraph on every render, and a fresh RegExp literal per call was
2777
+ * pure allocation on the streaming hot path. `/g` is stateful, so the
2778
+ * scanner resets `lastIndex` before each use (it never recurses).
2779
+ */
2780
+ const INLINE_TOKEN_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
2781
+
2528
2782
  /** Inline markdown-lite: `code`, **bold**, *italic*, [label](http url) — never innerHTML. */
2529
2783
  function inlineNodes(text, key) {
2530
2784
  const out = []
2531
- const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
2785
+ INLINE_TOKEN_RE.lastIndex = 0
2532
2786
  let last = 0
2533
2787
  let match
2534
2788
  let index = 0
2535
- while ((match = pattern.exec(text)) !== null) {
2789
+ while ((match = INLINE_TOKEN_RE.exec(text)) !== null) {
2536
2790
  if (match.index > last) out.push(text.slice(last, match.index))
2537
2791
  const token = match[0]
2538
2792
  if (token.startsWith('`')) {
@@ -3873,18 +4127,27 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3873
4127
  // context, not the answer, and a long chain-of-thought would otherwise
3874
4128
  // bury the answer the user asked for. Collapsed by default, open on
3875
4129
  // demand — the text inside still streams live while it is open.
3876
- const reasoning = typeof card.reasoning === 'string' && card.reasoning !== ''
4130
+ const reasoningText = typeof card.reasoning === 'string' ? card.reasoning : ''
4131
+ // Memoised like the answer below: without this every 50 ms delta of the
4132
+ // ANSWER re-parsed the whole reasoning stream as well, and reasoning is
4133
+ // usually the longer of the two.
4134
+ const reasoningNodes = React.useMemo(() => renderRichText(reasoningText), [reasoningText])
4135
+ const reasoning = reasoningText !== ''
3877
4136
  ? h('details', { className: 'dsa-details' },
3878
4137
  h('summary', null, t('reasoningTitle')),
3879
- h('div', { className: 'dsa-reasoning' }, renderRichText(card.reasoning)))
4138
+ h('div', { className: 'dsa-reasoning' }, reasoningNodes))
3880
4139
  : null
3881
4140
  // P2-4: the `<followups>` block is machine-readable scaffolding, never
3882
4141
  // prose. While streaming it is hidden from the first unclosed tag (a
3883
4142
  // half-written marker must not flash); once terminal the whole block is
3884
4143
  // stripped and only the answer is rendered.
3885
- const bodyText = card.status === 'streaming'
4144
+ // P6: parseFollowUps/streamVisibleText run over the whole answer on every
4145
+ // 50ms flush; memoize by the text so an unrelated store update does not
4146
+ // re-parse a long answer.
4147
+ const bodyText = React.useMemo(() => card.status === 'streaming'
3886
4148
  ? streamVisibleText(card.text)
3887
- : parseFollowUps(card.text).text
4149
+ : parseFollowUps(card.text).text, [card.text, card.status])
4150
+ const answerNodes = React.useMemo(() => renderRichText(bodyText), [bodyText])
3888
4151
  // Follow the stream only while the reader sits at the bottom; scrolling
3889
4152
  // up to reread an earlier paragraph must win over the auto-scroll. The
3890
4153
  // 48px slack keeps single-line deltas from losing the tail.
@@ -3914,7 +4177,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3914
4177
  return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerEmpty')))
3915
4178
  }
3916
4179
  return h('div', { className: 'dsa-answer', ref: containerRef },
3917
- reasoning, renderRichText(bodyText))
4180
+ reasoning, answerNodes)
3918
4181
  }
3919
4182
 
3920
4183
  /** Card footer: status, copy, follow-up, retry/edit-retry, close. */
@@ -4335,6 +4598,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4335
4598
  : null)
4336
4599
  }
4337
4600
 
4601
+ /**
4602
+ * Memoised {@link AnswerCard}.
4603
+ *
4604
+ * Only the card that actually changed gets a new `card` object
4605
+ * (`patchCard` / the stream flush keep every other card by reference), and
4606
+ * the handler bundle from {@link useCardHandlers} is stable per id — so a
4607
+ * delta on card A no longer re-renders cards B..N. `React.memo` is probed
4608
+ * because the test double (and any trimmed-down React a host might hand a
4609
+ * plugin) does not have to implement it; without it this is the same
4610
+ * component, just un-memoised.
4611
+ */
4612
+ const MemoAnswerCard = typeof React.memo === 'function' ? React.memo(AnswerCard) : AnswerCard
4613
+
4338
4614
  /**
4339
4615
  * The flow surface, i.e. the floating layer (A).
4340
4616
  *
@@ -4354,6 +4630,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4354
4630
  const containerRef = React.useRef(null)
4355
4631
  const dockRef = React.useRef(null)
4356
4632
  const state = useStoreState()
4633
+ const handlersFor = useCardHandlers(actions)
4357
4634
  const [dock, setDock] = React.useState(null)
4358
4635
  const visible = cards.filter(card => card.surface === 'flow' && card.open)
4359
4636
  const total = visible.length
@@ -4467,16 +4744,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4467
4744
  }
4468
4745
  if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
4469
4746
  // Opening/closing the rail changes no size this effect already watches,
4470
- // so the DOM itself has to be the trigger. Attributes only (no
4471
- // childList): streaming tokens rewrite text nodes continuously and must
4472
- // not wake the layer on every frame.
4747
+ // so the DOM itself has to be the trigger. The two data attributes are
4748
+ // the only signals that MEAN "the rail geometry changed". `class` and
4749
+ // `style` used to be in this list too, and a chat pane rewrites both
4750
+ // constantly (hover, scroll state, streaming badges) — every one of
4751
+ // those woke this observer and forced a layout read on the next frame.
4752
+ // Streaming tokens rewrite text nodes, which `childList`-free observing
4753
+ // already ignores.
4473
4754
  if (typeof window.MutationObserver === 'function' && document.body !== null && document.body !== undefined) {
4474
4755
  try {
4475
4756
  mutation = new window.MutationObserver(schedule)
4476
4757
  mutation.observe(document.body, {
4477
4758
  attributes: true,
4478
4759
  subtree: true,
4479
- attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active', 'style', 'class'],
4760
+ attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'],
4480
4761
  })
4481
4762
  } catch { mutation = null }
4482
4763
  }
@@ -4529,24 +4810,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4529
4810
  : null),
4530
4811
  }, children)
4531
4812
 
4532
- const cardNodes = visible.map(card => h(AnswerCard, {
4813
+ const cardNodes = visible.map(card => h(MemoAnswerCard, {
4533
4814
  key: card.id,
4534
4815
  card,
4535
4816
  surfaceLabel: null,
4536
- onCopy: element => actions.copy(card, element),
4537
- onFollowUp: text => actions.followUp(card, text),
4538
- onRetry: () => actions.retry(card),
4539
- onRetryEdit: text => actions.retryEdit(card, text),
4540
- onCancel: () => actions.cancel(card),
4541
- onSendToMain: () => actions.sendToMain(card),
4542
- onClose: () => actions.close(card),
4543
- onLocate: target => actions.locateCard(target ?? card),
4544
- onExport: target => actions.exportCard(target ?? card),
4545
- onInsert: target => actions.insertAnswer(target ?? card),
4546
- onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
4547
- onViewVersion: index => actions.viewVersion(card, index),
4548
4817
  onFloatMode: setFloatMode,
4549
- onDelete: () => actions.deleteCard(card),
4818
+ // Stable per card id (see useCardHandlers), which is what lets
4819
+ // React.memo skip every card that did not change in this tick.
4820
+ ...handlersFor(card),
4550
4821
  }))
4551
4822
 
4552
4823
  if (mode === 'capsule') {
@@ -5382,6 +5653,31 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5382
5653
  const PERSIST_LEGACY_KEY = 'dsh-sidecard-ask:cards:v1'
5383
5654
  const PERSIST_FALLBACK_CARDS = 10
5384
5655
  const PERSIST_TEXT_LIMIT = 65536
5656
+ /**
5657
+ * Total character budget for the whole v3 payload (every card, every
5658
+ * field), in UTF-16 code units — what the localStorage quota counts.
5659
+ *
5660
+ * The per-field cap alone allows ~0.9 MB for ONE card (four 64k fields
5661
+ * plus five 64k×2 versions), so the default 10-card history could reach
5662
+ * ~9 MB and blow the usual 5 MB quota on every write, which then fell
5663
+ * into the drop-one-and-retry loop below. 2M units stays under the
5664
+ * quota for mixed CJK/ASCII content and still holds ~20 long answers.
5665
+ */
5666
+ const PERSIST_TOTAL_CHARS = 2_000_000
5667
+
5668
+ /** Size of one history entry, in UTF-16 code units. */
5669
+ const entryChars = (entry) => {
5670
+ let total = 0
5671
+ for (const key of ['question', 'selected', 'selection', 'text', 'reasoning']) {
5672
+ total += String(entry?.[key] ?? '').length
5673
+ }
5674
+ for (const version of Array.isArray(entry?.versions) ? entry.versions : []) {
5675
+ total += String(version?.question ?? '').length
5676
+ + String(version?.text ?? '').length
5677
+ + String(version?.reasoning ?? '').length
5678
+ }
5679
+ return total
5680
+ }
5385
5681
 
5386
5682
  /** Configured durable cap, clamped to the Host's 0–50 range. */
5387
5683
  const historyCap = () => {
@@ -5425,12 +5721,36 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5425
5721
  }
5426
5722
 
5427
5723
  function writePersisted(cards) {
5428
- try {
5429
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards }))
5430
- } catch (error) {
5431
- // Quota or privacy mode — the cards stay on screen, just not durable.
5432
- console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
5724
+ let remaining = Array.isArray(cards) ? [...cards] : []
5725
+ // Budget the WHOLE payload first (see PERSIST_TOTAL_CHARS). Trimming
5726
+ // here keeps the common case on the first successful write instead of
5727
+ // walking into the quota branch at all.
5728
+ let total = remaining.reduce((sum, entry) => sum + entryChars(entry), 0)
5729
+ while (remaining.length > 0 && total > PERSIST_TOTAL_CHARS) {
5730
+ total -= entryChars(remaining[remaining.length - 1])
5731
+ remaining.pop()
5732
+ }
5733
+ // Newest first (cards are prepended); on quota, drop the OLDEST (tail)
5734
+ // and retry instead of abandoning every card. An EMPTY array must still
5735
+ // be written: deleting the last card clears the durable set on disk.
5736
+ // Returns what actually landed so the manager mirrors the durable set.
5737
+ let lastError = null
5738
+ for (;;) {
5739
+ try {
5740
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
5741
+ return remaining
5742
+ } catch (error) {
5743
+ lastError = error
5744
+ if (remaining.length === 0) break
5745
+ // Drop HALF, not one: every retry re-serialises the whole payload,
5746
+ // so the old one-at-a-time fallback was O(n²) stringify work on the
5747
+ // main thread — exactly while the UI is finishing a card.
5748
+ remaining = remaining.slice(0, Math.floor(remaining.length / 2))
5749
+ }
5433
5750
  }
5751
+ // Quota or privacy mode — the cards stay on screen, just not durable.
5752
+ if (lastError !== null) console.warn(`[${PLUGIN_ID}] card history not persisted:`, lastError)
5753
+ return []
5434
5754
  }
5435
5755
 
5436
5756
  /** Recompute the durable set from the live store and write it. */
@@ -5480,8 +5800,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5480
5800
  store.set({ history: [] })
5481
5801
  return
5482
5802
  }
5483
- writePersisted(finished)
5484
- store.set({ history: finished })
5803
+ const persisted = writePersisted(finished)
5804
+ store.set({ history: persisted })
5485
5805
  }
5486
5806
 
5487
5807
  /** Replay finished cards from a previous page load into the store. */
@@ -5501,37 +5821,47 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5501
5821
  if (cap > 0 && (currentRaw === null || currentRaw === undefined)) {
5502
5822
  writePersisted(saved)
5503
5823
  }
5504
- const replayed = saved.map(entry => ({
5505
- id: entry.id,
5506
- question: entry.question,
5507
- selected: String(entry.selected ?? ''),
5508
- selection: String(entry.selection ?? ''),
5509
- text: String(entry.text ?? ''),
5510
- reasoning: String(entry.reasoning ?? ''),
5511
- status: entry.status === 'stopped' ? 'stopped' : 'done',
5512
- carrier: 'side',
5513
- zone: entry.zone,
5514
- truncated: entry.truncated === true,
5515
- droppedChars: Number(entry.droppedChars ?? 0),
5516
- streaming: null,
5517
- error: null,
5518
- provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
5519
- elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
5520
- createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
5521
- // P2-5b: replayed versions stay switchable after a reload.
5522
- versions: replayVersions(entry.versions),
5523
- viewingVersion: null,
5524
- actionId: typeof entry.actionId === 'string' && entry.actionId !== '' ? entry.actionId : null,
5525
- // A native/better tab cannot survive a reload, so history always
5526
- // comes back on the flow stack where it is visible immediately.
5527
- surface: 'flow',
5528
- open: true,
5529
- history: Array.isArray(entry.history)
5530
- ? entry.history
5531
- .filter(turn => turn !== null && typeof turn === 'object')
5532
- .map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
5533
- : [],
5534
- }))
5824
+ // Replay budget: the newest card always comes back unfolded, and the
5825
+ // rest stay unfolded only while the running total of replayed
5826
+ // characters stays under the budget. A page load used to mount up to
5827
+ // 10 × 64 k characters of markdown before it was usable; past the
5828
+ // budget the older cards come back collapsed and are parsed when the
5829
+ // user opens them.
5830
+ let replayedChars = 0
5831
+ const replayed = saved.map((entry, index) => {
5832
+ replayedChars += String(entry.text ?? '').length + String(entry.reasoning ?? '').length
5833
+ return {
5834
+ id: entry.id,
5835
+ question: entry.question,
5836
+ selected: String(entry.selected ?? ''),
5837
+ selection: String(entry.selection ?? ''),
5838
+ text: String(entry.text ?? ''),
5839
+ reasoning: String(entry.reasoning ?? ''),
5840
+ status: entry.status === 'stopped' ? 'stopped' : 'done',
5841
+ carrier: 'side',
5842
+ zone: entry.zone,
5843
+ truncated: entry.truncated === true,
5844
+ droppedChars: Number(entry.droppedChars ?? 0),
5845
+ streaming: null,
5846
+ error: null,
5847
+ provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
5848
+ elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
5849
+ createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
5850
+ // P2-5b: replayed versions stay switchable after a reload.
5851
+ versions: replayVersions(entry.versions),
5852
+ viewingVersion: null,
5853
+ actionId: typeof entry.actionId === 'string' && entry.actionId !== '' ? entry.actionId : null,
5854
+ // A native/better tab cannot survive a reload, so history always
5855
+ // comes back on the flow stack where it is visible immediately.
5856
+ surface: 'flow',
5857
+ open: replayCardOpen(index, replayedChars),
5858
+ history: Array.isArray(entry.history)
5859
+ ? entry.history
5860
+ .filter(turn => turn !== null && typeof turn === 'object')
5861
+ .map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
5862
+ : [],
5863
+ }
5864
+ })
5535
5865
  store.set({ cards: [...replayed, ...store.state.cards] })
5536
5866
  }
5537
5867
 
@@ -5605,7 +5935,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5605
5935
  if (store.state.cards.some(card => card.id === id)) {
5606
5936
  patchCard(id, { surface: 'flow', open: true })
5607
5937
  } else {
5608
- store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
5938
+ pushCard(historyEntryToCard(entry))
5609
5939
  }
5610
5940
  // A collapsed (capsule) layer would swallow the restored card;
5611
5941
  // this is a session-only override, exactly like clicking expand.
@@ -5734,7 +6064,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5734
6064
  actionId: payload?.actionId ?? null,
5735
6065
  }
5736
6066
  if (payload.carrier === 'main') {
5737
- store.set({ cards: [card, ...store.state.cards] })
6067
+ pushCard(card)
5738
6068
  try {
5739
6069
  const result = await askInMainConversation({
5740
6070
  ctx,
@@ -5760,7 +6090,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5760
6090
  }
5761
6091
  const { adapter, fellBack } = pickSurface(config.sideSurface)
5762
6092
  card.surface = adapter.kind
5763
- store.set({ cards: [card, ...store.state.cards], surface: adapter.kind })
6093
+ pushCard(card, { surface: adapter.kind })
5764
6094
  if (fellBack) {
5765
6095
  store.set({ surfaceNote: t('surfaceFellBack', { name: adapter.label() }) })
5766
6096
  toast(t('surfaceUnavailable'))
@@ -5905,7 +6235,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5905
6235
  return result
5906
6236
  },
5907
6237
  followUp(card, text) {
5908
- const history = [...card.history, { question: card.question, answer: card.text }]
6238
+ const history = [...card.history, {
6239
+ question: String(card.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
6240
+ answer: String(card.text ?? '').slice(0, HISTORY_TURN_A_CHARS),
6241
+ }].slice(-HISTORY_TURNS)
5909
6242
  const next = patchCard(card.id, {
5910
6243
  question: text,
5911
6244
  history,
@@ -6093,31 +6426,65 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6093
6426
  const pendingReasonings = new Map()
6094
6427
  let deltaFlushTimer = null
6095
6428
 
6429
+ /**
6430
+ * Commit every buffered chunk for this tick as ONE store update.
6431
+ *
6432
+ * The previous shape published text and reasoning separately, so a
6433
+ * single 50 ms tick could bump the store twice per streaming card —
6434
+ * and every bump re-renders the whole overlay. With the concurrency
6435
+ * cap at 3 that is up to 120 tree renders per second for two string
6436
+ * appends. Cards that saw no chunk keep their identity, so a memoised
6437
+ * card is not touched at all.
6438
+ */
6096
6439
  const flushStreamBuffers = () => {
6097
6440
  if (deltaFlushTimer !== null) {
6098
6441
  clearTimeout(deltaFlushTimer)
6099
6442
  deltaFlushTimer = null
6100
6443
  }
6101
- if (pendingDeltas.size > 0) {
6102
- for (const [cardId, chunk] of [...pendingDeltas.entries()]) appendCardText(cardId, chunk)
6103
- pendingDeltas.clear()
6104
- }
6105
- if (pendingReasonings.size > 0) {
6106
- for (const [cardId, chunk] of [...pendingReasonings.entries()]) appendCardReasoning(cardId, chunk)
6107
- pendingReasonings.clear()
6444
+ if (pendingDeltas.size === 0 && pendingReasonings.size === 0) return
6445
+ const cards = store.state.cards.map((card) => {
6446
+ const text = pendingDeltas.get(card.id)
6447
+ const reasoning = pendingReasonings.get(card.id)
6448
+ if (text === undefined && reasoning === undefined) return card
6449
+ const next = { ...card }
6450
+ if (text !== undefined) next.text = `${card.text}${text}`
6451
+ if (reasoning !== undefined) next.reasoning = `${card.reasoning}${reasoning}`
6452
+ return next
6453
+ })
6454
+ pendingDeltas.clear()
6455
+ pendingReasonings.clear()
6456
+ store.set({ cards })
6457
+ }
6458
+
6459
+ /**
6460
+ * Milliseconds to wait before the next flush.
6461
+ *
6462
+ * 50 ms is the "feels live" cadence, but the cost of one frame grows
6463
+ * with the answer: `renderRichText` measured 0.2–0.3 ms at 8 k
6464
+ * characters and 1.2–2.0 ms at 64 k. Past 32 k a 100 ms cadence is
6465
+ * indistinguishable to the eye and halves the work.
6466
+ * @returns {number} the delay for the next flush tick.
6467
+ */
6468
+ const streamFlushDelay = () => {
6469
+ let longest = 0
6470
+ for (const card of store.state.cards) {
6471
+ if (card.status === 'streaming' && typeof card.text === 'string' && card.text.length > longest) {
6472
+ longest = card.text.length
6473
+ }
6108
6474
  }
6475
+ return longest > STREAM_SLOW_CHARS ? STREAM_FLUSH_SLOW_MS : STREAM_FLUSH_MS
6109
6476
  }
6110
6477
 
6111
6478
  const bufferStreamDelta = (cardId, chunk) => {
6112
6479
  if (chunk === '') return
6113
6480
  pendingDeltas.set(cardId, (pendingDeltas.get(cardId) ?? '') + chunk)
6114
- if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
6481
+ if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
6115
6482
  }
6116
6483
 
6117
6484
  const bufferStreamReasoning = (cardId, chunk) => {
6118
6485
  if (chunk === '') return
6119
6486
  pendingReasonings.set(cardId, (pendingReasonings.get(cardId) ?? '') + chunk)
6120
- if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
6487
+ if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
6121
6488
  }
6122
6489
 
6123
6490
  /** Stream one side answer into its card. */
@@ -6146,7 +6513,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6146
6513
  zone: card.zone,
6147
6514
  carrier: 'side',
6148
6515
  sessionId: currentSessionId(ctx),
6149
- history: card.history.slice(-HISTORY_TURNS),
6516
+ history: card.history.slice(-HISTORY_TURNS).map(turn => ({
6517
+ question: String(turn?.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
6518
+ answer: String(turn?.answer ?? '').slice(0, HISTORY_TURN_A_CHARS),
6519
+ })),
6150
6520
  }, {
6151
6521
  signal: controller.signal,
6152
6522
  onStart: (data) => {
@@ -6717,7 +7087,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6717
7087
  }
6718
7088
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '', style: { padding: '10px' } },
6719
7089
  h(Styles, null),
6720
- h(AnswerCard, {
7090
+ h(MemoAnswerCard, {
6721
7091
  card,
6722
7092
  surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
6723
7093
  onCopy: element => actions.copy(card, element),
@@ -6789,12 +7159,33 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6789
7159
  scheduleReport(0)
6790
7160
  }
6791
7161
  const legacyTimers = [300, 1200, 3000].map(delay => setTimeout(detectLegacy, delay))
7162
+ // Coalesce the observer: `detectLegacy` runs a whole-document
7163
+ // `querySelectorAll`, and a streaming answer mutates the DOM dozens of
7164
+ // times per second — unthrottled, every one of those mutations became a
7165
+ // full-tree scan for the first six seconds of the page's life. One
7166
+ // trailing scan per 250 ms is plenty for a layer that only has to be
7167
+ // noticed, never measured.
7168
+ let legacyScanTimer = null
7169
+ const queueLegacyScan = () => {
7170
+ if (legacyScanTimer !== null) return
7171
+ legacyScanTimer = setTimeout(() => {
7172
+ legacyScanTimer = null
7173
+ detectLegacy()
7174
+ }, 250)
7175
+ }
6792
7176
  if (typeof document !== 'undefined' && typeof MutationObserver === 'function') {
6793
- legacyObserver = new MutationObserver(() => detectLegacy())
7177
+ legacyObserver = new MutationObserver(queueLegacyScan)
6794
7178
  try {
6795
7179
  legacyObserver.observe(document.documentElement ?? document, { childList: true, subtree: true })
6796
7180
  } catch { /* document without a root yet — timers still cover it */ }
6797
7181
  }
7182
+ // After the boot probe window closes with no legacy, stop the
7183
+ // document-wide observer: left attached it would re-scan the whole tree
7184
+ // on every DOM mutation for the page's lifetime.
7185
+ const legacyStopTimer = setTimeout(() => {
7186
+ try { legacyObserver?.disconnect() } catch { /* already gone */ }
7187
+ legacyObserver = null
7188
+ }, 6000)
6798
7189
 
6799
7190
  reelect()
6800
7191
  void refreshState().catch(() => { /* the settings page shows the failure */ })
@@ -6815,6 +7206,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6815
7206
  if (toastTimer !== null) clearTimeout(toastTimer)
6816
7207
  if (reportTimer !== null) clearTimeout(reportTimer)
6817
7208
  for (const timer of legacyTimers) clearTimeout(timer)
7209
+ clearTimeout(legacyStopTimer)
7210
+ if (legacyScanTimer !== null) clearTimeout(legacyScanTimer)
6818
7211
  try {
6819
7212
  if (typeof document !== 'undefined' && document.getElementById) {
6820
7213
  document.getElementById('dsa-family-legacy-style')?.remove()
@@ -6869,6 +7262,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6869
7262
  CARD_KIND,
6870
7263
  CARD_KIND_BETTER,
6871
7264
  HISTORY_TURNS,
7265
+ API_ROOT,
7266
+ apiBaseAfter,
7267
+ MAX_LIVE_CARDS,
7268
+ REPLAY_OPEN_BUDGET_CHARS,
7269
+ STREAM_FLUSH_MS,
7270
+ STREAM_FLUSH_SLOW_MS,
7271
+ STREAM_SLOW_CHARS,
7272
+ capLiveCards,
7273
+ replayCardOpen,
6872
7274
  estimateLabelWidth,
6873
7275
  parseSseBlock,
6874
7276
  truncateSelection,