dsh-sidecard-ask 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  * the Host and the module's `api.version` both read it, so a report can
48
48
  * never claim a generation the browser is not actually running.
49
49
  */
50
- const CLIENT_VERSION = '1.3.2'
50
+ const CLIENT_VERSION = '1.5.0'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -70,6 +70,13 @@ window.__ModuleLoader__.load({
70
70
  */
71
71
  const CARD_KIND_BETTER = 'sidecard-ask:card:workbench'
72
72
 
73
+ /**
74
+ * How many earlier turns of a card ride the next ask payload. Mirrors the
75
+ * Host's own cap (contract-asserted): the Host trims to the same number,
76
+ * so sending more would only be dead wire weight.
77
+ */
78
+ const HISTORY_TURNS = 6
79
+
73
80
  /**
74
81
  * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
75
82
  * before `/state` answers (first paint) and when the Host is unreachable;
@@ -89,6 +96,8 @@ window.__ModuleLoader__.load({
89
96
  sideTools: 'readonly',
90
97
  sideTimeoutMs: 180000,
91
98
  sideProvider: 'auto',
99
+ floatMode: 'capsule',
100
+ floatMaxWidth: 340,
92
101
  }
93
102
 
94
103
  /** Zone classification: slot-key patterns the harness itself renders. */
@@ -97,6 +106,54 @@ window.__ModuleLoader__.load({
97
106
  task: [/\btask/i, /\btodo/i, /\bschedule/i, /\bteam/i, /\bjob/i, /\bplan/i, /^rightbar\b/, /^sidebar\.right\b/],
98
107
  }
99
108
 
109
+ // ─────────────────────────────────────────────────────────────────────
110
+ // §1b floating-layer (A) geometry
111
+ // ─────────────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Placement rules for the floating layer, mirrored by
115
+ * `test/float-placement.mjs`.
116
+ *
117
+ * The layer is anchored to the FRAME's right edge, so it can only ever
118
+ * grow LEFT into the conversation column. Everything here exists to stop
119
+ * that: the layer may use the strip to the right of the conversation
120
+ * column's right boundary (where the right rail lives), but it never
121
+ * crosses that boundary while there is at least `minWidth` of room.
122
+ */
123
+ const FLOAT_GAP_RIGHT = 8
124
+ const FLOAT_GAP_SIDEBAR = 10
125
+ const FLOAT_MIN_WIDTH = 240
126
+ const FLOAT_MAX_WIDTH_LIMIT = 560
127
+
128
+ /**
129
+ * The floating layer's width for one frame.
130
+ *
131
+ * Pure (no DOM) so both the runtime and the tests can call it:
132
+ * `avail` is the distance from the conversation column's right boundary to
133
+ * the frame's right edge, minus the right rail when one is present. The
134
+ * boundary is treated as un-crossable while `avail >= minWidth`; below that
135
+ * the layer gives up and clamps to `minWidth` (a pill narrower than that is
136
+ * unusable), which is the only case where it may overlap the column.
137
+ *
138
+ * @param {object} input - `frameWidth`, `laneRight`, `railLeft`, `maxWidth`.
139
+ * @returns {{width: number, avail: number, capped: boolean, bleeds: boolean}}
140
+ */
141
+ function floatPlacement({ frameWidth, laneRight, railLeft = null, maxWidth = CLIENT_DEFAULTS.floatMaxWidth }) {
142
+ const frame = Number.isFinite(frameWidth) ? frameWidth : 0
143
+ const lane = Number.isFinite(laneRight) ? laneRight : 0
144
+ const cap = Math.min(
145
+ FLOAT_MAX_WIDTH_LIMIT,
146
+ Math.max(FLOAT_MIN_WIDTH, Number.isFinite(maxWidth) ? maxWidth : CLIENT_DEFAULTS.floatMaxWidth),
147
+ )
148
+ let avail = frame - FLOAT_GAP_RIGHT - lane
149
+ if (Number.isFinite(railLeft)) avail = Math.min(avail, frame - railLeft - FLOAT_GAP_SIDEBAR)
150
+ avail = Math.max(FLOAT_MIN_WIDTH, Math.floor(avail))
151
+ const width = Math.min(cap, avail)
152
+ // Left edge of a right-anchored layer of this width.
153
+ const left = frame - FLOAT_GAP_RIGHT - width
154
+ return { width, avail, capped: width < cap, bleeds: lane - left > 1 }
155
+ }
156
+
100
157
  // ─────────────────────────────────────────────────────────────────────
101
158
  // §2 i18n
102
159
  // ─────────────────────────────────────────────────────────────────────
@@ -129,6 +186,9 @@ window.__ModuleLoader__.load({
129
186
  answerStopped: '已停止',
130
187
  answerNonStreaming: '当前 DSH 版本未提供流式帧,答案一次性返回',
131
188
  answerEmpty: '(没有内容)',
189
+ cardClosed: '该卡片已关闭',
190
+ cardMovedToFlow: '该卡片已移至浮层显示',
191
+ reasoningTitle: '思考过程',
132
192
  copy: '复制',
133
193
  copied: '已复制',
134
194
  copySelectedHint: '已选中答案文本,请按 Ctrl+C 复制',
@@ -146,6 +206,18 @@ window.__ModuleLoader__.load({
146
206
  noMainCarrier: '主对话发送接口不可用:既不能提交消息,也不能写入输入框',
147
207
  errorTitle: '作答失败',
148
208
  errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
209
+ errorTooLarge: '请求体过大',
210
+ errorBadRequest: '请求无效(问题或选中文本为空)',
211
+ errorUnloaded: '插件正在卸载,无法受理新的追问',
212
+ errorDuplicate: '该追问已在处理中,请等它完成',
213
+ errorBusy: '并发追问已达上限({n}),请稍后再试',
214
+ errorNoSideEngineBody: '这台 DSH 组合没有可用的子代理 provider',
215
+ errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
216
+ errorAborted: '作答被取消或超时',
217
+ errorUnreachableBody: '宿主接口不可达',
218
+ errorBadResponseBody: '宿主返回了无法解析的响应',
219
+ errorStreamBrokenBody: '流式连接中断',
220
+ errorStreamEndedBody: '流式连接意外结束',
149
221
  surfaceFlow: '浮层卡片',
150
222
  surfaceNative: '右侧栏卡片',
151
223
  surfaceBetter: '侧边卡片插件',
@@ -163,6 +235,20 @@ window.__ModuleLoader__.load({
163
235
  setSurfaceFlow: '内置浮层卡片',
164
236
  setMaxChars: '最大字符数',
165
237
  setMaxCharsHint: '超过部分截断并在提问中标注',
238
+ setFloatMode: '浮层默认形态',
239
+ setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
240
+ setFloatModeFull: '直接展开成卡片',
241
+ setFloatModeOff: '关闭浮层(只留小圆点)',
242
+ setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
243
+ setFloatMaxWidth: '浮层宽度上限(px)',
244
+ setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
245
+ floatExpand: '展开侧边卡片',
246
+ floatCollapse: '收起为胶囊(不遮挡对话)',
247
+ floatHide: '隐藏浮层(只留小圆点)',
248
+ floatRestore: '恢复浮层',
249
+ floatCount: '{n} 条追问回答',
250
+ floatStreaming: '正在作答…',
251
+ floatDone: '已完成 {n} 条',
166
252
  setShortcut: '快捷键',
167
253
  setZones: '捕获区域',
168
254
  setZonesAuto: '全部区域',
@@ -175,6 +261,10 @@ window.__ModuleLoader__.load({
175
261
  setSideTools: '侧边作答工具权限',
176
262
  setSideToolsReadonly: '只读(推荐)',
177
263
  setSideToolsInherit: '继承当前会话',
264
+ setSideTimeout: '侧边作答超时(毫秒)',
265
+ setSideTimeoutHint: '超过该时间未完成即停止作答',
266
+ setSideProvider: '子代理 provider',
267
+ setSideProviderHint: 'auto = 自动挑选已注册的 provider',
178
268
  save: '保存',
179
269
  saving: '保存中…',
180
270
  saved: '已保存',
@@ -232,6 +322,9 @@ window.__ModuleLoader__.load({
232
322
  answerStopped: 'Stopped',
233
323
  answerNonStreaming: 'This DSH build publishes no stream frames; the answer arrived at once',
234
324
  answerEmpty: '(empty)',
325
+ cardClosed: 'This card was closed',
326
+ cardMovedToFlow: 'This card moved to the floating stack',
327
+ reasoningTitle: 'Reasoning',
235
328
  copy: 'Copy',
236
329
  copied: 'Copied',
237
330
  copySelectedHint: 'Answer selected — press Ctrl+C to copy',
@@ -249,6 +342,18 @@ window.__ModuleLoader__.load({
249
342
  noMainCarrier: 'The main-chat send API is unavailable: neither submitting a message nor writing the composer draft worked',
250
343
  errorTitle: 'Answer failed',
251
344
  errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
345
+ errorTooLarge: 'The request body is too large',
346
+ errorBadRequest: 'Invalid request (empty question or empty selection)',
347
+ errorUnloaded: 'The plugin is unloading and cannot take new follow-ups',
348
+ errorDuplicate: 'This follow-up is already being answered; wait for it to finish',
349
+ errorBusy: 'Too many concurrent answers (limit {n}); try again shortly',
350
+ errorNoSideEngineBody: 'This DSH composition has no usable subagent provider',
351
+ errorNoParentBody: 'No active session agent to answer independently',
352
+ errorAborted: 'The answer was cancelled or timed out',
353
+ errorUnreachableBody: 'The host API is unreachable',
354
+ errorBadResponseBody: 'The host returned an unreadable response',
355
+ errorStreamBrokenBody: 'The stream connection broke',
356
+ errorStreamEndedBody: 'The stream ended without a terminal event',
252
357
  surfaceFlow: 'Floating card',
253
358
  surfaceNative: 'Right rail',
254
359
  surfaceBetter: 'Sidebar plugin',
@@ -266,6 +371,20 @@ window.__ModuleLoader__.load({
266
371
  setSurfaceFlow: 'Built-in floating card',
267
372
  setMaxChars: 'Max characters',
268
373
  setMaxCharsHint: 'Longer selections are truncated and marked in the question',
374
+ setFloatMode: 'Floating layer default',
375
+ setFloatModeCapsule: 'Capsule (never covers the conversation)',
376
+ setFloatModeFull: 'Expanded card stack',
377
+ setFloatModeOff: 'Hidden (small launcher only)',
378
+ setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
379
+ setFloatMaxWidth: 'Floating layer max width (px)',
380
+ setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
381
+ floatExpand: 'Expand the side cards',
382
+ floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
383
+ floatHide: 'Hide the layer (leaves a small launcher)',
384
+ floatRestore: 'Restore the floating layer',
385
+ floatCount: '{n} follow-up answers',
386
+ floatStreaming: 'Answering…',
387
+ floatDone: '{n} done',
269
388
  setShortcut: 'Shortcut',
270
389
  setZones: 'Capture regions',
271
390
  setZonesAuto: 'Every region',
@@ -278,6 +397,10 @@ window.__ModuleLoader__.load({
278
397
  setSideTools: 'Side answerer tool access',
279
398
  setSideToolsReadonly: 'Read-only (recommended)',
280
399
  setSideToolsInherit: 'Inherit from the session',
400
+ setSideTimeout: 'Side answer timeout (ms)',
401
+ setSideTimeoutHint: 'The answer stops once this time is exceeded',
402
+ setSideProvider: 'Subagent provider',
403
+ setSideProviderHint: 'auto picks a registered provider automatically',
281
404
  save: 'Save',
282
405
  saving: 'Saving…',
283
406
  saved: 'Saved',
@@ -310,13 +433,37 @@ window.__ModuleLoader__.load({
310
433
  },
311
434
  }
312
435
 
313
- /** The active language id, kept in step with the harness locale service. */
314
- let lang = (() => {
315
- const raw = typeof navigator !== 'undefined' && typeof navigator.language === 'string'
316
- ? navigator.language.toLowerCase()
317
- : 'zh'
318
- return raw.startsWith('zh') ? 'zh' : 'en'
319
- })()
436
+ /**
437
+ * Pick a dictionary id from locale candidates, best first. A candidate
438
+ * naming neither zh nor en is skipped, so a lesser preference can still
439
+ * win — `navigator.languages` is an ordered preference list, and a user
440
+ * whose first choice the UI cannot serve should get their second, not a
441
+ * hard fallback. This is the ONE place locale strings become a dictionary
442
+ * id; the harness locale subscription below feeds the same helper.
443
+ * @param {unknown[]} candidates locale strings, best first.
444
+ * @param {'zh'|'en'} fallback applied only when no candidate matches.
445
+ * @returns {'zh'|'en'} the dictionary id.
446
+ */
447
+ function resolveLanguage(candidates, fallback) {
448
+ for (const candidate of candidates) {
449
+ if (typeof candidate !== 'string' || candidate === '') continue
450
+ const lower = candidate.toLowerCase()
451
+ if (lower.startsWith('zh')) return 'zh'
452
+ if (lower.startsWith('en')) return 'en'
453
+ }
454
+ return fallback
455
+ }
456
+
457
+ /**
458
+ * The active language id, kept in step with the harness locale service.
459
+ * When the browser lists a language the UI does not carry, the next
460
+ * preference decides; only a list with no zh/en entry at all falls back.
461
+ */
462
+ const initialLocales = [
463
+ ...(Array.isArray(navigator?.languages) ? [...navigator.languages] : []),
464
+ ...(typeof navigator?.language === 'string' ? [navigator.language] : []),
465
+ ]
466
+ let lang = resolveLanguage(initialLocales, initialLocales.length > 0 ? 'en' : 'zh')
320
467
 
321
468
  /**
322
469
  * Translate one key with `{name}` interpolation.
@@ -332,6 +479,37 @@ window.__ModuleLoader__.load({
332
479
  return text
333
480
  }
334
481
 
482
+ /**
483
+ * Wire-error codes whose meaning is fully known Client-side, so an English
484
+ * interface never has to show the Host's Chinese message line. Codes that
485
+ * carry a live diagnostic in their message (engine-error, blocked-step,
486
+ * http-*, internal, main-failed) are deliberately absent: the Host message
487
+ * is the only place that reason exists, and replacing it would lose it.
488
+ * `vars` receives the whole error when the copy needs a live value.
489
+ */
490
+ const ERROR_CODES = {
491
+ 'too-large': { key: 'errorTooLarge' },
492
+ 'bad-request': { key: 'errorBadRequest' },
493
+ unloaded: { key: 'errorUnloaded' },
494
+ duplicate: { key: 'errorDuplicate' },
495
+ busy: { key: 'errorBusy', vars: () => ({ n: store?.state?.config?.maxConcurrentAsks ?? 0 }) },
496
+ 'no-side-engine': { key: 'errorNoSideEngineBody' },
497
+ 'no-parent': { key: 'errorNoParentBody' },
498
+ aborted: { key: 'errorAborted' },
499
+ unreachable: { key: 'errorUnreachableBody' },
500
+ 'bad-response': { key: 'errorBadResponseBody' },
501
+ 'stream-broken': { key: 'errorStreamBrokenBody' },
502
+ 'stream-ended': { key: 'errorStreamEndedBody' },
503
+ }
504
+
505
+ /** One localized line for a wire error; unknown codes keep the Host text. */
506
+ function localizeError(error) {
507
+ if (error === null || error === undefined) return ''
508
+ const entry = ERROR_CODES[error?.code]
509
+ if (entry === undefined) return error.message ?? String(error?.code ?? error)
510
+ return t(entry.key, entry.vars?.(error))
511
+ }
512
+
335
513
  // ─────────────────────────────────────────────────────────────────────
336
514
  // §3 wire client
337
515
  // ─────────────────────────────────────────────────────────────────────
@@ -467,6 +645,11 @@ window.__ModuleLoader__.load({
467
645
  hostReachable: null,
468
646
  surface: 'flow',
469
647
  surfaceNote: null,
648
+ /**
649
+ * In-session override of `config.floatMode`, set by the layer's own
650
+ * collapse/hide buttons. `null` = follow the configured default.
651
+ */
652
+ floatMode: null,
470
653
  sessionId: null,
471
654
  trigger: null,
472
655
  popover: null,
@@ -503,6 +686,18 @@ window.__ModuleLoader__.load({
503
686
  return store.state
504
687
  }
505
688
 
689
+ /**
690
+ * In-session override of the configured floating-layer shape. Deliberately
691
+ * NOT persisted: the settings page owns the durable default, and a reload
692
+ * should return to it (a pill hidden by accident must not stay hidden
693
+ * across sessions).
694
+ * @param {string} mode - `capsule` | `full` | `off`.
695
+ */
696
+ function setFloatMode(mode) {
697
+ if (mode !== 'capsule' && mode !== 'full' && mode !== 'off') return
698
+ store.set({ floatMode: mode })
699
+ }
700
+
506
701
  /** Replace one card by id (immutably; a no-op when it is gone). */
507
702
  function patchCard(id, patch) {
508
703
  const cards = store.state.cards.map(card => (card.id === id ? { ...card, ...patch } : card))
@@ -652,7 +847,7 @@ window.__ModuleLoader__.load({
652
847
  function shouldOffer(candidate, config, options) {
653
848
  if (candidate === null) return { ok: false, reason: 'empty' }
654
849
  if (config.trigger === 'shortcut') return { ok: false, reason: 'shortcut-only' }
655
- if (candidate.text.trim().length < config.minChars) return { ok: false, reason: 'too-short' }
850
+ if (codePointLength(candidate.text.trim()) < config.minChars) return { ok: false, reason: 'too-short' }
656
851
  if (options?.anchors === false) return { ok: true, zoneFiltering: 'unavailable' }
657
852
  const zones = config.captureZones
658
853
  const zone = candidate.zone
@@ -675,22 +870,29 @@ window.__ModuleLoader__.load({
675
870
 
676
871
  /**
677
872
  * Truncate an over-long selection for an outgoing prompt, keeping head and
678
- * tail. Mirrors the Host's `truncateSelection` (asserted by contract test).
873
+ * tail. Mirrors the Host's `truncateSelection` (asserted by contract test):
874
+ * code points, not UTF-16 units, so a surrogate pair is never split.
679
875
  * @returns {{text: string, truncated: boolean, droppedChars: number}}
680
876
  */
681
877
  function truncateSelection(text, maxChars) {
682
878
  const source = typeof text === 'string' ? text : ''
683
- if (source.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
879
+ const points = Array.from(source)
880
+ if (points.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
684
881
  const head = Math.max(1, Math.ceil(maxChars * 0.7))
685
882
  const tail = Math.max(0, maxChars - head)
686
- const dropped = source.length - head - tail
883
+ const dropped = points.length - head - tail
687
884
  return {
688
- text: `${source.slice(0, head)}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? source.slice(source.length - tail) : ''}`,
885
+ text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
689
886
  truncated: true,
690
887
  droppedChars: dropped,
691
888
  }
692
889
  }
693
890
 
891
+ /** Character count a Chinese user would expect: one glyph = one char. */
892
+ function codePointLength(text) {
893
+ return Array.from(String(text ?? '')).length
894
+ }
895
+
694
896
  /** Parse a `Ctrl+Shift+K`-style accelerator into matcher facts. */
695
897
  function parseShortcut(accelerator) {
696
898
  if (typeof accelerator !== 'string') return null
@@ -880,9 +1082,20 @@ window.__ModuleLoader__.load({
880
1082
  this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
881
1083
  return true
882
1084
  },
883
- close() {
884
- // The right rail owns its tab lifecycle; the store entry is enough.
885
- return true
1085
+ close(card) {
1086
+ // The right rail owns its tab lifecycle, so closing never blocks on
1087
+ // it — but since 0.2.0 the rail controller exposes `closeTab` with
1088
+ // the same shape `openTab` has (verified only for `openTab` on this
1089
+ // machine; see README §6). Probe defensively: a mismatching rail
1090
+ // simply keeps the tab, and CardHost then shows the closed-card note.
1091
+ if (typeof this.controller?.closeTab !== 'function') return false
1092
+ try {
1093
+ this.controller.closeTab(CARD_KIND, { params: { cardId: card.id } })
1094
+ return true
1095
+ } catch (error) {
1096
+ console.warn(`[${PLUGIN_ID}] native closeTab rejected the close:`, error)
1097
+ return false
1098
+ }
886
1099
  },
887
1100
  },
888
1101
  better: {
@@ -1042,12 +1255,13 @@ window.__ModuleLoader__.load({
1042
1255
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
1043
1256
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
1044
1257
  Every surface we draw declares it. The property is inert in a browser. */
1045
- .dsa-layer,.dsa-trigger,.dsa-pop,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1258
+ .dsa-layer,.dsa-trigger,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1046
1259
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
1047
1260
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
1048
1261
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1049
1262
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1050
- font-size:12px;line-height:16px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18);font-family:inherit}
1263
+ font-size:12px;line-height:16px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18);
1264
+ font-family:inherit;white-space:nowrap}
1051
1265
  .dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1052
1266
  .dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1053
1267
  .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
@@ -1074,6 +1288,9 @@ window.__ModuleLoader__.load({
1074
1288
  .dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
1075
1289
  color:#fff;font-weight:500}
1076
1290
  .dsa-btn:disabled{opacity:.55;cursor:default}
1291
+ /* Icon-only variant (the floating layer's collapse/hide controls): square and
1292
+ quiet so the card title bar stays one line and the title keeps the weight. */
1293
+ .dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
1077
1294
  .dsa-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;overflow:hidden}
1078
1295
  .dsa-seg button{padding:3px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary, #555);
1079
1296
  font:inherit;font-size:12px;cursor:pointer}
@@ -1082,14 +1299,55 @@ window.__ModuleLoader__.load({
1082
1299
  .dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
1083
1300
  border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1084
1301
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1085
- box-shadow:0 10px 30px rgba(0,0,0,.22);font-size:13px;line-height:19px}
1086
- .dsa-stack{pointer-events:none;position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;
1087
- width:min(400px, 92vw);max-height:70vh;overflow:auto}
1302
+ box-shadow:0 4px 16px rgba(0,0,0,.10);font-size:13px;line-height:19px}
1303
+ /* The floating layer (A). Anchored to the frame's RIGHT edge and sized by
1304
+ \`floatPlacement\` through --dsa-float-w, so it grows left only as far as the
1305
+ conversation column allows. pointer-events stays off on the container so the
1306
+ empty area around the pill never eats a click meant for the conversation. */
1307
+ .dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
1308
+ display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
1309
+ .dsa-float > *{pointer-events:auto}
1310
+ .dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
1311
+ .dsa-capsule{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;text-align:left;
1312
+ padding:8px 12px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
1313
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1314
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1315
+ box-shadow:0 2px 10px rgba(0,0,0,.14)}
1316
+ .dsa-capsule:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1317
+ .dsa-capsule:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1318
+ .dsa-capsule-dot{flex:none;width:8px;height:8px;border-radius:50%;
1319
+ background:var(--dsw-alias-state-idle-primary, #8a8f98)}
1320
+ .dsa-capsule-dot[data-status="streaming"]{background:var(--dsw-alias-brand-primary, #4c8dff)}
1321
+ .dsa-capsule-dot[data-status="done"]{background:var(--dsw-alias-state-success-primary, #2e9e5b)}
1322
+ .dsa-capsule-dot[data-status="error"]{background:var(--dsw-alias-state-error-primary, #c0392b)}
1323
+ .dsa-capsule-dot[data-status="stopped"]{background:var(--dsw-alias-state-warn-primary, #b8860b)}
1324
+ .dsa-capsule-label{font-weight:600;white-space:nowrap}
1325
+ .dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
1326
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1327
+ .dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
1328
+ .dsa-launcher{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;padding:6px 11px;
1329
+ border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
1330
+ border:1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.45));
1331
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-secondary, #666)}
1332
+ .dsa-launcher:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1088
1333
  .dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
1089
1334
  .dsa-answer .dsa-p{margin:0 0 6px}
1090
1335
  .dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
1091
1336
  .dsa-answer .dsa-h{font-weight:600;margin:0 0 6px}
1092
1337
  .dsa-answer .dsa-gap{height:4px}
1338
+ .dsa-details summary{cursor:pointer;user-select:none;font-size:12px;line-height:18px;
1339
+ color:var(--dsw-alias-label-secondary, #666)}
1340
+ .dsa-reasoning{max-height:24vh;overflow:auto;margin:4px 0 8px;padding:6px 8px;word-break:break-word;
1341
+ border-left:3px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));border-radius:4px;
1342
+ background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.08));font-size:12px;line-height:17px}
1343
+ .dsa-a{color:var(--dsw-alias-brand-primary, #0066cc);text-decoration:underline;word-break:break-all}
1344
+ .dsa-quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1345
+ border-radius:4px;background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.08));
1346
+ font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary, #555)}
1347
+ .dsa-table{border-collapse:collapse;margin:0 0 6px;font-size:12px;max-width:100%}
1348
+ .dsa-table th,.dsa-table td{border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1349
+ padding:4px 8px;text-align:left;word-break:break-word;vertical-align:top}
1350
+ .dsa-table th{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-weight:600}
1093
1351
  .dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
1094
1352
  background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
1095
1353
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -1117,10 +1375,10 @@ window.__ModuleLoader__.load({
1117
1375
  return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
1118
1376
  }
1119
1377
 
1120
- /** Inline markdown-lite: `code` and **bold** only — never innerHTML. */
1378
+ /** Inline markdown-lite: `code`, **bold**, *italic*, [label](http url) — never innerHTML. */
1121
1379
  function inlineNodes(text, key) {
1122
1380
  const out = []
1123
- const pattern = /(`[^`]+`|\*\*[^*]+\*\*)/g
1381
+ const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
1124
1382
  let last = 0
1125
1383
  let match
1126
1384
  let index = 0
@@ -1129,8 +1387,23 @@ window.__ModuleLoader__.load({
1129
1387
  const token = match[0]
1130
1388
  if (token.startsWith('`')) {
1131
1389
  out.push(h('code', { key: `c${key}-${index++}`, className: 'dsa-code' }, token.slice(1, -1)))
1132
- } else {
1390
+ } else if (token.startsWith('**')) {
1133
1391
  out.push(h('strong', { key: `b${key}-${index++}` }, token.slice(2, -2)))
1392
+ } else if (token.startsWith('*')) {
1393
+ out.push(h('em', { key: `i${key}-${index++}` }, token.slice(1, -1)))
1394
+ } else {
1395
+ // [label](url). The protocol allow-list (http/https only) is the
1396
+ // whole security story: a model answer must not be able to make a
1397
+ // `javascript:` or `data:` URL clickable. The label stays plain
1398
+ // text — no nested syntax inside a link label.
1399
+ const split = token.indexOf('](')
1400
+ out.push(h('a', {
1401
+ key: `a${key}-${index++}`,
1402
+ className: 'dsa-a',
1403
+ href: token.slice(split + 2, -1),
1404
+ target: '_blank',
1405
+ rel: 'noopener noreferrer',
1406
+ }, token.slice(1, split)))
1134
1407
  }
1135
1408
  last = match.index + token.length
1136
1409
  }
@@ -1140,8 +1413,8 @@ window.__ModuleLoader__.load({
1140
1413
 
1141
1414
  /**
1142
1415
  * Render answer text as React nodes: fenced code, lists, headings,
1143
- * paragraphs. No HTML injection anywhere, so model output cannot become
1144
- * markup.
1416
+ * blockquotes, tables, paragraphs. No HTML injection anywhere, so model
1417
+ * output cannot become markup.
1145
1418
  */
1146
1419
  function renderRichText(text) {
1147
1420
  const lines = String(text ?? '').split('\n')
@@ -1150,6 +1423,15 @@ window.__ModuleLoader__.load({
1150
1423
  let key = 0
1151
1424
  const isBullet = line => /^\s*([-*+]|\d+\.)\s+/.test(line)
1152
1425
  const isHeading = line => /^#{1,6}\s+/.test(line)
1426
+ const isQuote = line => /^>\s?/.test(line)
1427
+ // A GFM table separator: `|---|---|`, `---|:---:`, whole row nothing
1428
+ // but pipes, dashes, colons and spaces.
1429
+ const isTableSplit = line => /^\s*\|?[\s:|-]*\|[\s:|-]*$/.test(line)
1430
+ const tableCells = rowText => rowText
1431
+ .replace(/^\s*\|/, '')
1432
+ .replace(/\|\s*$/, '')
1433
+ .split('|')
1434
+ .map(cell => cell.trim())
1153
1435
  while (i < lines.length) {
1154
1436
  const line = lines[i]
1155
1437
  if (/^```/.test(line)) {
@@ -1163,6 +1445,22 @@ window.__ModuleLoader__.load({
1163
1445
  nodes.push(h('pre', { key: `f${key++}`, className: 'dsa-pre' }, h('code', null, body.join('\n'))))
1164
1446
  continue
1165
1447
  }
1448
+ if (line.includes('|') && i + 1 < lines.length && isTableSplit(lines[i + 1])) {
1449
+ const header = tableCells(line)
1450
+ i += 2
1451
+ const rows = []
1452
+ while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) {
1453
+ rows.push(tableCells(lines[i]))
1454
+ i += 1
1455
+ }
1456
+ nodes.push(h('table', { key: `t${key}`, className: 'dsa-table' },
1457
+ h('thead', null, h('tr', null,
1458
+ header.map((cell, index) => h('th', { key: `th${key}-${index}` }, inlineNodes(cell, `${key}h-${index}`))))),
1459
+ h('tbody', null, rows.map((row, rowIndex) => h('tr', { key: `tr${key}-${rowIndex}` },
1460
+ row.map((cell, index) => h('td', { key: `td${key}-${rowIndex}-${index}` }, inlineNodes(cell, `${key}-${rowIndex}-${index}`))))))))
1461
+ key += 1
1462
+ continue
1463
+ }
1166
1464
  if (isBullet(line)) {
1167
1465
  const items = []
1168
1466
  while (i < lines.length && isBullet(lines[i])) {
@@ -1179,6 +1477,17 @@ window.__ModuleLoader__.load({
1179
1477
  i += 1
1180
1478
  continue
1181
1479
  }
1480
+ if (isQuote(line)) {
1481
+ const quoted = []
1482
+ while (i < lines.length && isQuote(lines[i])) {
1483
+ quoted.push(lines[i].replace(/^>\s?/, ''))
1484
+ i += 1
1485
+ }
1486
+ nodes.push(h('blockquote', { key: `q${key}`, className: 'dsa-quote' },
1487
+ quoted.map((row, index) => h('div', { key: `ql${key}-${index}` }, inlineNodes(row, `${key}-${index}`)))))
1488
+ key += 1
1489
+ continue
1490
+ }
1182
1491
  if (line.trim() === '') {
1183
1492
  nodes.push(h('div', { key: `g${key++}`, className: 'dsa-gap' }))
1184
1493
  i += 1
@@ -1191,6 +1500,7 @@ window.__ModuleLoader__.load({
1191
1500
  && !/^```/.test(lines[i])
1192
1501
  && !isBullet(lines[i])
1193
1502
  && !isHeading(lines[i])
1503
+ && !isQuote(lines[i])
1194
1504
  ) {
1195
1505
  paragraph.push(lines[i])
1196
1506
  i += 1
@@ -1201,6 +1511,26 @@ window.__ModuleLoader__.load({
1201
1511
  return nodes
1202
1512
  }
1203
1513
 
1514
+ /**
1515
+ * Rough advance width of a 12px label, in CSS pixels: full-width for CJK
1516
+ * ideographs and fullwidth forms, ~0.55em for everything else. The zh
1517
+ * trigger label ("追问选中内容") estimates ~72px of text while the en one
1518
+ * ("Ask about selection") estimates ~125px — exactly the difference a
1519
+ * hard-coded clamp width tuned for zh would miss.
1520
+ */
1521
+ function estimateLabelWidth(text, fontSize = 12) {
1522
+ let units = 0
1523
+ for (const char of typeof text === 'string' ? text : '') {
1524
+ const code = char.codePointAt(0)
1525
+ const fullWidth = (code >= 0x2e80 && code <= 0x9fff)
1526
+ || (code >= 0xf900 && code <= 0xfaff)
1527
+ || (code >= 0xff00 && code <= 0xffef)
1528
+ || (code >= 0x30000 && code <= 0x3134f)
1529
+ units += fullWidth ? 1 : 0.55
1530
+ }
1531
+ return units * fontSize
1532
+ }
1533
+
1204
1534
  /** Clamp a fixed-position box into the viewport. */
1205
1535
  function clampBox(rect, width, height, prefer = 'below') {
1206
1536
  const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
@@ -1260,7 +1590,10 @@ window.__ModuleLoader__.load({
1260
1590
 
1261
1591
  /** The in-place trigger button floating at the selection. */
1262
1592
  function TriggerButton({ candidate, onOpen }) {
1263
- const box = clampBox(candidate.rect, 132, 30, 'below')
1593
+ // The clamp width follows the live label: icon + gap + padding + border
1594
+ // (~38px of chrome) plus a safety margin, over the estimated label width.
1595
+ const width = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
1596
+ const box = clampBox(candidate.rect, width, 30, 'below')
1264
1597
  return h('button', {
1265
1598
  type: 'button',
1266
1599
  className: 'dsa-trigger',
@@ -1322,11 +1655,14 @@ window.__ModuleLoader__.load({
1322
1655
  h('div', { className: 'dsa-row' },
1323
1656
  h('span', { className: 'dsa-title' }, t('askTitle')),
1324
1657
  h('span', { className: 'dsa-badge' }, zoneLabel(candidate)),
1325
- h('span', { className: 'dsa-badge' }, t('chars', { n: candidate.text.length })),
1658
+ h('span', { className: 'dsa-badge' }, t('chars', { n: codePointLength(candidate.text) })),
1326
1659
  cut.truncated
1327
1660
  ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: cut.droppedChars }))
1328
1661
  : null),
1329
- h('pre', { className: 'dsa-quote' }, candidate.text.length > 400 ? `${candidate.text.slice(0, 400)}…` : candidate.text),
1662
+ h('pre', { className: 'dsa-quote' },
1663
+ codePointLength(candidate.text) > 400
1664
+ ? `${Array.from(candidate.text).slice(0, 400).join('')}…`
1665
+ : candidate.text),
1330
1666
  h('textarea', {
1331
1667
  ref: areaRef,
1332
1668
  className: 'dsa-textarea',
@@ -1363,20 +1699,42 @@ window.__ModuleLoader__.load({
1363
1699
 
1364
1700
  /** The streamed answer card body, shared by every surface. */
1365
1701
  function AnswerBody({ card, containerRef }) {
1702
+ // The reasoning stream is collapsible on purpose: it is diagnostic
1703
+ // context, not the answer, and a long chain-of-thought would otherwise
1704
+ // bury the answer the user asked for. Collapsed by default, open on
1705
+ // demand — the text inside still streams live while it is open.
1706
+ const reasoning = typeof card.reasoning === 'string' && card.reasoning !== ''
1707
+ ? h('details', { className: 'dsa-details' },
1708
+ h('summary', null, t('reasoningTitle')),
1709
+ h('div', { className: 'dsa-reasoning' }, renderRichText(card.reasoning)))
1710
+ : null
1711
+ // Follow the stream only while the reader sits at the bottom; scrolling
1712
+ // up to reread an earlier paragraph must win over the auto-scroll. The
1713
+ // 48px slack keeps single-line deltas from losing the tail.
1714
+ React.useEffect(() => {
1715
+ if (card.status !== 'streaming') return undefined
1716
+ const element = containerRef?.current
1717
+ if (element === null || element === undefined) return undefined
1718
+ if (element.scrollHeight - element.scrollTop - element.clientHeight <= 48) {
1719
+ element.scrollTop = element.scrollHeight
1720
+ }
1721
+ return undefined
1722
+ }, [card.text, card.reasoning, card.status, containerRef])
1366
1723
  if (card.error !== null) {
1367
1724
  const engineMissing = card.error.code === 'no-side-engine' || card.error.code === 'no-parent'
1368
1725
  return h('div', { className: 'dsa-error' },
1369
1726
  h('div', { className: 'dsa-title' }, t('errorTitle')),
1370
- h('div', null, card.error.message ?? String(card.error.code ?? '')),
1727
+ h('div', null, localizeError(card.error)),
1371
1728
  engineMissing ? h('div', { className: 'dsa-muted' }, t('errorNoSideEngine')) : null)
1372
1729
  }
1373
1730
  if (card.text === '' && card.status === 'streaming') {
1374
- return h('div', { className: 'dsa-muted' }, t('answerStreaming'))
1731
+ return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerStreaming')))
1375
1732
  }
1376
1733
  if (card.text === '' && card.status === 'done') {
1377
- return h('div', { className: 'dsa-muted' }, t('answerEmpty'))
1734
+ return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerEmpty')))
1378
1735
  }
1379
- return h('div', { className: 'dsa-answer', ref: containerRef }, renderRichText(card.text))
1736
+ return h('div', { className: 'dsa-answer', ref: containerRef },
1737
+ reasoning, renderRichText(card.text))
1380
1738
  }
1381
1739
 
1382
1740
  /** Card footer: status, copy, follow-up, retry, close. */
@@ -1410,18 +1768,40 @@ window.__ModuleLoader__.load({
1410
1768
  }, t('close')))
1411
1769
  }
1412
1770
 
1413
- /** One answer card; `embedded` is true inside a host's tab. */
1414
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
1771
+ /**
1772
+ * One answer card; `embedded` is true inside a host's tab.
1773
+ *
1774
+ * `onFloatMode` is passed ONLY by the floating layer: it renders the
1775
+ * collapse/hide controls that let the user get the layer out of the way
1776
+ * (the layer is the only surface that can cover the conversation). A host
1777
+ * tab that embeds the same card passes no callback and gets no buttons.
1778
+ */
1779
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
1415
1780
  const [followOpen, setFollowOpen] = React.useState(false)
1416
1781
  const [draft, setDraft] = React.useState('')
1417
1782
  const answerRef = React.useRef(null)
1418
1783
  const config = store.state.config
1784
+ // Icon-only, so the header row keeps one line: the full labels
1785
+ // ("收起为胶囊(不遮挡对话)") are 12+ characters each and would wrap the
1786
+ // title bar. The words stay on `title`/`aria-label`.
1787
+ const floatControl = (mode, labelKey, glyph) => h('button', {
1788
+ type: 'button',
1789
+ className: 'dsa-btn dsa-btn-icon',
1790
+ title: t(labelKey),
1791
+ 'aria-label': t(labelKey),
1792
+ onClick: () => onFloatMode(mode),
1793
+ }, glyph)
1419
1794
  return h('section', { className: 'dsa-card', 'aria-label': t('answerTitle') },
1420
1795
  h('div', { className: 'dsa-row' },
1421
1796
  h('span', { className: 'dsa-title' }, t('answerTitle')),
1422
1797
  h('span', { className: 'dsa-badge' }, zoneLabel(card)),
1423
1798
  surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
1424
- card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null),
1799
+ card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null,
1800
+ typeof onFloatMode === 'function'
1801
+ ? h('span', { className: 'dsa-row', style: { marginLeft: 'auto' } },
1802
+ floatControl('capsule', 'floatCollapse', '⇥'),
1803
+ floatControl('off', 'floatHide', '×'))
1804
+ : null),
1425
1805
  h('pre', { className: 'dsa-quote' }, card.question),
1426
1806
  h(AnswerBody, { card, containerRef: answerRef }),
1427
1807
  card.toolFilter === 'unsupported'
@@ -1462,11 +1842,124 @@ window.__ModuleLoader__.load({
1462
1842
  surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
1463
1843
  }
1464
1844
 
1465
- /** The flow surface: a fixed stack of cards at the frame's bottom-right. */
1466
- function FlowStack({ cards, actions }) {
1845
+ /**
1846
+ * The flow surface, i.e. the floating layer (A).
1847
+ *
1848
+ * Three shapes, all sharing one right-anchored container:
1849
+ * - `capsule`: a single ~34px pill (status + truncated question). The
1850
+ * default, because a one-line pill cannot bury the conversation.
1851
+ * - `full`: the classic card stack, collapsed on demand.
1852
+ * - `off`: only a small launcher, so nothing covers the page until asked.
1853
+ *
1854
+ * The width comes from {@link floatPlacement}, so the layer stops growing
1855
+ * left as soon as it would cross the conversation column's right boundary.
1856
+ */
1857
+ function FloatingLayer({ cards, actions }) {
1858
+ const containerRef = React.useRef(null)
1859
+ const state = useStoreState()
1467
1860
  const visible = cards.filter(card => card.surface === 'flow' && card.open)
1468
- if (visible.length === 0) return null
1469
- return h('div', { className: 'dsa-stack', 'data-dsa-root': '' },
1861
+ const total = visible.length
1862
+ const mode = state.floatMode ?? state.config.floatMode ?? CLIENT_DEFAULTS.floatMode
1863
+ const maxWidth = state.config.floatMaxWidth ?? CLIENT_DEFAULTS.floatMaxWidth
1864
+
1865
+ // Measure the conversation column + right rail, then pin the layer's
1866
+ // width to what is left. Recomputed on frame resize and on rail toggles
1867
+ // (ResizeObserver), not once at mount. Every probe is optional: the test
1868
+ // harness's synthetic DOM has no `querySelectorAll`, and an older WebView
1869
+ // may have no `ResizeObserver` — neither may break the layer.
1870
+ React.useEffect(() => {
1871
+ const node = containerRef.current
1872
+ if (node === null || node === undefined) return undefined
1873
+ const selector = '[data-slot^="conversation"], .rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]'
1874
+ const all = () => {
1875
+ try {
1876
+ return typeof document.querySelectorAll === 'function'
1877
+ ? [...document.querySelectorAll(selector)]
1878
+ : []
1879
+ } catch { return [] }
1880
+ }
1881
+ const one = (query) => {
1882
+ try { return typeof document.querySelector === 'function' ? document.querySelector(query) : null } catch { return null }
1883
+ }
1884
+ let frame = 0
1885
+ const apply = () => {
1886
+ if (typeof node.style?.setProperty !== 'function') return
1887
+ const lane = one('[data-slot^="conversation"]')
1888
+ const rail = one('.rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]')
1889
+ const rootWidth = document.documentElement?.clientWidth ?? window.innerWidth
1890
+ const laneRight = lane === null
1891
+ ? rootWidth
1892
+ : lane.getBoundingClientRect().right
1893
+ const railLeft = rail === null ? null : rail.getBoundingClientRect().left
1894
+ const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
1895
+ node.style.setProperty('--dsa-float-w', `${placement.width}px`)
1896
+ node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
1897
+ // Readable placement, so a user report can say WHERE the layer sat.
1898
+ node.dataset.layerWidth = String(placement.width)
1899
+ }
1900
+ const schedule = () => {
1901
+ if (typeof window.requestAnimationFrame !== 'function') { apply(); return }
1902
+ if (frame !== 0) return
1903
+ frame = window.requestAnimationFrame(() => {
1904
+ frame = 0
1905
+ apply()
1906
+ })
1907
+ }
1908
+ apply()
1909
+ let observer = null
1910
+ if (typeof window.ResizeObserver === 'function') {
1911
+ observer = new window.ResizeObserver(schedule)
1912
+ for (const target of all()) {
1913
+ try { observer.observe(target) } catch { /* not an element we can watch */ }
1914
+ }
1915
+ }
1916
+ if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
1917
+ return () => {
1918
+ if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', schedule)
1919
+ if (observer !== null) { try { observer.disconnect() } catch { /* already gone */ } }
1920
+ if (frame !== 0 && typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(frame)
1921
+ }
1922
+ }, [maxWidth, total, mode])
1923
+
1924
+ if (total === 0) return null
1925
+
1926
+ const dot = h('span', {
1927
+ className: 'dsa-capsule-dot',
1928
+ 'data-status': visible.some(card => card.status === 'streaming') ? 'streaming' : visible[0].status,
1929
+ })
1930
+ const label = visible.some(card => card.status === 'streaming')
1931
+ ? t('floatStreaming')
1932
+ : t('floatDone', { n: total })
1933
+ const firstQuestion = visible[0].question
1934
+
1935
+ const container = children => h('div', {
1936
+ className: 'dsa-float',
1937
+ 'data-dsa-root': '',
1938
+ ref: containerRef,
1939
+ 'data-dsa-float-mode': mode,
1940
+ }, children)
1941
+
1942
+ if (mode === 'off') {
1943
+ return container(h('button', {
1944
+ type: 'button',
1945
+ className: 'dsa-launcher',
1946
+ title: t('floatRestore'),
1947
+ onClick: () => setFloatMode('full'),
1948
+ }, dot, t('floatCount', { n: total })))
1949
+ }
1950
+
1951
+ if (mode === 'capsule') {
1952
+ return container(h('button', {
1953
+ type: 'button',
1954
+ className: 'dsa-capsule',
1955
+ title: t('floatExpand'),
1956
+ onClick: () => setFloatMode('full'),
1957
+ }, dot, h('span', { className: 'dsa-capsule-label' }, label),
1958
+ h('span', { className: 'dsa-capsule-q' }, firstQuestion),
1959
+ h('span', { className: 'dsa-capsule-chev' }, '›')))
1960
+ }
1961
+
1962
+ return container(h('div', { className: 'dsa-stack' },
1470
1963
  visible.map(card => h(AnswerCard, {
1471
1964
  key: card.id,
1472
1965
  card,
@@ -1477,7 +1970,8 @@ window.__ModuleLoader__.load({
1477
1970
  onCancel: () => actions.cancel(card),
1478
1971
  onSendToMain: () => actions.sendToMain(card),
1479
1972
  onClose: () => actions.close(card),
1480
- })))
1973
+ onFloatMode: setFloatMode,
1974
+ }))))
1481
1975
  }
1482
1976
 
1483
1977
  /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
@@ -1499,7 +1993,7 @@ window.__ModuleLoader__.load({
1499
1993
  onSubmit: payload => actions.submit(popover.candidate, payload),
1500
1994
  })
1501
1995
  : null,
1502
- h(FlowStack, { cards: state.cards, actions }),
1996
+ h(FloatingLayer, { cards: state.cards, actions }),
1503
1997
  state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
1504
1998
  }
1505
1999
 
@@ -1533,6 +2027,25 @@ window.__ModuleLoader__.load({
1533
2027
  onChange: event => setDraft({ ...draft, [key]: Number(event.target.value) }),
1534
2028
  })
1535
2029
 
2030
+ /**
2031
+ * Provider picker: `auto` plus whatever `/state` reported live. The
2032
+ * current draft value is always offered, so a stale saved name or an
2033
+ * unreachable host never blanks the select.
2034
+ */
2035
+ const providerSelect = () => {
2036
+ const reported = Array.isArray(state.capabilities?.sideProviders)
2037
+ ? state.capabilities.sideProviders.filter(name => typeof name === 'string' && name !== '')
2038
+ : []
2039
+ const known = ['auto', ...reported]
2040
+ const value = String(draft.sideProvider)
2041
+ const options = known.includes(value) ? known : [...known, value]
2042
+ return h('select', {
2043
+ className: 'dsa-input',
2044
+ value,
2045
+ onChange: event => setDraft({ ...draft, sideProvider: event.target.value }),
2046
+ }, options.map(name => h('option', { key: name, value: name }, name)))
2047
+ }
2048
+
1536
2049
  const save = async () => {
1537
2050
  if (parseShortcut(draft.shortcut) === null) {
1538
2051
  setStatus(`error:${t('unsupportedTrigger')}`)
@@ -1575,6 +2088,12 @@ window.__ModuleLoader__.load({
1575
2088
  ['chat+task', t('setZonesBoth')],
1576
2089
  ])),
1577
2090
  field(t('setMaxChars'), number('maxChars', 200, 60000), t('setMaxCharsHint')),
2091
+ field(t('setFloatMode'), select('floatMode', [
2092
+ ['capsule', t('setFloatModeCapsule')],
2093
+ ['full', t('setFloatModeFull')],
2094
+ ['off', t('setFloatModeOff')],
2095
+ ]), t('setFloatModeHint')),
2096
+ field(t('setFloatMaxWidth'), number('floatMaxWidth', 240, 560), t('setFloatMaxWidthHint')),
1578
2097
  field(t('setMinChars'), number('minChars', 0, 200)),
1579
2098
  field(t('setConcurrency'), number('maxConcurrentAsks', 1, 12)),
1580
2099
  field(t('setShortcut'), h('input', {
@@ -1585,7 +2104,9 @@ window.__ModuleLoader__.load({
1585
2104
  field(t('setSideTools'), select('sideTools', [
1586
2105
  ['readonly', t('setSideToolsReadonly')],
1587
2106
  ['inherit', t('setSideToolsInherit')],
1588
- ]))),
2107
+ ])),
2108
+ field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
2109
+ field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
1589
2110
  h('label', { className: 'dsa-check' },
1590
2111
  h('input', {
1591
2112
  type: 'checkbox',
@@ -1715,7 +2236,7 @@ window.__ModuleLoader__.load({
1715
2236
  const disposers = []
1716
2237
  const controllers = new Map()
1717
2238
  /** Pending surface render-proof timers (cleared on unload). */
1718
- const pendingProofs = new Set()
2239
+ const pendingProofs = new Map()
1719
2240
  let lastSignature = null
1720
2241
  let lastAt = 0
1721
2242
  let pendingTimer = null
@@ -1740,6 +2261,96 @@ window.__ModuleLoader__.load({
1740
2261
  }
1741
2262
  }
1742
2263
 
2264
+ // ── card persistence ────────────────────────────────────────────
2265
+ // A page reload used to lose every finished answer: the store is
2266
+ // memory-only and the reload drops the SSE link mid-stream. Finished
2267
+ // cards are therefore mirrored into `localStorage` (same data, no
2268
+ // server round-trip) and replayed on boot. Everything here is optional
2269
+ // by design — a private-mode window (throws on setItem), a full quota,
2270
+ // or a tampered payload must degrade to "no history", never break boot.
2271
+ const PERSIST_KEY = 'dsh-sidecard-ask:cards:v1'
2272
+ const PERSIST_MAX_CARDS = 10
2273
+ const PERSIST_TEXT_LIMIT = 65536
2274
+
2275
+ function readPersisted() {
2276
+ let raw = null
2277
+ try {
2278
+ raw = window.localStorage?.getItem(PERSIST_KEY)
2279
+ } catch { return [] }
2280
+ if (raw === null || raw === undefined) return []
2281
+ try {
2282
+ const value = JSON.parse(raw)
2283
+ if (value?.version !== 1 || !Array.isArray(value.cards)) return []
2284
+ return value.cards
2285
+ .filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
2286
+ .slice(0, PERSIST_MAX_CARDS)
2287
+ } catch { return [] }
2288
+ }
2289
+
2290
+ function writePersisted(cards) {
2291
+ try {
2292
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 1, cards }))
2293
+ } catch (error) {
2294
+ // Quota or privacy mode — the cards stay on screen, just not durable.
2295
+ console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
2296
+ }
2297
+ }
2298
+
2299
+ /** Recompute the durable set from the live store and write it. */
2300
+ const syncPersisted = () => {
2301
+ const finished = store.state.cards
2302
+ .filter(card => card.carrier === 'side'
2303
+ && (card.status === 'done' || card.status === 'stopped')
2304
+ && card.text !== '')
2305
+ .slice(0, PERSIST_MAX_CARDS)
2306
+ .map(card => ({
2307
+ id: card.id,
2308
+ question: card.question,
2309
+ selected: String(card.selected ?? '').slice(0, PERSIST_TEXT_LIMIT),
2310
+ selection: String(card.selection ?? '').slice(0, PERSIST_TEXT_LIMIT),
2311
+ text: String(card.text ?? '').slice(0, PERSIST_TEXT_LIMIT),
2312
+ reasoning: String(card.reasoning ?? '').slice(0, PERSIST_TEXT_LIMIT),
2313
+ status: card.status === 'stopped' ? 'stopped' : 'done',
2314
+ carrier: 'side',
2315
+ zone: card.zone,
2316
+ truncated: card.truncated === true,
2317
+ droppedChars: Number(card.droppedChars ?? 0),
2318
+ history: Array.isArray(card.history) ? card.history.slice(-12) : [],
2319
+ }))
2320
+ writePersisted(finished)
2321
+ }
2322
+
2323
+ /** Replay finished cards from a previous page load into the store. */
2324
+ const restorePersisted = () => {
2325
+ const saved = readPersisted()
2326
+ if (saved.length === 0) return
2327
+ const replayed = saved.map(entry => ({
2328
+ id: entry.id,
2329
+ question: entry.question,
2330
+ selected: String(entry.selected ?? ''),
2331
+ selection: String(entry.selection ?? ''),
2332
+ text: String(entry.text ?? ''),
2333
+ reasoning: String(entry.reasoning ?? ''),
2334
+ status: entry.status === 'stopped' ? 'stopped' : 'done',
2335
+ carrier: 'side',
2336
+ zone: entry.zone,
2337
+ truncated: entry.truncated === true,
2338
+ droppedChars: Number(entry.droppedChars ?? 0),
2339
+ streaming: null,
2340
+ error: null,
2341
+ // A native/better tab cannot survive a reload, so history always
2342
+ // comes back on the flow stack where it is visible immediately.
2343
+ surface: 'flow',
2344
+ open: true,
2345
+ history: Array.isArray(entry.history)
2346
+ ? entry.history
2347
+ .filter(turn => turn !== null && typeof turn === 'object')
2348
+ .map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
2349
+ : [],
2350
+ }))
2351
+ store.set({ cards: [...replayed, ...store.state.cards] })
2352
+ }
2353
+
1743
2354
  const actions = {
1744
2355
  openPopover(candidate) {
1745
2356
  store.set({ popover: { candidate } })
@@ -1759,7 +2370,6 @@ window.__ModuleLoader__.load({
1759
2370
  truncated: cut.truncated,
1760
2371
  droppedChars: cut.droppedChars,
1761
2372
  zone: candidate.zone,
1762
- cross: candidate.cross,
1763
2373
  carrier: payload.carrier,
1764
2374
  status: 'pending',
1765
2375
  text: '',
@@ -1817,11 +2427,13 @@ window.__ModuleLoader__.load({
1817
2427
  } else {
1818
2428
  // Render proof: an adapter that accepts the open but never
1819
2429
  // mounts our body would leave an empty tab. Unless the card is
1820
- // on screen shortly after, move it to the flow stack — the
1821
- // card's data lives in the store, so the running stream simply
1822
- // continues in the other surface.
2430
+ // mounted by then (CardHost cancels its own proof on mount),
2431
+ // move it to the flow stack — the card's data lives in the
2432
+ // store, so the running stream simply continues on the other
2433
+ // surface. A proof that fires before a lazy host mounts is
2434
+ // what the "moved to the flow stack" body below covers.
1823
2435
  const proof = setTimeout(() => {
1824
- pendingProofs.delete(proof)
2436
+ pendingProofs.delete(card.id)
1825
2437
  const live = store.state.cards.find(item => item.id === card.id)
1826
2438
  if (live === undefined || live.surface === 'flow') return
1827
2439
  if (renderedCardId === card.id) return
@@ -1830,7 +2442,7 @@ window.__ModuleLoader__.load({
1830
2442
  toast(t('surfaceUnproven'))
1831
2443
  scheduleReport(0)
1832
2444
  }, 600)
1833
- pendingProofs.add(proof)
2445
+ pendingProofs.set(card.id, proof)
1834
2446
  }
1835
2447
  }
1836
2448
  void runSideCard(card.id)
@@ -1877,7 +2489,15 @@ window.__ModuleLoader__.load({
1877
2489
  },
1878
2490
  cancel(card) {
1879
2491
  const controller = controllers.get(card.id)
1880
- if (controller !== undefined) controller.abort()
2492
+ if (controller !== undefined) {
2493
+ controller.abort()
2494
+ // The local abort stops only the browser's stream: the Host's
2495
+ // child run would keep burning until its timeout unless told,
2496
+ // so POST /cancel too. Fire-and-forget on purpose — the card is
2497
+ // already stopped locally, and a dead host must not turn the
2498
+ // Stop button into a failure.
2499
+ void postJson('cancel', { id: card.id }).catch(() => {})
2500
+ }
1881
2501
  patchCard(card.id, { status: 'stopped' })
1882
2502
  },
1883
2503
  async sendToMain(card) {
@@ -1906,14 +2526,33 @@ window.__ModuleLoader__.load({
1906
2526
  }
1907
2527
  },
1908
2528
  close(card) {
1909
- controllers.get(card.id)?.abort()
2529
+ const controller = controllers.get(card.id)
2530
+ if (controller !== undefined) {
2531
+ controller.abort()
2532
+ // Same host-side release as the Stop button: closing a card
2533
+ // mid-stream must not leave the child running server-side.
2534
+ void postJson('cancel', { id: card.id }).catch(() => {})
2535
+ }
1910
2536
  controllers.delete(card.id)
2537
+ // A closed card's buffered-but-unflushed deltas belong to nobody:
2538
+ // drop them here so the next tick's flush cannot touch them.
2539
+ pendingDeltas.delete(card.id)
2540
+ pendingReasonings.delete(card.id)
2541
+ // Every surface gets a chance to close its own container: an
2542
+ // orphaned native tab is indistinguishable from a stuck card to
2543
+ // the user, so closing the card must close the tab too (the rail
2544
+ // silently ignores a close it cannot perform).
1911
2545
  if (card.surface === 'better-sidebar') {
1912
2546
  try {
1913
2547
  surfaces.better.close(card)
1914
2548
  } catch { /* the host may already be gone */ }
2549
+ } else if (card.surface === 'native-rightbar') {
2550
+ try {
2551
+ surfaces.native.close(card)
2552
+ } catch { /* the right rail owns its lifecycle */ }
1915
2553
  }
1916
2554
  store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
2555
+ syncPersisted()
1917
2556
  },
1918
2557
  async saveConfig(patch) {
1919
2558
  const value = await postJson('config', patch)
@@ -1938,6 +2577,44 @@ window.__ModuleLoader__.load({
1938
2577
  },
1939
2578
  }
1940
2579
 
2580
+ /**
2581
+ * Streaming deltas coalesce into one store commit per tick. Every
2582
+ * commit re-renders every card and re-parses the whole answer, so a
2583
+ * fast stream on a long answer was quadratic work. A terminal event
2584
+ * (or cancel, or close) flushes the buffer first, so no streamed text
2585
+ * is ever lost on the way into the store.
2586
+ */
2587
+ const pendingDeltas = new Map()
2588
+ const pendingReasonings = new Map()
2589
+ let deltaFlushTimer = null
2590
+
2591
+ const flushStreamBuffers = () => {
2592
+ if (deltaFlushTimer !== null) {
2593
+ clearTimeout(deltaFlushTimer)
2594
+ deltaFlushTimer = null
2595
+ }
2596
+ if (pendingDeltas.size > 0) {
2597
+ for (const [cardId, chunk] of [...pendingDeltas.entries()]) appendCardText(cardId, chunk)
2598
+ pendingDeltas.clear()
2599
+ }
2600
+ if (pendingReasonings.size > 0) {
2601
+ for (const [cardId, chunk] of [...pendingReasonings.entries()]) appendCardReasoning(cardId, chunk)
2602
+ pendingReasonings.clear()
2603
+ }
2604
+ }
2605
+
2606
+ const bufferStreamDelta = (cardId, chunk) => {
2607
+ if (chunk === '') return
2608
+ pendingDeltas.set(cardId, (pendingDeltas.get(cardId) ?? '') + chunk)
2609
+ if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
2610
+ }
2611
+
2612
+ const bufferStreamReasoning = (cardId, chunk) => {
2613
+ if (chunk === '') return
2614
+ pendingReasonings.set(cardId, (pendingReasonings.get(cardId) ?? '') + chunk)
2615
+ if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, 50)
2616
+ }
2617
+
1941
2618
  /** Stream one side answer into its card. */
1942
2619
  const runSideCard = async (cardId) => {
1943
2620
  const card = store.state.cards.find(item => item.id === cardId)
@@ -1950,10 +2627,9 @@ window.__ModuleLoader__.load({
1950
2627
  question: card.question,
1951
2628
  selection: card.selected,
1952
2629
  zone: card.zone,
1953
- cross: card.cross,
1954
2630
  carrier: 'side',
1955
2631
  sessionId: currentSessionId(ctx),
1956
- history: card.history.slice(-6),
2632
+ history: card.history.slice(-HISTORY_TURNS),
1957
2633
  }, {
1958
2634
  signal: controller.signal,
1959
2635
  onStart: (data) => {
@@ -1965,18 +2641,27 @@ window.__ModuleLoader__.load({
1965
2641
  toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
1966
2642
  })
1967
2643
  },
1968
- onDelta: data => appendCardText(cardId, String(data?.text ?? '')),
1969
- onReasoning: data => appendCardReasoning(cardId, String(data?.text ?? '')),
2644
+ onDelta: data => bufferStreamDelta(cardId, String(data?.text ?? '')),
2645
+ onReasoning: data => bufferStreamReasoning(cardId, String(data?.text ?? '')),
1970
2646
  onDone: (data) => {
2647
+ // A locally cancelled stream can still see its terminal frame
2648
+ // (a harness stream ignores the abort signal; a real host may
2649
+ // race the abort): the card's local "stopped" verdict wins.
2650
+ if (controller.signal.aborted) return
2651
+ flushStreamBuffers()
1971
2652
  const current = store.state.cards.find(item => item.id === cardId)
1972
2653
  patchCard(cardId, {
1973
2654
  status: data?.aborted === true ? 'stopped' : 'done',
1974
2655
  streaming: data?.streaming === true,
1975
2656
  text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
2657
+ reasoning: typeof data?.reasoning === 'string' ? data.reasoning : (current?.reasoning ?? ''),
1976
2658
  })
1977
2659
  controllers.delete(cardId)
2660
+ syncPersisted()
1978
2661
  },
1979
2662
  onError: (error) => {
2663
+ if (controller.signal.aborted) return
2664
+ flushStreamBuffers()
1980
2665
  patchCard(cardId, { status: 'error', error })
1981
2666
  controllers.delete(cardId)
1982
2667
  },
@@ -2080,7 +2765,10 @@ window.__ModuleLoader__.load({
2080
2765
  id = snapshot?.locale ?? snapshot?.id ?? (typeof locale.getLocale === 'function' ? locale.getLocale()?.locale : null)
2081
2766
  } catch { /* keep the previous language */ }
2082
2767
  if (typeof id === 'string' && id !== '') {
2083
- const next = id.toLowerCase().startsWith('zh') ? 'zh' : 'en'
2768
+ // The service names ONE locale (no preference list behind it),
2769
+ // so an id the UI cannot serve falls back to en — same rule the
2770
+ // initial resolution applies to a preference list with no hit.
2771
+ const next = resolveLanguage([id], 'en')
2084
2772
  if (next !== lang) {
2085
2773
  lang = next
2086
2774
  store.set({})
@@ -2274,7 +2962,7 @@ window.__ModuleLoader__.load({
2274
2962
  // degrades to the next surface and logs, never breaks the plugin.
2275
2963
  // See README §6 for the replacement checklist.
2276
2964
  try {
2277
- ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => {
2965
+ const uninject = ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => {
2278
2966
  try {
2279
2967
  const registry = injected.get('sidebarRightTabs')
2280
2968
  const controller = injected.get('sidebarRight')
@@ -2346,6 +3034,9 @@ window.__ModuleLoader__.load({
2346
3034
  console.error(`[${PLUGIN_ID}] native right-rail adapter unavailable:`, error)
2347
3035
  }
2348
3036
  })
3037
+ // ctx.inject's own reverse-registration must unwind with the plugin
3038
+ // too, or the host keeps routing injections into a disposed fiber.
3039
+ if (typeof uninject === 'function') disposers.push(uninject)
2349
3040
  } catch (error) {
2350
3041
  surfaces.native.error = String(error?.message ?? error)
2351
3042
  }
@@ -2361,7 +3052,7 @@ window.__ModuleLoader__.load({
2361
3052
  // ("Features are never removed"), so the adapter gates on `tabMeta`
2362
3053
  // instead of on a version number.
2363
3054
  try {
2364
- ctx.inject(['betterSidebar'], (injected) => {
3055
+ const uninject = ctx.inject(['betterSidebar'], (injected) => {
2365
3056
  try {
2366
3057
  const service = injected.get('betterSidebar')
2367
3058
  if (service === null || service === undefined) return
@@ -2398,6 +3089,7 @@ window.__ModuleLoader__.load({
2398
3089
  console.error(`[${PLUGIN_ID}] better-sidebar adapter unavailable:`, error)
2399
3090
  }
2400
3091
  })
3092
+ if (typeof uninject === 'function') disposers.push(uninject)
2401
3093
  } catch (error) {
2402
3094
  surfaces.better.error = String(error?.message ?? error)
2403
3095
  }
@@ -2412,6 +3104,14 @@ window.__ModuleLoader__.load({
2412
3104
  React.useEffect(() => {
2413
3105
  if (typeof cardId !== 'string' || cardId === '') return undefined
2414
3106
  renderedCardId = cardId
3107
+ // Mounting settles the render proof, even when this host mounted
3108
+ // late (a lazy tab panel slower than the 600ms timer) — the proof
3109
+ // must never fire after the card is demonstrably on screen.
3110
+ const proof = pendingProofs.get(cardId)
3111
+ if (proof !== undefined) {
3112
+ clearTimeout(proof)
3113
+ pendingProofs.delete(cardId)
3114
+ }
2415
3115
  return () => {
2416
3116
  if (renderedCardId === cardId) renderedCardId = null
2417
3117
  }
@@ -2419,7 +3119,18 @@ window.__ModuleLoader__.load({
2419
3119
  if (card === undefined) {
2420
3120
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
2421
3121
  h(Styles, null),
2422
- h('div', { className: 'dsa-muted' }, t('answerEmpty')))
3122
+ h('div', { className: 'dsa-muted' }, t('cardClosed')))
3123
+ }
3124
+ if (card.surface === 'flow') {
3125
+ // The proof already moved this card to the flow stack and this
3126
+ // host mounted afterwards. Rendering the answer again here would
3127
+ // show it in two places, so close the tab we can and leave the
3128
+ // note for a rail that keeps it.
3129
+ try { surfaces.native.close(card) } catch { /* the rail owns its lifecycle */ }
3130
+ try { surfaces.better.close(card) } catch { /* the host may already be gone */ }
3131
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
3132
+ h(Styles, null),
3133
+ h('div', { className: 'dsa-muted' }, t('cardMovedToFlow')))
2423
3134
  }
2424
3135
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '', style: { padding: '10px' } },
2425
3136
  h(Styles, null),
@@ -2436,6 +3147,12 @@ window.__ModuleLoader__.load({
2436
3147
  }
2437
3148
 
2438
3149
  // ── boot ─────────────────────────────────────────────────────────
3150
+ try {
3151
+ restorePersisted()
3152
+ } catch (error) {
3153
+ // Corrupt-but-parseable history must never block startup.
3154
+ console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
3155
+ }
2439
3156
  void refreshState().catch(() => { /* the settings page shows the failure */ })
2440
3157
  // One report after the slot ladders have had time to settle, so the
2441
3158
  // Host ends up holding the CLIENT's real registration state.
@@ -2443,7 +3160,13 @@ window.__ModuleLoader__.load({
2443
3160
  disposers.push(() => {
2444
3161
  for (const controller of controllers.values()) controller.abort()
2445
3162
  controllers.clear()
2446
- for (const proof of pendingProofs) clearTimeout(proof)
3163
+ pendingDeltas.clear()
3164
+ pendingReasonings.clear()
3165
+ if (deltaFlushTimer !== null) {
3166
+ clearTimeout(deltaFlushTimer)
3167
+ deltaFlushTimer = null
3168
+ }
3169
+ for (const proof of pendingProofs.values()) clearTimeout(proof)
2447
3170
  pendingProofs.clear()
2448
3171
  if (toastTimer !== null) clearTimeout(toastTimer)
2449
3172
  if (reportTimer !== null) clearTimeout(reportTimer)
@@ -2482,6 +3205,8 @@ window.__ModuleLoader__.load({
2482
3205
  IDS,
2483
3206
  CARD_KIND,
2484
3207
  CARD_KIND_BETTER,
3208
+ HISTORY_TURNS,
3209
+ estimateLabelWidth,
2485
3210
  parseSseBlock,
2486
3211
  truncateSelection,
2487
3212
  parseShortcut,
@@ -2496,6 +3221,10 @@ window.__ModuleLoader__.load({
2496
3221
  isDiagnosticsUnsupported,
2497
3222
  renderRichText,
2498
3223
  pickSurface,
3224
+ floatPlacement,
3225
+ localizeError,
3226
+ errorCodes: ERROR_CODES,
3227
+ resolveLanguage,
2499
3228
  dict: DICT,
2500
3229
  }),
2501
3230
  }),