dsh-sidecard-ask 1.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.
package/client.js ADDED
@@ -0,0 +1,2268 @@
1
+ /**
2
+ * dsh-sidecard-ask — Client half (`dsh.client.platform = web`).
3
+ *
4
+ * Browser artifact: registers one lazy factory whose id equals the package
5
+ * name. React comes from the browser module table (`require('react')`); this
6
+ * file imports NOTHING else, and it is plain JavaScript on purpose — no build
7
+ * step, no bundler, no client package.
8
+ *
9
+ * Layout of this file:
10
+ * §1 constants, defaults, dictionaries
11
+ * §2 i18n (own dictionary + optional `locale` service)
12
+ * §3 wire client (JSON + SSE against the Host half)
13
+ * §4 module store (one observable state for every surface)
14
+ * §5 selection engine (read, classify, place, dedupe)
15
+ * §6 carriers (main conversation / independent side card)
16
+ * §7 side-card surfaces (native right sidebar → better-sidebar → own flow)
17
+ * §8 view helpers (markdown-lite renderer, styles, small controls)
18
+ * §9 components (overlay root, cards, settings page, session probe)
19
+ * §10 plugin face (apply + registrations)
20
+ *
21
+ * Test seam: the returned module carries `api.pure`, a frozen bag of the pure
22
+ * helpers above. The DSH client runtime only reads `inject`/`apply`; the test
23
+ * harness in `test/` loads this file with a synthetic `window` to exercise
24
+ * them without a browser.
25
+ */
26
+
27
+ window.__ModuleLoader__.load({
28
+ id: 'dsh-sidecard-ask',
29
+ factory(require) {
30
+ const React = require('react')
31
+ const h = React.createElement
32
+
33
+ // ─────────────────────────────────────────────────────────────────────
34
+ // §1 constants and defaults
35
+ // ─────────────────────────────────────────────────────────────────────
36
+
37
+ const PLUGIN_ID = 'dsh-sidecard-ask'
38
+ const API = '/sidecard-ask/api'
39
+ /** Slot ids contributed by this plugin (also the overlay entry ids). */
40
+ const IDS = {
41
+ overlay: 'sidecard-ask',
42
+ settings: 'sidecard-ask-settings',
43
+ sessionProbe: 'sidecard-ask-session-probe',
44
+ }
45
+ /** Side-card tab type registered in a side-card host. */
46
+ const CARD_KIND = 'sidecard-ask:card'
47
+
48
+ /**
49
+ * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
50
+ * before `/state` answers (first paint) and when the Host is unreachable;
51
+ * `test/contract-test.mjs` asserts this mirror stays identical to the
52
+ * Host's own defaults.
53
+ */
54
+ const CLIENT_DEFAULTS = {
55
+ trigger: 'selection',
56
+ defaultCarrier: 'side',
57
+ sideSurface: 'auto',
58
+ maxChars: 4000,
59
+ shortcut: 'Alt+Q',
60
+ captureZones: 'auto',
61
+ showInUnclassified: true,
62
+ minChars: 2,
63
+ maxConcurrentAsks: 3,
64
+ sideTools: 'readonly',
65
+ sideTimeoutMs: 180000,
66
+ sideProvider: 'auto',
67
+ }
68
+
69
+ /** Zone classification: slot-key patterns the harness itself renders. */
70
+ const ZONE_PATTERNS = {
71
+ chat: [/^conversation\b/],
72
+ task: [/\btask/i, /\btodo/i, /\bschedule/i, /\bteam/i, /\bjob/i, /\bplan/i, /^rightbar\b/, /^sidebar\.right\b/],
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────
76
+ // §2 i18n
77
+ // ─────────────────────────────────────────────────────────────────────
78
+
79
+ const DICT = {
80
+ zh: {
81
+ triggerLabel: '追问选中内容',
82
+ triggerHint: '点击用 AI 追问这段文字',
83
+ askTitle: '追问选中内容',
84
+ askPlaceholder: '想就这段内容问什么?(Enter 发送,Shift+Enter 换行)',
85
+ askSubmit: '提问',
86
+ askCancel: '取消',
87
+ askShortcutHint: '快捷键 {key} 可随时唤起',
88
+ carrier: '作答位置',
89
+ carrierMain: '主对话',
90
+ carrierSide: '侧边卡片',
91
+ carrierMainHint: '在主对话里提问,答案随对话正常流式呈现',
92
+ carrierSideHint: '独立子代理作答,不打扰主对话',
93
+ zoneChat: '聊天区',
94
+ zoneTask: '任务区',
95
+ zoneOther: '其它区域',
96
+ zoneCross: '跨区选择',
97
+ chars: '{n} 字',
98
+ truncatedBadge: '已截断 {n} 字',
99
+ selectionEmpty: '没有选中文本',
100
+ selectionTooShort: '选中文本太短,未触发追问',
101
+ answerTitle: '追问',
102
+ answerStreaming: '作答中…',
103
+ answerDone: '已完成',
104
+ answerStopped: '已停止',
105
+ answerNonStreaming: '当前 DSH 版本未提供流式帧,答案一次性返回',
106
+ answerEmpty: '(没有内容)',
107
+ copy: '复制',
108
+ copied: '已复制',
109
+ copySelectedHint: '已选中答案文本,请按 Ctrl+C 复制',
110
+ copyFailed: '复制失败,请手动选择',
111
+ close: '关闭',
112
+ followUp: '继续追问',
113
+ followUpPlaceholder: '继续问下去…',
114
+ retry: '重试',
115
+ sendToMain: '改到主对话',
116
+ cancelAnswer: '停止作答',
117
+ mainSent: '已发送到主对话',
118
+ mainSentHint: '答案会出现在主对话里,随对话正常流式输出。',
119
+ draftInserted: '已填入输入框',
120
+ draftInsertedHint: '主对话发送接口不可用,已把追问内容放进输入框,请按 Enter 发送。',
121
+ noMainCarrier: '主对话发送接口不可用:既不能提交消息,也不能写入输入框',
122
+ errorTitle: '作答失败',
123
+ errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
124
+ surfaceFlow: '浮层卡片',
125
+ surfaceNative: '右侧栏卡片',
126
+ surfaceBetter: '侧边卡片插件',
127
+ surfaceFellBack: '请求的侧边面不可用,已回退到{name}',
128
+ surfaceUnproven: '目标承载面未真正渲染该卡片,已回退到内置浮层卡片',
129
+ settingsTitle: '划词追问',
130
+ settingsDesc: '选中聊天或任务里的文本,就地追问。',
131
+ setTrigger: '触发方式',
132
+ setTriggerSelection: '选中即出现按钮',
133
+ setTriggerShortcut: '仅快捷键',
134
+ setTriggerBoth: '按钮 + 快捷键',
135
+ setCarrier: '默认作答位置',
136
+ setSurface: '侧边卡片承载面',
137
+ setSurfaceAuto: '自动选择',
138
+ setSurfaceFlow: '内置浮层卡片',
139
+ setMaxChars: '最大字符数',
140
+ setMaxCharsHint: '超过部分截断并在提问中标注',
141
+ setShortcut: '快捷键',
142
+ setZones: '捕获区域',
143
+ setZonesAuto: '全部区域',
144
+ setZonesChat: '仅聊天区',
145
+ setZonesTask: '仅任务区',
146
+ setZonesBoth: '聊天区 + 任务区',
147
+ setUnclassified: '未归类区域也触发',
148
+ setMinChars: '最小触发字符数',
149
+ setConcurrency: '并发作答上限',
150
+ setSideTools: '侧边作答工具权限',
151
+ setSideToolsReadonly: '只读(推荐)',
152
+ setSideToolsInherit: '继承当前会话',
153
+ save: '保存',
154
+ saving: '保存中…',
155
+ saved: '已保存',
156
+ saveFailed: '保存失败:{msg}',
157
+ reset: '重置为 patch 配置',
158
+ resetDone: '已重置',
159
+ diagnostics: '运行自检',
160
+ diagTitle: '能力自检',
161
+ diagHost: '宿主接口',
162
+ diagSide: '侧边作答引擎',
163
+ diagProviders: '子代理 provider',
164
+ diagAgents: '活动会话代理',
165
+ diagSurface: '当前侧边承载面',
166
+ diagSession: '已识别会话',
167
+ diagZones: '区域锚点(data-slot)',
168
+ diagZonesMissing: '缺失,区域过滤已停用',
169
+ diagSlots: '槽位落点',
170
+ toolGuardUnavailable: '该 provider 不支持工具白名单,本次作答继承了当前会话的工具',
171
+ toolGuardInherited: '按配置继承当前会话的工具',
172
+ diagUnreachable: '宿主接口不可达:{msg}',
173
+ yes: '可用',
174
+ no: '不可用',
175
+ none: '无',
176
+ unsupportedTrigger: '无法识别该快捷键,请用形如 Alt+Q / Ctrl+Shift+K 的写法',
177
+ surfaceUnavailable: '所选侧边承载面不可用',
178
+ },
179
+ en: {
180
+ triggerLabel: 'Ask about selection',
181
+ triggerHint: 'Ask AI about this text',
182
+ askTitle: 'Ask about the selection',
183
+ askPlaceholder: 'What do you want to ask? (Enter sends, Shift+Enter breaks a line)',
184
+ askSubmit: 'Ask',
185
+ askCancel: 'Cancel',
186
+ askShortcutHint: 'Press {key} any time',
187
+ carrier: 'Answer in',
188
+ carrierMain: 'Main chat',
189
+ carrierSide: 'Side card',
190
+ carrierMainHint: 'Ask in the main conversation; the answer streams there as usual',
191
+ carrierSideHint: 'A separate child agent answers, leaving the main chat untouched',
192
+ zoneChat: 'Chat',
193
+ zoneTask: 'Tasks',
194
+ zoneOther: 'Elsewhere',
195
+ zoneCross: 'Cross-region',
196
+ chars: '{n} chars',
197
+ truncatedBadge: 'truncated {n} chars',
198
+ selectionEmpty: 'Nothing selected',
199
+ selectionTooShort: 'Selection too short to trigger',
200
+ answerTitle: 'Follow-up',
201
+ answerStreaming: 'Answering…',
202
+ answerDone: 'Done',
203
+ answerStopped: 'Stopped',
204
+ answerNonStreaming: 'This DSH build publishes no stream frames; the answer arrived at once',
205
+ answerEmpty: '(empty)',
206
+ copy: 'Copy',
207
+ copied: 'Copied',
208
+ copySelectedHint: 'Answer selected — press Ctrl+C to copy',
209
+ copyFailed: 'Copy failed — select the text manually',
210
+ close: 'Close',
211
+ followUp: 'Follow up',
212
+ followUpPlaceholder: 'Ask again…',
213
+ retry: 'Retry',
214
+ sendToMain: 'Ask in main chat',
215
+ cancelAnswer: 'Stop',
216
+ mainSent: 'Sent to the main chat',
217
+ mainSentHint: 'The answer appears in the main conversation and streams there.',
218
+ draftInserted: 'Inserted into the composer',
219
+ draftInsertedHint: 'The main-chat send API is unavailable, so the follow-up was placed in the composer — press Enter.',
220
+ noMainCarrier: 'The main-chat send API is unavailable: neither submitting a message nor writing the composer draft worked',
221
+ errorTitle: 'Answer failed',
222
+ errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
223
+ surfaceFlow: 'Floating card',
224
+ surfaceNative: 'Right rail',
225
+ surfaceBetter: 'Sidebar plugin',
226
+ surfaceFellBack: 'The requested surface is unavailable; fell back to {name}',
227
+ surfaceUnproven: 'The target surface never rendered the card; fell back to the built-in floating card',
228
+ settingsTitle: 'Selection Ask',
229
+ settingsDesc: 'Select text in chat or tasks and ask about it in place.',
230
+ setTrigger: 'Trigger',
231
+ setTriggerSelection: 'Button on selection',
232
+ setTriggerShortcut: 'Shortcut only',
233
+ setTriggerBoth: 'Button + shortcut',
234
+ setCarrier: 'Default answer carrier',
235
+ setSurface: 'Side-card surface',
236
+ setSurfaceAuto: 'Automatic',
237
+ setSurfaceFlow: 'Built-in floating card',
238
+ setMaxChars: 'Max characters',
239
+ setMaxCharsHint: 'Longer selections are truncated and marked in the question',
240
+ setShortcut: 'Shortcut',
241
+ setZones: 'Capture regions',
242
+ setZonesAuto: 'Every region',
243
+ setZonesChat: 'Chat only',
244
+ setZonesTask: 'Tasks only',
245
+ setZonesBoth: 'Chat + tasks',
246
+ setUnclassified: 'Also trigger outside known regions',
247
+ setMinChars: 'Minimum selection length',
248
+ setConcurrency: 'Max concurrent answers',
249
+ setSideTools: 'Side answerer tool access',
250
+ setSideToolsReadonly: 'Read-only (recommended)',
251
+ setSideToolsInherit: 'Inherit from the session',
252
+ save: 'Save',
253
+ saving: 'Saving…',
254
+ saved: 'Saved',
255
+ saveFailed: 'Save failed: {msg}',
256
+ reset: 'Reset to patch config',
257
+ resetDone: 'Reset',
258
+ diagnostics: 'Run diagnostics',
259
+ diagTitle: 'Capability check',
260
+ diagHost: 'Host API',
261
+ diagSide: 'Side answer engine',
262
+ diagProviders: 'Subagent providers',
263
+ diagAgents: 'Live session agents',
264
+ diagSurface: 'Current side surface',
265
+ diagSession: 'Session detected',
266
+ diagZones: 'Zone anchors (data-slot)',
267
+ diagZonesMissing: 'missing — region filtering disabled',
268
+ diagSlots: 'Slot landing',
269
+ toolGuardUnavailable: 'This provider supports no tool allow-list, so the answer inherited the session tools',
270
+ toolGuardInherited: 'Inherits the session tools, as configured',
271
+ diagUnreachable: 'Host API unreachable: {msg}',
272
+ yes: 'yes',
273
+ no: 'no',
274
+ none: 'none',
275
+ unsupportedTrigger: 'Unrecognized shortcut — use a form like Alt+Q / Ctrl+Shift+K',
276
+ surfaceUnavailable: 'The chosen side surface is unavailable',
277
+ },
278
+ }
279
+
280
+ /** The active language id, kept in step with the harness locale service. */
281
+ let lang = (() => {
282
+ const raw = typeof navigator !== 'undefined' && typeof navigator.language === 'string'
283
+ ? navigator.language.toLowerCase()
284
+ : 'zh'
285
+ return raw.startsWith('zh') ? 'zh' : 'en'
286
+ })()
287
+
288
+ /**
289
+ * Translate one key with `{name}` interpolation.
290
+ * @param {string} key - dictionary key.
291
+ * @param {Record<string, unknown>} [vars] - interpolation values.
292
+ */
293
+ function t(key, vars) {
294
+ const table = DICT[lang] ?? DICT.zh
295
+ let text = table[key] ?? DICT.zh[key] ?? key
296
+ if (vars !== undefined) {
297
+ for (const [name, value] of Object.entries(vars)) text = text.split(`{${name}}`).join(String(value))
298
+ }
299
+ return text
300
+ }
301
+
302
+ // ─────────────────────────────────────────────────────────────────────
303
+ // §3 wire client
304
+ // ─────────────────────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * POST JSON to the plugin's Host API.
308
+ * @returns {Promise<unknown>} the `value` field of an `{ok:true}` answer.
309
+ * @throws {Error} with a `code` for every `{ok:false}` or transport failure.
310
+ */
311
+ async function postJson(method, payload) {
312
+ let response
313
+ try {
314
+ response = await fetch(`${API}/${method}`, {
315
+ method: 'POST',
316
+ headers: { 'content-type': 'application/json' },
317
+ body: JSON.stringify(payload ?? {}),
318
+ })
319
+ } catch (error) {
320
+ throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
321
+ }
322
+ let body
323
+ try {
324
+ body = await response.json()
325
+ } catch {
326
+ throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'bad-response' })
327
+ }
328
+ if (body?.ok === true) return body.value
329
+ const error = new Error(body?.error?.message ?? `HTTP ${response.status}`)
330
+ error.code = body?.error?.code ?? 'bad-response'
331
+ throw error
332
+ }
333
+
334
+ /** GET the plugin state (`/state` also answers POST, GET keeps it cacheable-free). */
335
+ async function getState() {
336
+ const response = await fetch(`${API}/state`, { method: 'GET' })
337
+ const body = await response.json()
338
+ if (body?.ok !== true) throw Object.assign(new Error('state failed'), { code: 'bad-response' })
339
+ return body.value
340
+ }
341
+
342
+ /**
343
+ * Open the SSE ask stream and drive callbacks per event.
344
+ * Split out from the React layer so a card owns no transport details.
345
+ * @param {object} payload - the ask request.
346
+ * @param {{onStart: Function, onDelta: Function, onReasoning: Function,
347
+ * onDone: Function, onError: Function, signal: AbortSignal}} handlers
348
+ */
349
+ async function streamAsk(payload, handlers) {
350
+ let response
351
+ try {
352
+ response = await fetch(`${API}/ask`, {
353
+ method: 'POST',
354
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
355
+ body: JSON.stringify(payload),
356
+ signal: handlers.signal,
357
+ })
358
+ } catch (error) {
359
+ if (handlers.signal.aborted) return
360
+ handlers.onError({ code: 'unreachable', message: String(error?.message ?? error) })
361
+ return
362
+ }
363
+ if (!response.ok || response.body === null) {
364
+ handlers.onError({ code: `http-${response.status}`, message: `HTTP ${response.status}` })
365
+ return
366
+ }
367
+ const reader = response.body.getReader()
368
+ const decoder = new TextDecoder()
369
+ let buffer = ''
370
+ let settled = false
371
+ try {
372
+ for (;;) {
373
+ const { value, done } = await reader.read()
374
+ if (done) break
375
+ buffer += decoder.decode(value, { stream: true })
376
+ let cut = buffer.indexOf('\n\n')
377
+ while (cut !== -1) {
378
+ const block = buffer.slice(0, cut)
379
+ buffer = buffer.slice(cut + 2)
380
+ cut = buffer.indexOf('\n\n')
381
+ const parsed = parseSseBlock(block)
382
+ if (parsed === null) continue
383
+ const { event, data } = parsed
384
+ if (event === 'start') handlers.onStart(data)
385
+ else if (event === 'delta') handlers.onDelta(data)
386
+ else if (event === 'reasoning') handlers.onReasoning(data)
387
+ else if (event === 'status') handlers.onStatus?.(data)
388
+ else if (event === 'done') { settled = true; handlers.onDone(data) }
389
+ else if (event === 'error') { settled = true; handlers.onError(data) }
390
+ }
391
+ }
392
+ } catch (error) {
393
+ if (!handlers.signal.aborted && !settled) {
394
+ handlers.onError({ code: 'stream-broken', message: String(error?.message ?? error) })
395
+ }
396
+ return
397
+ }
398
+ if (!settled && !handlers.signal.aborted) {
399
+ handlers.onError({ code: 'stream-ended', message: 'stream ended without a terminal event' })
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Parse one raw SSE block into `{event, data}`.
405
+ * @returns {{event: string, data: unknown}|null} null when unusable.
406
+ */
407
+ function parseSseBlock(block) {
408
+ let event = 'message'
409
+ const dataLines = []
410
+ for (const line of block.split('\n')) {
411
+ if (line.startsWith(':')) continue
412
+ if (line.startsWith('event:')) event = line.slice(6).trim()
413
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
414
+ }
415
+ if (dataLines.length === 0) return null
416
+ try {
417
+ return { event, data: JSON.parse(dataLines.join('\n')) }
418
+ } catch {
419
+ return null
420
+ }
421
+ }
422
+
423
+ // ─────────────────────────────────────────────────────────────────────
424
+ // §4 module store
425
+ // ─────────────────────────────────────────────────────────────────────
426
+
427
+ /** The single observable state every surface reads. */
428
+ const store = {
429
+ state: {
430
+ config: { ...CLIENT_DEFAULTS },
431
+ problems: [],
432
+ capabilities: null,
433
+ provenance: null,
434
+ hostReachable: null,
435
+ surface: 'flow',
436
+ surfaceNote: null,
437
+ sessionId: null,
438
+ trigger: null,
439
+ popover: null,
440
+ cards: [],
441
+ toast: null,
442
+ /** Whether the harness renders the `data-slot` zone anchors (null = unknown yet). */
443
+ zoneAnchors: null,
444
+ /** Which ladder rung each registration actually landed on. */
445
+ slots: {},
446
+ revision: 0,
447
+ },
448
+ listeners: new Set(),
449
+ get() { return this.state },
450
+ set(patch) {
451
+ this.state = { ...this.state, ...patch, revision: this.state.revision + 1 }
452
+ for (const listener of [...this.listeners]) {
453
+ try {
454
+ listener()
455
+ } catch (error) {
456
+ console.error(`[${PLUGIN_ID}] store listener failed:`, error)
457
+ }
458
+ }
459
+ },
460
+ subscribe(listener) {
461
+ this.listeners.add(listener)
462
+ return () => { this.listeners.delete(listener) }
463
+ },
464
+ }
465
+
466
+ /** Subscribe a component to the whole store (small state, one revision). */
467
+ function useStoreState() {
468
+ const [, bump] = React.useReducer(count => count + 1, 0)
469
+ React.useEffect(() => store.subscribe(bump), [])
470
+ return store.state
471
+ }
472
+
473
+ /** Replace one card by id (immutably; a no-op when it is gone). */
474
+ function patchCard(id, patch) {
475
+ const cards = store.state.cards.map(card => (card.id === id ? { ...card, ...patch } : card))
476
+ store.set({ cards })
477
+ return cards.find(card => card.id === id)
478
+ }
479
+
480
+ /** Append text to one card's streaming answer. */
481
+ function appendCardText(id, delta) {
482
+ const cards = store.state.cards.map(card => (
483
+ card.id === id ? { ...card, text: card.text + delta } : card
484
+ ))
485
+ store.set({ cards })
486
+ }
487
+
488
+ /** Append reasoning text to one card. */
489
+ function appendCardReasoning(id, delta) {
490
+ const cards = store.state.cards.map(card => (
491
+ card.id === id ? { ...card, reasoning: card.reasoning + delta } : card
492
+ ))
493
+ store.set({ cards })
494
+ }
495
+
496
+ /** Show a transient toast line. */
497
+ let toastTimer = null
498
+ function toast(message) {
499
+ store.set({ toast: message })
500
+ if (toastTimer !== null) clearTimeout(toastTimer)
501
+ toastTimer = setTimeout(() => { store.set({ toast: null }) }, 2600)
502
+ }
503
+
504
+ // ─────────────────────────────────────────────────────────────────────
505
+ // §5 selection engine
506
+ // ─────────────────────────────────────────────────────────────────────
507
+
508
+ /** Element for a DOM node (the node itself when it is an element). */
509
+ function elementOf(node) {
510
+ if (node === null || node === undefined) return null
511
+ return node.nodeType === 1 ? node : node.parentElement ?? null
512
+ }
513
+
514
+ /** Whether the node lives inside an editable field (composer, rename box…). */
515
+ function isEditable(node) {
516
+ const element = elementOf(node)
517
+ if (element === null) return false
518
+ const tag = element.tagName
519
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
520
+ return element.closest('[contenteditable=""],[contenteditable="true"]') !== null
521
+ }
522
+
523
+ /** Whether the node belongs to this plugin's own UI. */
524
+ function isOurUi(node) {
525
+ const element = elementOf(node)
526
+ return element !== null && element.closest('[data-dsa-root]') !== null
527
+ }
528
+
529
+ /**
530
+ * The `data-slot` chain above a node. The harness slot renderer stamps
531
+ * every outlet with `data-slot="<slot key>"`, so this is the sanctioned
532
+ * way to tell *where* a selection lives without reading any plugin's
533
+ * markup.
534
+ * @param {Node} node - selection anchor or focus node.
535
+ * @returns {string[]} slot keys, nearest first.
536
+ */
537
+ function slotPathOf(node) {
538
+ const keys = []
539
+ let element = elementOf(node)
540
+ while (element !== null) {
541
+ const key = element.getAttribute?.('data-slot')
542
+ if (typeof key === 'string' && key !== '') keys.push(key)
543
+ element = element.parentElement
544
+ }
545
+ return keys
546
+ }
547
+
548
+ /**
549
+ * Classify a selection by its slot chain.
550
+ * @param {string[]} slotPath - nearest-first slot keys.
551
+ * @returns {'chat'|'task'|'other'} the zone.
552
+ */
553
+ function classifyZone(slotPath) {
554
+ for (const key of slotPath) {
555
+ if (ZONE_PATTERNS.chat.some(pattern => pattern.test(key))) return 'chat'
556
+ if (ZONE_PATTERNS.task.some(pattern => pattern.test(key))) return 'task'
557
+ }
558
+ return 'other'
559
+ }
560
+
561
+ /** The rect a trigger should hang off, tolerating multi-block selections. */
562
+ function rectOf(range) {
563
+ let rect = range.getBoundingClientRect()
564
+ const usable = candidate => candidate !== undefined && (candidate.width > 0 || candidate.height > 0)
565
+ if (!usable(rect)) {
566
+ const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : []
567
+ rect = rects.length > 0 ? rects[rects.length - 1] : rect
568
+ }
569
+ return {
570
+ left: rect.left,
571
+ top: rect.top,
572
+ right: rect.right,
573
+ bottom: rect.bottom,
574
+ width: rect.width,
575
+ height: rect.height,
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Read the current DOM selection into a trigger candidate.
581
+ * @returns {{text: string, rect: object, zone: string, cross: boolean,
582
+ * slotPath: string[], kind: 'text'} | null} null when nothing usable.
583
+ */
584
+ function readSelection() {
585
+ if (typeof window === 'undefined' || typeof window.getSelection !== 'function') return null
586
+ const selection = window.getSelection()
587
+ if (selection === null || selection.rangeCount === 0 || selection.isCollapsed === true) return null
588
+ const text = selection.toString()
589
+ if (typeof text !== 'string' || text.trim() === '') return null
590
+ const range = selection.getRangeAt(0)
591
+ const anchorNode = selection.anchorNode
592
+ const focusNode = selection.focusNode ?? anchorNode
593
+ if (isOurUi(anchorNode) || isOurUi(focusNode)) return null
594
+ if (isEditable(anchorNode) || isEditable(focusNode)) return null
595
+ const anchorPath = slotPathOf(anchorNode)
596
+ const focusPath = slotPathOf(focusNode)
597
+ const anchorZone = classifyZone(anchorPath)
598
+ const focusZone = classifyZone(focusPath)
599
+ return {
600
+ text,
601
+ rect: rectOf(range),
602
+ zone: anchorZone,
603
+ cross: anchorZone !== focusZone,
604
+ slotPath: anchorPath,
605
+ kind: 'text',
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Whether a candidate may raise the in-place trigger button.
611
+ * @param {object|null} candidate - a `readSelection()` result.
612
+ * @param {object} config - the effective config.
613
+ * @param {{anchors?: boolean}} [options] - `anchors: false` means this DSH
614
+ * build renders no `data-slot` marker, so the selection cannot be placed
615
+ * in a region at all; the zone filter is then skipped instead of
616
+ * silently suppressing every trigger (the settings page reports why).
617
+ * @returns {{ok: boolean, reason?: string, zoneFiltering?: string}} the decision.
618
+ */
619
+ function shouldOffer(candidate, config, options) {
620
+ if (candidate === null) return { ok: false, reason: 'empty' }
621
+ if (config.trigger === 'shortcut') return { ok: false, reason: 'shortcut-only' }
622
+ if (candidate.text.trim().length < config.minChars) return { ok: false, reason: 'too-short' }
623
+ if (options?.anchors === false) return { ok: true, zoneFiltering: 'unavailable' }
624
+ const zones = config.captureZones
625
+ const zone = candidate.zone
626
+ if (zones === 'chat' && zone !== 'chat') return { ok: false, reason: 'zone' }
627
+ if (zones === 'task' && zone !== 'task') return { ok: false, reason: 'zone' }
628
+ if ((zones === 'chat+task' || zones === 'auto') && zone === 'other' && config.showInUnclassified !== true) {
629
+ return { ok: false, reason: 'zone' }
630
+ }
631
+ return { ok: true }
632
+ }
633
+
634
+ /**
635
+ * A cheap identity for a selection, used to suppress duplicate triggers
636
+ * for the same text at (nearly) the same place.
637
+ */
638
+ function selectionSignature(candidate) {
639
+ const rect = candidate.rect ?? { left: 0, top: 0 }
640
+ return `${candidate.text.length}:${Math.round(rect.left)}:${Math.round(rect.top)}:${candidate.text.slice(0, 24)}`
641
+ }
642
+
643
+ /**
644
+ * Truncate an over-long selection for an outgoing prompt, keeping head and
645
+ * tail. Mirrors the Host's `truncateSelection` (asserted by contract test).
646
+ * @returns {{text: string, truncated: boolean, droppedChars: number}}
647
+ */
648
+ function truncateSelection(text, maxChars) {
649
+ const source = typeof text === 'string' ? text : ''
650
+ if (source.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
651
+ const head = Math.max(1, Math.ceil(maxChars * 0.7))
652
+ const tail = Math.max(0, maxChars - head)
653
+ const dropped = source.length - head - tail
654
+ return {
655
+ text: `${source.slice(0, head)}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? source.slice(source.length - tail) : ''}`,
656
+ truncated: true,
657
+ droppedChars: dropped,
658
+ }
659
+ }
660
+
661
+ /** Parse a `Ctrl+Shift+K`-style accelerator into matcher facts. */
662
+ function parseShortcut(accelerator) {
663
+ if (typeof accelerator !== 'string') return null
664
+ const trimmed = accelerator.trim()
665
+ // A trailing separator ("Alt+") names a modifier as the key: refuse it.
666
+ if (trimmed === '' || trimmed.endsWith('+')) return null
667
+ const parts = trimmed.split('+').map(part => part.trim().toLowerCase()).filter(Boolean)
668
+ const key = parts.pop()
669
+ if (key === undefined || key === '') return null
670
+ const facts = { key, ctrl: false, alt: false, shift: false, meta: false }
671
+ for (const part of parts) {
672
+ if (part === 'ctrl' || part === 'control') facts.ctrl = true
673
+ else if (part === 'alt' || part === 'option') facts.alt = true
674
+ else if (part === 'shift') facts.shift = true
675
+ else if (part === 'meta' || part === 'cmd' || part === 'command' || part === 'win') facts.meta = true
676
+ else return null
677
+ }
678
+ // A bare modifier is not an accelerator either.
679
+ if (['ctrl', 'control', 'alt', 'option', 'shift', 'meta', 'cmd', 'command', 'win'].includes(facts.key)) return null
680
+ return facts
681
+ }
682
+
683
+ /** Whether a keyboard event matches a parsed accelerator. */
684
+ function matchesShortcut(event, facts) {
685
+ if (facts === null) return false
686
+ const key = String(event.key ?? '').toLowerCase()
687
+ if (key !== facts.key) return false
688
+ return event.ctrlKey === facts.ctrl
689
+ && event.altKey === facts.alt
690
+ && event.shiftKey === facts.shift
691
+ && event.metaKey === facts.meta
692
+ }
693
+
694
+ // ─────────────────────────────────────────────────────────────────────
695
+ // §6 carriers
696
+ // ─────────────────────────────────────────────────────────────────────
697
+
698
+ /** Compose the prompt sent to the main conversation. */
699
+ function composeMainPrompt(selection, question, zone, maxChars) {
700
+ const cut = truncateSelection(selection, maxChars)
701
+ const quoted = cut.text.split('\n').map(line => `> ${line}`).join('\n')
702
+ const zoneLabel = t(zone === 'chat' ? 'zoneChat' : zone === 'task' ? 'zoneTask' : 'zoneOther')
703
+ const head = `${quoted}\n`
704
+ const mark = cut.truncated ? `\n(选中文本已截断,省略约 ${cut.droppedChars} 字)` : ''
705
+ return `${head}${mark}\n【来源:${zoneLabel}】\n${question}`
706
+ }
707
+
708
+ /**
709
+ * Send one question into the main conversation.
710
+ *
711
+ * Four rungs, strongest first, each probed at call time — the submit API
712
+ * moved twice inside the supported range (`sessions.retain` before
713
+ * 0.1.6-alpha.2, `sessions.using` from then on), so a version table would
714
+ * rot. Every rung reports which one won, and the caller renders that.
715
+ *
716
+ * @returns {Promise<{mode: 'session'|'draft', via: string}>}
717
+ */
718
+ async function askInMainConversation(input) {
719
+ const text = composeMainPrompt(input.selection, input.question, input.zone, input.maxChars)
720
+ const sessions = input.ctx.get('sessions')
721
+ const sessionId = input.sessionId ?? currentSessionId(input.ctx)
722
+ const parts = [{ type: 'text', text }]
723
+ const accepted = (result) => {
724
+ if (result !== undefined && result !== null && result.ok === false) {
725
+ throw Object.assign(new Error(result.error?.message ?? 'prompt rejected'), { code: 'prompt-rejected' })
726
+ }
727
+ }
728
+
729
+ // Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
730
+ if (sessionId !== null && typeof sessions?.using === 'function') {
731
+ const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
732
+ const face = reference?.binding?.session
733
+ if (face === undefined || typeof face.prompt !== 'function') {
734
+ throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
735
+ }
736
+ return face.prompt(parts, 'queue')
737
+ })
738
+ accepted(result)
739
+ return { mode: 'session', via: 'sessions.using' }
740
+ }
741
+
742
+ // Rung 2 — `retain()` + explicit release: the same operation spelled out,
743
+ // and the only session-face submit path on 0.1.2-rc.1 … 0.1.6-alpha.1.
744
+ let scope
745
+ if (sessionId !== null && typeof sessions?.retain === 'function') {
746
+ const reference = sessions.retain(sessionId, { source: 'gateway' })
747
+ try {
748
+ if (reference?.ready !== undefined) await reference.ready
749
+ const face = reference?.binding?.session
750
+ if (face !== undefined && typeof face.prompt === 'function') {
751
+ accepted(await face.prompt(parts, 'queue'))
752
+ return { mode: 'session', via: 'sessions.retain' }
753
+ }
754
+ } catch (error) {
755
+ if (error?.code === 'prompt-rejected') throw error
756
+ console.warn(`[${PLUGIN_ID}] sessions.retain 提交失败,尝试下一级:`, error)
757
+ } finally {
758
+ try {
759
+ reference?.release?.()
760
+ } catch { /* already released */ }
761
+ }
762
+ }
763
+
764
+ // Rung 3 — the composer's own action face. A session-scoped slot
765
+ // component receives `inputActions` as a standard prop (the composer tool
766
+ // row is where this plugin collects the session id), so the harness
767
+ // itself hands us the sanctioned draft+submit pair.
768
+ const captured = composerFace()
769
+ if (captured !== null) {
770
+ const { actions } = captured
771
+ try {
772
+ if (typeof actions.setDraft === 'function') actions.setDraft(text)
773
+ const submit = [actions.submit, actions.send, actions.submitPrompt]
774
+ .find(candidate => typeof candidate === 'function')
775
+ if (submit !== undefined) {
776
+ await submit.call(actions)
777
+ return { mode: 'session', via: 'inputActions.submit' }
778
+ }
779
+ } catch (error) {
780
+ console.warn(`[${PLUGIN_ID}] inputActions 提交失败,回退到写入输入框:`, error)
781
+ }
782
+ }
783
+
784
+ // Rung 4 — write the draft and let the user press Enter.
785
+ //
786
+ // PLACEHOLDER: composer-draft — `ctx.get('conversation').input.for(scope)`
787
+ // is the harness's own composer draft face; it is not in the published
788
+ // client service catalog, so it is last and probed defensively (the
789
+ // supported replacement is `inputActions` above; see README §6).
790
+ const conversation = input.ctx.get('conversation')
791
+ if (scope === undefined && sessions !== undefined && typeof sessions.scope === 'function' && sessionId !== null) {
792
+ scope = sessions.scope(sessionId)
793
+ }
794
+ if (conversation?.input?.for !== undefined && scope !== undefined) {
795
+ const reactor = conversation.input.for(scope)
796
+ const draft = reactor.state?.getSnapshot?.().draft ?? ''
797
+ reactor.setDraft(draft.trim() === '' ? text : `${draft}\n\n${text}`)
798
+ return { mode: 'draft', via: 'composer.draft' }
799
+ }
800
+ // PLACEHOLDER: clipboard-fallback — nothing could carry the question.
801
+ // The caller renders this message on the card; replace this throw when a
802
+ // supported send API appears (see README §6).
803
+ throw Object.assign(new Error(t('noMainCarrier')), { code: 'no-main-carrier' })
804
+ }
805
+
806
+ /** Best-effort active session id from every sanctioned source. */
807
+ function currentSessionId(ctx) {
808
+ const captured = store.state.sessionId
809
+ if (typeof captured === 'string' && captured !== '') return captured
810
+ try {
811
+ const better = ctx.get('betterSidebar')
812
+ const id = better?.getSnapshot?.()?.sessionId
813
+ if (typeof id === 'string' && id !== '') return id
814
+ } catch { /* the service is optional */ }
815
+ try {
816
+ const mounted = ctx.get('sidebarRight')?.mounted
817
+ const id = typeof mounted?.getSnapshot === 'function' ? mounted.getSnapshot() : undefined
818
+ if (typeof id === 'string' && id !== '') return id
819
+ } catch { /* the service is optional */ }
820
+ return null
821
+ }
822
+
823
+ // ─────────────────────────────────────────────────────────────────────
824
+ // §7 side-card surfaces
825
+ // ─────────────────────────────────────────────────────────────────────
826
+
827
+ /**
828
+ * Adapter registry. Each adapter answers two questions — "can you host a
829
+ * card now?" and "put this card there" — so `auto` never has to guess.
830
+ * Order matters: the native right rail is the shipped surface, the
831
+ * better-sidebar plugin is an optional third party, and the built-in flow
832
+ * card is the guaranteed fallback that needs nothing at all.
833
+ */
834
+ const surfaces = {
835
+ native: {
836
+ kind: 'native-rightbar',
837
+ label: () => t('surfaceNative'),
838
+ registry: null,
839
+ controller: null,
840
+ error: null,
841
+ dispose: null,
842
+ available() {
843
+ return this.registry !== null && this.controller !== null && this.error === null
844
+ },
845
+ open(card) {
846
+ if (!this.available() || typeof this.controller.openTab !== 'function') return false
847
+ this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
848
+ return true
849
+ },
850
+ close() {
851
+ // The right rail owns its tab lifecycle; the store entry is enough.
852
+ return true
853
+ },
854
+ },
855
+ better: {
856
+ kind: 'better-sidebar',
857
+ label: () => t('surfaceBetter'),
858
+ service: null,
859
+ error: null,
860
+ dispose: null,
861
+ features: [],
862
+ available() {
863
+ return this.service !== null && this.error === null
864
+ },
865
+ open(card) {
866
+ if (!this.available() || typeof this.service.openTab !== 'function') return false
867
+ const tabId = `${CARD_KIND}:${card.id}`
868
+ this.service.openTab(
869
+ { type: CARD_KIND, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
870
+ store.state.sessionId === null ? undefined : { sessionId: store.state.sessionId },
871
+ )
872
+ return true
873
+ },
874
+ close(card) {
875
+ if (!this.available() || typeof this.service.closeTab !== 'function') return false
876
+ this.service.closeTab(`${CARD_KIND}:${card.id}`)
877
+ return true
878
+ },
879
+ },
880
+ flow: {
881
+ kind: 'flow',
882
+ label: () => t('surfaceFlow'),
883
+ available() { return true },
884
+ open() { return true },
885
+ close() { return true },
886
+ },
887
+ }
888
+
889
+ /**
890
+ * The composer action face captured from a session-scoped slot entry
891
+ * (`inputActions`), or null before any such entry has rendered.
892
+ */
893
+ let capturedComposerActions = null
894
+
895
+ /**
896
+ * The card id an external host is CURRENTLY rendering. `CardHost` sets it
897
+ * on mount and clears it on unmount, which is what turns "the adapter
898
+ * accepted the open" into "the user can actually see the card": a host
899
+ * that swallows the open (kind mismatch, collapsed panel, an older plugin
900
+ * version) is detected and the card falls back to the flow stack.
901
+ */
902
+ let renderedCardId = null
903
+
904
+ /**
905
+ * Pick the surface for a new card.
906
+ * @param {string} wanted - `auto` | adapter kind.
907
+ * @returns {{adapter: object, fellBack: boolean}}
908
+ */
909
+ function pickSurface(wanted) {
910
+ const order = [surfaces.native, surfaces.better, surfaces.flow]
911
+ if (wanted === 'auto' || wanted === undefined) {
912
+ for (const adapter of order) if (adapter.available()) return { adapter, fellBack: false }
913
+ return { adapter: surfaces.flow, fellBack: false }
914
+ }
915
+ const match = order.find(adapter => adapter.kind === wanted || adapter === surfaces[wanted])
916
+ if (match === undefined) return { adapter: surfaces.flow, fellBack: true }
917
+ if (!match.available()) return { adapter: surfaces.flow, fellBack: true }
918
+ return { adapter: match, fellBack: false }
919
+ }
920
+
921
+ // ─────────────────────────────────────────────────────────────────────
922
+ // §8 view helpers
923
+ // ─────────────────────────────────────────────────────────────────────
924
+
925
+ /** Theme-token-only stylesheet. Every class is prefixed `dsa-`. */
926
+ const CSS_TEXT = `
927
+ .dsa-layer{pointer-events:none;position:relative;z-index:60}
928
+ .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
929
+ padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
930
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
931
+ font-size:12px;line-height:16px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18);font-family:inherit}
932
+ .dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
933
+ .dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
934
+ .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
935
+ padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
936
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
937
+ box-shadow:0 10px 30px rgba(0,0,0,.24);font-size:13px;line-height:19px}
938
+ .dsa-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
939
+ .dsa-title{font-weight:600;font-size:13px}
940
+ .dsa-quote{margin:0;padding:6px 8px;max-height:96px;overflow:auto;white-space:pre-wrap;word-break:break-word;
941
+ border-left:3px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));border-radius:4px;
942
+ background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.08));color:var(--dsw-alias-label-secondary, #444);
943
+ font-size:12px;line-height:17px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
944
+ .dsa-badge{padding:1px 6px;border-radius:999px;font-size:11px;line-height:16px;
945
+ border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));color:var(--dsw-alias-label-secondary, #555)}
946
+ .dsa-badge-warn{border-color:var(--dsw-alias-state-warn-primary, #b8860b);color:var(--dsw-alias-state-warn-primary, #b8860b)}
947
+ .dsa-textarea{width:100%;box-sizing:border-box;min-height:64px;max-height:180px;resize:vertical;padding:8px;
948
+ border-radius:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
949
+ background:var(--dsw-alias-bg-layer-1, transparent);color:inherit;font:inherit;font-size:13px}
950
+ .dsa-textarea:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary, #4c8dff)}
951
+ .dsa-btn{pointer-events:auto;padding:4px 10px;border-radius:8px;cursor:pointer;font:inherit;font-size:12px;
952
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
953
+ background:var(--dsw-alias-bg-layer-1, transparent);color:var(--dsw-alias-label-primary, #111)}
954
+ .dsa-btn:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
955
+ .dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
956
+ color:#fff;font-weight:500}
957
+ .dsa-btn:disabled{opacity:.55;cursor:default}
958
+ .dsa-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;overflow:hidden}
959
+ .dsa-seg button{padding:3px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary, #555);
960
+ font:inherit;font-size:12px;cursor:pointer}
961
+ .dsa-seg button[data-on="true"]{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14));
962
+ color:var(--dsw-alias-label-primary, #111);font-weight:500}
963
+ .dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
964
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
965
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
966
+ box-shadow:0 10px 30px rgba(0,0,0,.22);font-size:13px;line-height:19px}
967
+ .dsa-stack{pointer-events:none;position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;
968
+ width:min(400px, 92vw);max-height:70vh;overflow:auto}
969
+ .dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
970
+ .dsa-answer .dsa-p{margin:0 0 6px}
971
+ .dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
972
+ .dsa-answer .dsa-h{font-weight:600;margin:0 0 6px}
973
+ .dsa-answer .dsa-gap{height:4px}
974
+ .dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
975
+ background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
976
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
977
+ .dsa-code{padding:0 4px;border-radius:4px;background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));
978
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
979
+ .dsa-muted{color:var(--dsw-alias-label-secondary, #666);font-size:11px;line-height:16px}
980
+ .dsa-error{color:var(--dsw-alias-state-error-primary, #c0392b);font-size:12px}
981
+ .dsa-toast{pointer-events:auto;position:fixed;left:50%;transform:translateX(-50%);bottom:24px;padding:6px 12px;
982
+ border-radius:999px;font-size:12px;background:var(--dsw-alias-bg-overlay, #222);
983
+ color:var(--dsw-alias-label-primary, #eee);border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3))}
984
+ .dsa-settings{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary, #111)}
985
+ .dsa-field{display:flex;flex-direction:column;gap:4px}
986
+ .dsa-field > label{font-size:12px;color:var(--dsw-alias-label-secondary, #555)}
987
+ .dsa-input{padding:5px 8px;border-radius:8px;font:inherit;font-size:13px;box-sizing:border-box;
988
+ border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
989
+ background:var(--dsw-alias-bg-layer-1, transparent);color:inherit}
990
+ .dsa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
991
+ .dsa-check{display:flex;align-items:center;gap:6px;font-size:12px}
992
+ .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
993
+ color:var(--dsw-alias-label-secondary, #555)}
994
+ `
995
+
996
+ /** Render the stylesheet as a React element (removed with its component). */
997
+ function Styles() {
998
+ return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
999
+ }
1000
+
1001
+ /** Inline markdown-lite: `code` and **bold** only — never innerHTML. */
1002
+ function inlineNodes(text, key) {
1003
+ const out = []
1004
+ const pattern = /(`[^`]+`|\*\*[^*]+\*\*)/g
1005
+ let last = 0
1006
+ let match
1007
+ let index = 0
1008
+ while ((match = pattern.exec(text)) !== null) {
1009
+ if (match.index > last) out.push(text.slice(last, match.index))
1010
+ const token = match[0]
1011
+ if (token.startsWith('`')) {
1012
+ out.push(h('code', { key: `c${key}-${index++}`, className: 'dsa-code' }, token.slice(1, -1)))
1013
+ } else {
1014
+ out.push(h('strong', { key: `b${key}-${index++}` }, token.slice(2, -2)))
1015
+ }
1016
+ last = match.index + token.length
1017
+ }
1018
+ if (last < text.length) out.push(text.slice(last))
1019
+ return out
1020
+ }
1021
+
1022
+ /**
1023
+ * Render answer text as React nodes: fenced code, lists, headings,
1024
+ * paragraphs. No HTML injection anywhere, so model output cannot become
1025
+ * markup.
1026
+ */
1027
+ function renderRichText(text) {
1028
+ const lines = String(text ?? '').split('\n')
1029
+ const nodes = []
1030
+ let i = 0
1031
+ let key = 0
1032
+ const isBullet = line => /^\s*([-*+]|\d+\.)\s+/.test(line)
1033
+ const isHeading = line => /^#{1,6}\s+/.test(line)
1034
+ while (i < lines.length) {
1035
+ const line = lines[i]
1036
+ if (/^```/.test(line)) {
1037
+ const body = []
1038
+ i += 1
1039
+ while (i < lines.length && !/^```/.test(lines[i])) {
1040
+ body.push(lines[i])
1041
+ i += 1
1042
+ }
1043
+ i += 1
1044
+ nodes.push(h('pre', { key: `f${key++}`, className: 'dsa-pre' }, h('code', null, body.join('\n'))))
1045
+ continue
1046
+ }
1047
+ if (isBullet(line)) {
1048
+ const items = []
1049
+ while (i < lines.length && isBullet(lines[i])) {
1050
+ items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ''))
1051
+ i += 1
1052
+ }
1053
+ nodes.push(h('ul', { key: `u${key++}`, className: 'dsa-ul' },
1054
+ items.map((item, index) => h('li', { key: index }, inlineNodes(item, `${key}-${index}`)))))
1055
+ continue
1056
+ }
1057
+ if (isHeading(line)) {
1058
+ nodes.push(h('div', { key: `h${key}`, className: 'dsa-h' }, inlineNodes(line.replace(/^#{1,6}\s+/, ''), key)))
1059
+ key += 1
1060
+ i += 1
1061
+ continue
1062
+ }
1063
+ if (line.trim() === '') {
1064
+ nodes.push(h('div', { key: `g${key++}`, className: 'dsa-gap' }))
1065
+ i += 1
1066
+ continue
1067
+ }
1068
+ const paragraph = []
1069
+ while (
1070
+ i < lines.length
1071
+ && lines[i].trim() !== ''
1072
+ && !/^```/.test(lines[i])
1073
+ && !isBullet(lines[i])
1074
+ && !isHeading(lines[i])
1075
+ ) {
1076
+ paragraph.push(lines[i])
1077
+ i += 1
1078
+ }
1079
+ nodes.push(h('p', { key: `p${key}`, className: 'dsa-p' }, inlineNodes(paragraph.join('\n'), key)))
1080
+ key += 1
1081
+ }
1082
+ return nodes
1083
+ }
1084
+
1085
+ /** Clamp a fixed-position box into the viewport. */
1086
+ function clampBox(rect, width, height, prefer = 'below') {
1087
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
1088
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 768
1089
+ let left = rect.right + 8
1090
+ if (left + width > vw - 8) left = Math.max(8, Math.min(vw - width - 8, rect.left - 8 - width))
1091
+ let top = prefer === 'below' ? rect.bottom + 6 : rect.top - height - 6
1092
+ if (top + height > vh - 8) top = Math.max(8, vh - height - 8)
1093
+ if (top < 8) top = 8
1094
+ return { left: Math.round(left), top: Math.round(top) }
1095
+ }
1096
+
1097
+ /**
1098
+ * Copy text to the clipboard.
1099
+ *
1100
+ * Only the async Clipboard API is used: this plugin never inserts an
1101
+ * element into `document.body` (a hidden textarea is how the legacy
1102
+ * `execCommand('copy')` path is usually built, and it would put DOM outside
1103
+ * the component tree). When the Clipboard API is unavailable, the rendered
1104
+ * answer node is SELECTED instead, so the user can finish with Ctrl+C.
1105
+ *
1106
+ * @param {string} text - the text to copy.
1107
+ * @param {Element|null} [fallbackElement] - the rendered node to select.
1108
+ * @returns {Promise<'copied'|'selected'|'failed'>}
1109
+ */
1110
+ async function copyText(text, fallbackElement) {
1111
+ try {
1112
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
1113
+ await navigator.clipboard.writeText(text)
1114
+ return 'copied'
1115
+ }
1116
+ } catch { /* fall through to the selection fallback */ }
1117
+ try {
1118
+ if (fallbackElement !== null && fallbackElement !== undefined && typeof window !== 'undefined') {
1119
+ const range = document.createRange()
1120
+ range.selectNodeContents(fallbackElement)
1121
+ const selection = window.getSelection()
1122
+ selection.removeAllRanges()
1123
+ selection.addRange(range)
1124
+ return 'selected'
1125
+ }
1126
+ } catch { /* report the failure below */ }
1127
+ return 'failed'
1128
+ }
1129
+
1130
+ /** The zone badge label for a candidate. */
1131
+ function zoneLabel(candidate) {
1132
+ if (candidate.cross) return t('zoneCross')
1133
+ if (candidate.zone === 'chat') return t('zoneChat')
1134
+ if (candidate.zone === 'task') return t('zoneTask')
1135
+ return t('zoneOther')
1136
+ }
1137
+
1138
+ // ─────────────────────────────────────────────────────────────────────
1139
+ // §9 components
1140
+ // ─────────────────────────────────────────────────────────────────────
1141
+
1142
+ /** The in-place trigger button floating at the selection. */
1143
+ function TriggerButton({ candidate, onOpen }) {
1144
+ const box = clampBox(candidate.rect, 132, 30, 'below')
1145
+ return h('button', {
1146
+ type: 'button',
1147
+ className: 'dsa-trigger',
1148
+ style: { left: `${box.left}px`, top: `${box.top}px` },
1149
+ title: t('triggerHint'),
1150
+ 'aria-label': `${t('triggerLabel')}:${zoneLabel(candidate)}`,
1151
+ onMouseDown: (event) => { event.preventDefault() },
1152
+ onClick: (event) => {
1153
+ event.preventDefault()
1154
+ event.stopPropagation()
1155
+ onOpen()
1156
+ },
1157
+ }, h('span', { 'aria-hidden': true }, '💬'), h('span', null, t('triggerLabel')))
1158
+ }
1159
+
1160
+ /** The question popover. */
1161
+ function AskPopover({ candidate, config, onClose, onSubmit }) {
1162
+ const [question, setQuestion] = React.useState('')
1163
+ const [carrier, setCarrier] = React.useState(config.defaultCarrier)
1164
+ const [busy, setBusy] = React.useState(false)
1165
+ const areaRef = React.useRef(null)
1166
+ const cut = truncateSelection(candidate.text, config.maxChars)
1167
+ const box = clampBox(candidate.rect, 420, 260, 'below')
1168
+
1169
+ React.useEffect(() => {
1170
+ const area = areaRef.current
1171
+ if (area !== null) area.focus()
1172
+ }, [])
1173
+
1174
+ const submit = async () => {
1175
+ const text = question.trim()
1176
+ if (text === '' || busy) return
1177
+ setBusy(true)
1178
+ try {
1179
+ await onSubmit({ question: text, carrier })
1180
+ } finally {
1181
+ setBusy(false)
1182
+ }
1183
+ }
1184
+
1185
+ return h('div', {
1186
+ className: 'dsa-pop',
1187
+ style: { left: `${box.left}px`, top: `${box.top}px` },
1188
+ role: 'dialog',
1189
+ 'aria-label': t('askTitle'),
1190
+ onMouseDown: event => event.stopPropagation(),
1191
+ onKeyDown: (event) => {
1192
+ if (event.key === 'Escape') {
1193
+ event.stopPropagation()
1194
+ onClose()
1195
+ return
1196
+ }
1197
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
1198
+ event.preventDefault()
1199
+ void submit()
1200
+ }
1201
+ },
1202
+ },
1203
+ h('div', { className: 'dsa-row' },
1204
+ h('span', { className: 'dsa-title' }, t('askTitle')),
1205
+ h('span', { className: 'dsa-badge' }, zoneLabel(candidate)),
1206
+ h('span', { className: 'dsa-badge' }, t('chars', { n: candidate.text.length })),
1207
+ cut.truncated
1208
+ ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: cut.droppedChars }))
1209
+ : null),
1210
+ h('pre', { className: 'dsa-quote' }, candidate.text.length > 400 ? `${candidate.text.slice(0, 400)}…` : candidate.text),
1211
+ h('textarea', {
1212
+ ref: areaRef,
1213
+ className: 'dsa-textarea',
1214
+ value: question,
1215
+ placeholder: t('askPlaceholder'),
1216
+ onChange: event => setQuestion(event.target.value),
1217
+ }),
1218
+ h('div', { className: 'dsa-row' },
1219
+ h('span', { className: 'dsa-muted' }, t('carrier')),
1220
+ h('div', { className: 'dsa-seg' },
1221
+ h('button', {
1222
+ type: 'button',
1223
+ 'data-on': String(carrier === 'main'),
1224
+ title: t('carrierMainHint'),
1225
+ onClick: () => setCarrier('main'),
1226
+ }, t('carrierMain')),
1227
+ h('button', {
1228
+ type: 'button',
1229
+ 'data-on': String(carrier === 'side'),
1230
+ title: t('carrierSideHint'),
1231
+ onClick: () => setCarrier('side'),
1232
+ }, t('carrierSide')))),
1233
+ h('div', { className: 'dsa-row', style: { justifyContent: 'flex-end' } },
1234
+ h('span', { className: 'dsa-muted', style: { marginRight: 'auto' } },
1235
+ t('askShortcutHint', { key: config.shortcut })),
1236
+ h('button', { type: 'button', className: 'dsa-btn', onClick: onClose }, t('askCancel')),
1237
+ h('button', {
1238
+ type: 'button',
1239
+ className: 'dsa-btn dsa-btn-primary',
1240
+ disabled: busy || question.trim() === '',
1241
+ onClick: () => { void submit() },
1242
+ }, t('askSubmit'))))
1243
+ }
1244
+
1245
+ /** The streamed answer card body, shared by every surface. */
1246
+ function AnswerBody({ card, containerRef }) {
1247
+ if (card.error !== null) {
1248
+ const engineMissing = card.error.code === 'no-side-engine' || card.error.code === 'no-parent'
1249
+ return h('div', { className: 'dsa-error' },
1250
+ h('div', { className: 'dsa-title' }, t('errorTitle')),
1251
+ h('div', null, card.error.message ?? String(card.error.code ?? '')),
1252
+ engineMissing ? h('div', { className: 'dsa-muted' }, t('errorNoSideEngine')) : null)
1253
+ }
1254
+ if (card.text === '' && card.status === 'streaming') {
1255
+ return h('div', { className: 'dsa-muted' }, t('answerStreaming'))
1256
+ }
1257
+ if (card.text === '' && card.status === 'done') {
1258
+ return h('div', { className: 'dsa-muted' }, t('answerEmpty'))
1259
+ }
1260
+ return h('div', { className: 'dsa-answer', ref: containerRef }, renderRichText(card.text))
1261
+ }
1262
+
1263
+ /** Card footer: status, copy, follow-up, retry, close. */
1264
+ function CardActions({ card, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
1265
+ const terminal = card.status === 'done' || card.status === 'error' || card.status === 'stopped'
1266
+ return h('div', { className: 'dsa-row' },
1267
+ h('span', { className: 'dsa-muted' },
1268
+ card.status === 'streaming' ? t('answerStreaming')
1269
+ : card.status === 'done' ? (card.streaming === false ? t('answerNonStreaming') : t('answerDone'))
1270
+ : card.status === 'stopped' ? t('answerStopped') : t('answerTitle')),
1271
+ card.status === 'streaming'
1272
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onCancel }, t('cancelAnswer'))
1273
+ : null,
1274
+ terminal && card.status !== 'done'
1275
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetry }, t('retry'))
1276
+ : null,
1277
+ card.status === 'error' && card.carrier === 'side'
1278
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onSendToMain }, t('sendToMain'))
1279
+ : null,
1280
+ terminal && card.text !== ''
1281
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onCopy }, t('copy'))
1282
+ : null,
1283
+ terminal
1284
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onFollowUp }, t('followUp'))
1285
+ : null,
1286
+ h('button', {
1287
+ type: 'button',
1288
+ className: 'dsa-btn',
1289
+ style: { marginLeft: 'auto' },
1290
+ onClick: onClose,
1291
+ }, t('close')))
1292
+ }
1293
+
1294
+ /** One answer card; `embedded` is true inside a host's tab. */
1295
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
1296
+ const [followOpen, setFollowOpen] = React.useState(false)
1297
+ const [draft, setDraft] = React.useState('')
1298
+ const answerRef = React.useRef(null)
1299
+ const config = store.state.config
1300
+ return h('section', { className: 'dsa-card', 'aria-label': t('answerTitle') },
1301
+ h('div', { className: 'dsa-row' },
1302
+ h('span', { className: 'dsa-title' }, t('answerTitle')),
1303
+ h('span', { className: 'dsa-badge' }, zoneLabel(card)),
1304
+ surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
1305
+ card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null),
1306
+ h('pre', { className: 'dsa-quote' }, card.question),
1307
+ h(AnswerBody, { card, containerRef: answerRef }),
1308
+ card.toolFilter === 'unsupported'
1309
+ ? h('div', { className: 'dsa-muted' }, t('toolGuardUnavailable'))
1310
+ : (card.toolFilter === 'inherit' ? h('div', { className: 'dsa-muted' }, t('toolGuardInherited')) : null),
1311
+ card.carrier === 'main' && card.status === 'done'
1312
+ ? h('div', { className: 'dsa-muted' }, t('mainSentHint'))
1313
+ : null,
1314
+ followOpen
1315
+ ? h('div', { className: 'dsa-field' },
1316
+ h('textarea', {
1317
+ className: 'dsa-textarea',
1318
+ value: draft,
1319
+ placeholder: t('followUpPlaceholder'),
1320
+ autoFocus: true,
1321
+ onChange: event => setDraft(event.target.value),
1322
+ onKeyDown: (event) => {
1323
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
1324
+ event.preventDefault()
1325
+ const text = draft.trim()
1326
+ if (text === '') return
1327
+ setDraft('')
1328
+ setFollowOpen(false)
1329
+ onFollowUp(text)
1330
+ }
1331
+ },
1332
+ }))
1333
+ : null,
1334
+ h(CardActions, {
1335
+ card,
1336
+ onCopy: () => onCopy(answerRef.current),
1337
+ onRetry,
1338
+ onCancel,
1339
+ onSendToMain,
1340
+ onFollowUp: () => setFollowOpen(open => !open),
1341
+ onClose,
1342
+ }),
1343
+ surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
1344
+ }
1345
+
1346
+ /** The flow surface: a fixed stack of cards at the frame's bottom-right. */
1347
+ function FlowStack({ cards, actions }) {
1348
+ const visible = cards.filter(card => card.surface === 'flow' && card.open)
1349
+ if (visible.length === 0) return null
1350
+ return h('div', { className: 'dsa-stack', 'data-dsa-root': '' },
1351
+ visible.map(card => h(AnswerCard, {
1352
+ key: card.id,
1353
+ card,
1354
+ surfaceLabel: null,
1355
+ onCopy: element => actions.copy(card, element),
1356
+ onFollowUp: text => actions.followUp(card, text),
1357
+ onRetry: () => actions.retry(card),
1358
+ onCancel: () => actions.cancel(card),
1359
+ onSendToMain: () => actions.sendToMain(card),
1360
+ onClose: () => actions.close(card),
1361
+ })))
1362
+ }
1363
+
1364
+ /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
1365
+ function OverlayRoot({ actions }) {
1366
+ const state = useStoreState()
1367
+ const config = state.config
1368
+ const candidate = state.trigger
1369
+ const popover = state.popover
1370
+ return h('div', { className: 'dsa-layer', 'data-dsa-root': '' },
1371
+ h(Styles, null),
1372
+ candidate !== null && popover === null && config.trigger !== 'shortcut'
1373
+ ? h(TriggerButton, { candidate, onOpen: () => actions.openPopover(candidate) })
1374
+ : null,
1375
+ popover !== null
1376
+ ? h(AskPopover, {
1377
+ candidate: popover.candidate,
1378
+ config,
1379
+ onClose: () => actions.closePopover(),
1380
+ onSubmit: payload => actions.submit(popover.candidate, payload),
1381
+ })
1382
+ : null,
1383
+ h(FlowStack, { cards: state.cards, actions }),
1384
+ state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
1385
+ }
1386
+
1387
+ /** The settings page (`settings.section`). */
1388
+ function SettingsSection({ actions }) {
1389
+ const state = useStoreState()
1390
+ const [draft, setDraft] = React.useState(state.config)
1391
+ const [status, setStatus] = React.useState('idle')
1392
+ const [diag, setDiag] = React.useState(null)
1393
+ const config = state.config
1394
+
1395
+ React.useEffect(() => { setDraft(config) }, [config])
1396
+
1397
+ const field = (label, control, hint) => h('div', { className: 'dsa-field' },
1398
+ h('label', null, label),
1399
+ control,
1400
+ hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
1401
+
1402
+ const select = (key, options) => h('select', {
1403
+ className: 'dsa-input',
1404
+ value: String(draft[key]),
1405
+ onChange: event => setDraft({ ...draft, [key]: event.target.value }),
1406
+ }, options.map(([value, label]) => h('option', { key: value, value }, label)))
1407
+
1408
+ const number = (key, min, max) => h('input', {
1409
+ className: 'dsa-input',
1410
+ type: 'number',
1411
+ min,
1412
+ max,
1413
+ value: String(draft[key]),
1414
+ onChange: event => setDraft({ ...draft, [key]: Number(event.target.value) }),
1415
+ })
1416
+
1417
+ const save = async () => {
1418
+ if (parseShortcut(draft.shortcut) === null) {
1419
+ setStatus(`error:${t('unsupportedTrigger')}`)
1420
+ return
1421
+ }
1422
+ setStatus('saving')
1423
+ try {
1424
+ await actions.saveConfig(draft)
1425
+ setStatus('saved')
1426
+ } catch (error) {
1427
+ setStatus(`error:${t('saveFailed', { msg: error?.message ?? error })}`)
1428
+ }
1429
+ }
1430
+
1431
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
1432
+ h(Styles, null),
1433
+ h('div', null,
1434
+ h('div', { className: 'dsa-title' }, t('settingsTitle')),
1435
+ h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
1436
+ h('div', { className: 'dsa-grid' },
1437
+ field(t('setTrigger'), select('trigger', [
1438
+ ['selection', t('setTriggerSelection')],
1439
+ ['shortcut', t('setTriggerShortcut')],
1440
+ ['both', t('setTriggerBoth')],
1441
+ ])),
1442
+ field(t('setCarrier'), select('defaultCarrier', [
1443
+ ['side', t('carrierSide')],
1444
+ ['main', t('carrierMain')],
1445
+ ])),
1446
+ field(t('setSurface'), select('sideSurface', [
1447
+ ['auto', t('setSurfaceAuto')],
1448
+ ['flow', t('setSurfaceFlow')],
1449
+ ['native-rightbar', t('surfaceNative')],
1450
+ ['better-sidebar', t('surfaceBetter')],
1451
+ ])),
1452
+ field(t('setZones'), select('captureZones', [
1453
+ ['auto', t('setZonesAuto')],
1454
+ ['chat', t('setZonesChat')],
1455
+ ['task', t('setZonesTask')],
1456
+ ['chat+task', t('setZonesBoth')],
1457
+ ])),
1458
+ field(t('setMaxChars'), number('maxChars', 200, 60000), t('setMaxCharsHint')),
1459
+ field(t('setMinChars'), number('minChars', 0, 200)),
1460
+ field(t('setConcurrency'), number('maxConcurrentAsks', 1, 12)),
1461
+ field(t('setShortcut'), h('input', {
1462
+ className: 'dsa-input',
1463
+ value: String(draft.shortcut),
1464
+ onChange: event => setDraft({ ...draft, shortcut: event.target.value }),
1465
+ })),
1466
+ field(t('setSideTools'), select('sideTools', [
1467
+ ['readonly', t('setSideToolsReadonly')],
1468
+ ['inherit', t('setSideToolsInherit')],
1469
+ ]))),
1470
+ h('label', { className: 'dsa-check' },
1471
+ h('input', {
1472
+ type: 'checkbox',
1473
+ checked: draft.showInUnclassified === true,
1474
+ onChange: event => setDraft({ ...draft, showInUnclassified: event.target.checked }),
1475
+ }),
1476
+ t('setUnclassified')),
1477
+ h('div', { className: 'dsa-row' },
1478
+ h('button', {
1479
+ type: 'button',
1480
+ className: 'dsa-btn dsa-btn-primary',
1481
+ disabled: status === 'saving',
1482
+ onClick: () => { void save() },
1483
+ }, status === 'saving' ? t('saving') : t('save')),
1484
+ h('button', {
1485
+ type: 'button',
1486
+ className: 'dsa-btn',
1487
+ onClick: async () => {
1488
+ try {
1489
+ await actions.resetConfig()
1490
+ setStatus('saved')
1491
+ } catch (error) {
1492
+ setStatus(`error:${String(error?.message ?? error)}`)
1493
+ }
1494
+ },
1495
+ }, t('reset')),
1496
+ h('button', {
1497
+ type: 'button',
1498
+ className: 'dsa-btn',
1499
+ onClick: async () => {
1500
+ try {
1501
+ const value = await actions.diagnose()
1502
+ setDiag(value)
1503
+ } catch (error) {
1504
+ setDiag({ unreachable: String(error?.message ?? error) })
1505
+ }
1506
+ },
1507
+ }, t('diagnostics')),
1508
+ h('span', { className: 'dsa-muted' },
1509
+ status === 'saved' ? t('saved')
1510
+ : status === 'idle' ? ''
1511
+ : status === 'saving' ? t('saving') : status.replace(/^error:/, ''))),
1512
+ diag !== null
1513
+ ? h('div', { className: 'dsa-diag' },
1514
+ h('div', { className: 'dsa-title' }, t('diagTitle')),
1515
+ diag.unreachable !== undefined
1516
+ ? h('div', { className: 'dsa-error' }, t('diagUnreachable', { msg: diag.unreachable }))
1517
+ : [
1518
+ h('div', { key: 'host' }, `${t('diagHost')}: ${t('yes')} · v${diag.version}`),
1519
+ h('div', { key: 'side' }, `${t('diagSide')}: ${diag.capabilities?.sideEngine ? t('yes') : t('no')}`),
1520
+ h('div', { key: 'prov' }, `${t('diagProviders')}: ${(diag.capabilities?.sideProviders ?? []).join(', ') || t('none')}`),
1521
+ h('div', { key: 'agents' }, `${t('diagAgents')}: ${diag.capabilities?.liveAgents ?? 0}`),
1522
+ h('div', { key: 'surface' }, `${t('diagSurface')}: ${state.surface}`),
1523
+ h('div', { key: 'session' }, `${t('diagSession')}: ${state.sessionId ?? t('none')}`),
1524
+ h('div', { key: 'zones' }, `${t('diagZones')}: ${state.zoneAnchors === false ? t('diagZonesMissing') : t('yes')}`),
1525
+ h('div', { key: 'slots' }, `${t('diagSlots')}: ${Object.entries(state.slots).map(([k, v]) => `${k}=${v}`).join(' · ')}`),
1526
+ h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
1527
+ ])
1528
+ : null)
1529
+ }
1530
+
1531
+ /** Session probe: an invisible entry in the composer tool row. */
1532
+ function SessionProbe(props) {
1533
+ const sessionId = props?.sessionId
1534
+ // `inputActions` is a STANDARD prop of every session-scoped slot entry
1535
+ // (the slot catalog lists it beside `sessionId`), so the composer's own
1536
+ // action face is captured here rather than guessed at from a service.
1537
+ const actions = props?.inputActions
1538
+ React.useEffect(() => {
1539
+ if (typeof sessionId === 'string' && sessionId !== '' && store.state.sessionId !== sessionId) {
1540
+ store.set({ sessionId })
1541
+ }
1542
+ if (actions !== undefined && actions !== null) capturedComposerActions = actions
1543
+ }, [sessionId, actions])
1544
+ return null
1545
+ }
1546
+
1547
+ /** The composer action face captured from a session-scoped slot entry. */
1548
+ function composerFace() {
1549
+ return capturedComposerActions === null ? null : { actions: capturedComposerActions }
1550
+ }
1551
+
1552
+ // ─────────────────────────────────────────────────────────────────────
1553
+ // §10 plugin face
1554
+ // ─────────────────────────────────────────────────────────────────────
1555
+
1556
+ return {
1557
+ inject: ['slots'],
1558
+ apply(ctx) {
1559
+ /** Everything this activation must undo. */
1560
+ const disposers = []
1561
+ const controllers = new Map()
1562
+ /** Pending surface render-proof timers (cleared on unload). */
1563
+ const pendingProofs = new Set()
1564
+ let lastSignature = null
1565
+ let lastAt = 0
1566
+ let pendingTimer = null
1567
+ let nextCardId = 1
1568
+
1569
+ // ── configuration ────────────────────────────────────────────────
1570
+ const refreshState = async () => {
1571
+ try {
1572
+ const value = await getState()
1573
+ store.set({
1574
+ config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
1575
+ problems: value.problems ?? [],
1576
+ capabilities: value.capabilities ?? null,
1577
+ provenance: value.provenance ?? null,
1578
+ hostReachable: true,
1579
+ })
1580
+ return value
1581
+ } catch (error) {
1582
+ store.set({ hostReachable: false })
1583
+ console.error(`[${PLUGIN_ID}] /state failed:`, error)
1584
+ throw error
1585
+ }
1586
+ }
1587
+
1588
+ const actions = {
1589
+ openPopover(candidate) {
1590
+ store.set({ popover: { candidate } })
1591
+ },
1592
+ closePopover() {
1593
+ store.set({ popover: null })
1594
+ },
1595
+ async submit(candidate, payload) {
1596
+ store.set({ popover: null, trigger: null })
1597
+ const config = store.state.config
1598
+ const cut = truncateSelection(candidate.text, config.maxChars)
1599
+ const card = {
1600
+ id: `card-${Date.now()}-${nextCardId++}`,
1601
+ question: payload.question,
1602
+ selection: candidate.text,
1603
+ selected: cut.text,
1604
+ truncated: cut.truncated,
1605
+ droppedChars: cut.droppedChars,
1606
+ zone: candidate.zone,
1607
+ cross: candidate.cross,
1608
+ carrier: payload.carrier,
1609
+ status: 'pending',
1610
+ text: '',
1611
+ reasoning: '',
1612
+ streaming: null,
1613
+ error: null,
1614
+ surface: 'flow',
1615
+ open: true,
1616
+ history: [],
1617
+ }
1618
+ if (payload.carrier === 'main') {
1619
+ store.set({ cards: [card, ...store.state.cards] })
1620
+ try {
1621
+ const result = await askInMainConversation({
1622
+ ctx,
1623
+ sessionId: currentSessionId(ctx),
1624
+ selection: candidate.text,
1625
+ question: payload.question,
1626
+ zone: candidate.zone,
1627
+ maxChars: config.maxChars,
1628
+ })
1629
+ patchCard(card.id, {
1630
+ status: 'done',
1631
+ text: result.mode === 'draft' ? t('draftInsertedHint') : t('mainSentHint'),
1632
+ streaming: null,
1633
+ })
1634
+ toast(result.mode === 'draft' ? t('draftInserted') : t('mainSent'))
1635
+ } catch (error) {
1636
+ patchCard(card.id, {
1637
+ status: 'error',
1638
+ error: { code: error?.code ?? 'main-failed', message: String(error?.message ?? error), retryable: true },
1639
+ })
1640
+ }
1641
+ return
1642
+ }
1643
+ const { adapter, fellBack } = pickSurface(config.sideSurface)
1644
+ card.surface = adapter.kind
1645
+ store.set({ cards: [card, ...store.state.cards], surface: adapter.kind })
1646
+ if (fellBack) {
1647
+ store.set({ surfaceNote: t('surfaceFellBack', { name: adapter.label() }) })
1648
+ toast(t('surfaceUnavailable'))
1649
+ }
1650
+ if (adapter.kind !== 'flow') {
1651
+ let opened = false
1652
+ try {
1653
+ opened = adapter.open(card) === true
1654
+ } catch (error) {
1655
+ console.error(`[${PLUGIN_ID}] surface open failed:`, error)
1656
+ opened = false
1657
+ }
1658
+ if (!opened) {
1659
+ store.set({ surface: 'flow' })
1660
+ patchCard(card.id, { surface: 'flow' })
1661
+ } else {
1662
+ // Render proof: an adapter that accepts the open but never
1663
+ // mounts our body would leave an empty tab. Unless the card is
1664
+ // on screen shortly after, move it to the flow stack — the
1665
+ // card's data lives in the store, so the running stream simply
1666
+ // continues in the other surface.
1667
+ const proof = setTimeout(() => {
1668
+ pendingProofs.delete(proof)
1669
+ const live = store.state.cards.find(item => item.id === card.id)
1670
+ if (live === undefined || live.surface === 'flow') return
1671
+ if (renderedCardId === card.id) return
1672
+ store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
1673
+ patchCard(card.id, { surface: 'flow' })
1674
+ toast(t('surfaceUnproven'))
1675
+ }, 600)
1676
+ pendingProofs.add(proof)
1677
+ }
1678
+ }
1679
+ void runSideCard(card.id)
1680
+ },
1681
+ copy(card, element) {
1682
+ void copyText(card.text, element).then((result) => {
1683
+ toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
1684
+ })
1685
+ },
1686
+ followUp(card, text) {
1687
+ const history = [...card.history, { question: card.question, answer: card.text }]
1688
+ const next = patchCard(card.id, {
1689
+ question: text,
1690
+ history,
1691
+ text: '',
1692
+ reasoning: '',
1693
+ status: 'pending',
1694
+ error: null,
1695
+ streaming: null,
1696
+ })
1697
+ if (next === undefined) return
1698
+ if (next.carrier === 'side') void runSideCard(card.id)
1699
+ else {
1700
+ void askInMainConversation({
1701
+ ctx,
1702
+ sessionId: currentSessionId(ctx),
1703
+ selection: next.selection,
1704
+ question: text,
1705
+ zone: next.zone,
1706
+ maxChars: store.state.config.maxChars,
1707
+ }).then(() => {
1708
+ patchCard(card.id, { status: 'done', text: t('mainSentHint') })
1709
+ }).catch((error) => {
1710
+ patchCard(card.id, {
1711
+ status: 'error',
1712
+ error: { code: error?.code ?? 'main-failed', message: String(error?.message ?? error), retryable: true },
1713
+ })
1714
+ })
1715
+ }
1716
+ },
1717
+ retry(card) {
1718
+ patchCard(card.id, { status: 'pending', text: '', reasoning: '', error: null, streaming: null })
1719
+ if (card.carrier === 'side') void runSideCard(card.id)
1720
+ },
1721
+ cancel(card) {
1722
+ const controller = controllers.get(card.id)
1723
+ if (controller !== undefined) controller.abort()
1724
+ patchCard(card.id, { status: 'stopped' })
1725
+ },
1726
+ async sendToMain(card) {
1727
+ try {
1728
+ const result = await askInMainConversation({
1729
+ ctx,
1730
+ sessionId: currentSessionId(ctx),
1731
+ selection: card.selection,
1732
+ question: card.question,
1733
+ zone: card.zone,
1734
+ maxChars: store.state.config.maxChars,
1735
+ })
1736
+ patchCard(card.id, {
1737
+ carrier: 'main',
1738
+ status: 'done',
1739
+ text: result.mode === 'draft' ? t('draftInsertedHint') : t('mainSentHint'),
1740
+ error: null,
1741
+ streaming: null,
1742
+ })
1743
+ toast(result.mode === 'draft' ? t('draftInserted') : t('mainSent'))
1744
+ } catch (error) {
1745
+ patchCard(card.id, {
1746
+ status: 'error',
1747
+ error: { code: error?.code ?? 'main-failed', message: String(error?.message ?? error), retryable: true },
1748
+ })
1749
+ }
1750
+ },
1751
+ close(card) {
1752
+ controllers.get(card.id)?.abort()
1753
+ controllers.delete(card.id)
1754
+ if (card.surface === 'better-sidebar') {
1755
+ try {
1756
+ surfaces.better.close(card)
1757
+ } catch { /* the host may already be gone */ }
1758
+ }
1759
+ store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
1760
+ },
1761
+ async saveConfig(patch) {
1762
+ const value = await postJson('config', patch)
1763
+ store.set({
1764
+ config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
1765
+ problems: value.problems ?? [],
1766
+ provenance: value.provenance ?? null,
1767
+ })
1768
+ return value
1769
+ },
1770
+ async resetConfig() {
1771
+ const value = await postJson('reset', {})
1772
+ store.set({
1773
+ config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
1774
+ problems: value.problems ?? [],
1775
+ provenance: value.provenance ?? null,
1776
+ })
1777
+ return value
1778
+ },
1779
+ async diagnose() {
1780
+ return await getState()
1781
+ },
1782
+ }
1783
+
1784
+ /** Stream one side answer into its card. */
1785
+ const runSideCard = async (cardId) => {
1786
+ const card = store.state.cards.find(item => item.id === cardId)
1787
+ if (card === undefined) return
1788
+ const controller = new AbortController()
1789
+ controllers.set(cardId, controller)
1790
+ patchCard(cardId, { status: 'streaming', text: '', reasoning: '', error: null })
1791
+ await streamAsk({
1792
+ id: cardId,
1793
+ question: card.question,
1794
+ selection: card.selected,
1795
+ zone: card.zone,
1796
+ cross: card.cross,
1797
+ carrier: 'side',
1798
+ sessionId: currentSessionId(ctx),
1799
+ history: card.history.slice(-6),
1800
+ }, {
1801
+ signal: controller.signal,
1802
+ onStart: (data) => {
1803
+ patchCard(cardId, {
1804
+ truncated: data?.truncated === true || card.truncated,
1805
+ droppedChars: data?.droppedChars ?? card.droppedChars,
1806
+ // Reported by the host so the card can be honest about the
1807
+ // read-only guard when the provider cannot take a tool filter.
1808
+ toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
1809
+ })
1810
+ },
1811
+ onDelta: data => appendCardText(cardId, String(data?.text ?? '')),
1812
+ onReasoning: data => appendCardReasoning(cardId, String(data?.text ?? '')),
1813
+ onDone: (data) => {
1814
+ const current = store.state.cards.find(item => item.id === cardId)
1815
+ patchCard(cardId, {
1816
+ status: data?.aborted === true ? 'stopped' : 'done',
1817
+ streaming: data?.streaming === true,
1818
+ text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
1819
+ })
1820
+ controllers.delete(cardId)
1821
+ },
1822
+ onError: (error) => {
1823
+ patchCard(cardId, { status: 'error', error })
1824
+ controllers.delete(cardId)
1825
+ },
1826
+ })
1827
+ }
1828
+
1829
+ // ── selection listeners ──────────────────────────────────────────
1830
+ const evaluate = () => {
1831
+ pendingTimer = null
1832
+ const config = store.state.config
1833
+ const candidate = readSelection()
1834
+ // Zone anchors: the harness stamps every slot outlet with
1835
+ // `data-slot="<key>"`. A build that renders no such marker (or a
1836
+ // selection made outside the frame's own tree) leaves the slot path
1837
+ // empty, and then region filtering is impossible — reported once and
1838
+ // relaxed rather than silently swallowing every trigger.
1839
+ if (candidate !== null) {
1840
+ const anchored = candidate.slotPath.length > 0
1841
+ if (store.state.zoneAnchors !== anchored) {
1842
+ store.set({ zoneAnchors: anchored })
1843
+ if (!anchored) {
1844
+ console.info(`[${PLUGIN_ID}] 该版本没有 data-slot 锚点,区域过滤不可用,已按"全部区域"工作`)
1845
+ }
1846
+ }
1847
+ }
1848
+ const decision = shouldOffer(candidate, config, { anchors: store.state.zoneAnchors !== false })
1849
+ if (!decision.ok) {
1850
+ lastSignature = null
1851
+ if (store.state.trigger !== null || store.state.popover !== null) {
1852
+ store.set({ trigger: null })
1853
+ }
1854
+ return
1855
+ }
1856
+ const signature = selectionSignature(candidate)
1857
+ const now = Date.now()
1858
+ if (signature === lastSignature && now - lastAt < 400) return
1859
+ lastSignature = signature
1860
+ lastAt = now
1861
+ store.set({ trigger: candidate })
1862
+ }
1863
+
1864
+ const schedule = () => {
1865
+ if (pendingTimer !== null) clearTimeout(pendingTimer)
1866
+ pendingTimer = setTimeout(evaluate, 120)
1867
+ }
1868
+
1869
+ const onDocumentDown = (event) => {
1870
+ const element = elementOf(event.target)
1871
+ if (element !== null && element.closest('[data-dsa-root]') !== null) return
1872
+ if (store.state.popover !== null) store.set({ popover: null })
1873
+ }
1874
+
1875
+ const onKeyDown = (event) => {
1876
+ const config = store.state.config
1877
+ const facts = parseShortcut(config.shortcut)
1878
+ if (matchesShortcut(event, facts)) {
1879
+ const candidate = readSelection() ?? store.state.trigger
1880
+ if (candidate !== null) {
1881
+ event.preventDefault()
1882
+ actions.openPopover(candidate)
1883
+ return
1884
+ }
1885
+ }
1886
+ if (event.key === 'Escape') {
1887
+ if (store.state.popover !== null) store.set({ popover: null })
1888
+ }
1889
+ }
1890
+
1891
+ const onScroll = () => {
1892
+ if (store.state.popover === null && store.state.trigger !== null) store.set({ trigger: null })
1893
+ }
1894
+
1895
+ if (typeof document !== 'undefined') {
1896
+ document.addEventListener('selectionchange', schedule)
1897
+ document.addEventListener('pointerup', schedule, true)
1898
+ document.addEventListener('keyup', schedule, true)
1899
+ document.addEventListener('mousedown', onDocumentDown, true)
1900
+ document.addEventListener('keydown', onKeyDown, true)
1901
+ window.addEventListener('scroll', onScroll, true)
1902
+ window.addEventListener('resize', onScroll, true)
1903
+ disposers.push(() => {
1904
+ document.removeEventListener('selectionchange', schedule)
1905
+ document.removeEventListener('pointerup', schedule, true)
1906
+ document.removeEventListener('keyup', schedule, true)
1907
+ document.removeEventListener('mousedown', onDocumentDown, true)
1908
+ document.removeEventListener('keydown', onKeyDown, true)
1909
+ window.removeEventListener('scroll', onScroll, true)
1910
+ window.removeEventListener('resize', onScroll, true)
1911
+ if (pendingTimer !== null) clearTimeout(pendingTimer)
1912
+ })
1913
+ }
1914
+
1915
+ // ── locale ───────────────────────────────────────────────────────
1916
+ try {
1917
+ const locale = ctx.get('locale')
1918
+ if (locale !== undefined && typeof locale.subscribe === 'function') {
1919
+ const read = () => {
1920
+ let id = null
1921
+ try {
1922
+ const snapshot = typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : null
1923
+ id = snapshot?.locale ?? snapshot?.id ?? (typeof locale.getLocale === 'function' ? locale.getLocale()?.locale : null)
1924
+ } catch { /* keep the previous language */ }
1925
+ if (typeof id === 'string' && id !== '') {
1926
+ const next = id.toLowerCase().startsWith('zh') ? 'zh' : 'en'
1927
+ if (next !== lang) {
1928
+ lang = next
1929
+ store.set({})
1930
+ }
1931
+ }
1932
+ }
1933
+ read()
1934
+ const off = locale.subscribe(read)
1935
+ if (typeof off === 'function') disposers.push(off)
1936
+ }
1937
+ } catch (error) {
1938
+ console.error(`[${PLUGIN_ID}] locale probe failed:`, error)
1939
+ }
1940
+
1941
+ // ── slot registrations ───────────────────────────────────────────
1942
+ //
1943
+ // A slot key is a version-scoped contract: `shell.overlay` has been the
1944
+ // frame-wide floating layer for the whole supported range, but nothing
1945
+ // guarantees it in a build we have not seen. Each registration below
1946
+ // therefore walks a LADDER of equivalent slots and keeps the best one
1947
+ // that is actually declared: a later rung is dropped the moment an
1948
+ // earlier one goes live, so the UI never doubles up.
1949
+ //
1950
+ // Every rung is a LIST slot on purpose — registering into a `single`
1951
+ // slot would REPLACE shipped UI (the catalog marks those
1952
+ // `shadows-shipped-ui`).
1953
+ const SLOT_PROBE_MS = 1500
1954
+
1955
+ /**
1956
+ * Register one entry into the first ladder rung that accepts it.
1957
+ * @param {string[]} candidates - slot keys, best first.
1958
+ * @param {(slotKey: string) => {options: object, component: Function}} build
1959
+ * @param {(from: string, to: string) => void} [onFallback] - reported when
1960
+ * a rung is skipped (the settings page shows the outcome).
1961
+ * @returns {() => void} disposer for every rung and timer.
1962
+ */
1963
+ const firstLiveSlot = (candidates, build, onFallback) => {
1964
+ const injected = []
1965
+ const timers = new Set()
1966
+ const registrations = new Map()
1967
+ let activeIndex = Number.POSITIVE_INFINITY
1968
+ let disposed = false
1969
+
1970
+ const takeOver = (index, disposeRegistration) => {
1971
+ if (disposed || index >= activeIndex) {
1972
+ // A better rung is already live: this one is surplus.
1973
+ if (index >= activeIndex) {
1974
+ try {
1975
+ disposeRegistration()
1976
+ } catch { /* already gone */ }
1977
+ }
1978
+ return
1979
+ }
1980
+ // This rung is better than whatever a previous fallback installed.
1981
+ for (const [otherIndex, entry] of registrations) {
1982
+ if (otherIndex <= index) continue
1983
+ try {
1984
+ entry.dispose()
1985
+ } catch { /* already gone */ }
1986
+ registrations.delete(otherIndex)
1987
+ }
1988
+ activeIndex = index
1989
+ registrations.set(index, { dispose: disposeRegistration })
1990
+ }
1991
+
1992
+ const attempt = (index) => {
1993
+ if (disposed || index >= candidates.length) return
1994
+ const key = candidates[index]
1995
+ try {
1996
+ injected.push(ctx.slots.inject(key, () => {
1997
+ const built = build(key)
1998
+ const disposeRegistration = ctx.slots.register(built.options, built.component)
1999
+ takeOver(index, disposeRegistration)
2000
+ return disposeRegistration
2001
+ }))
2002
+ } catch (error) {
2003
+ console.error(`[${PLUGIN_ID}] slot inject failed for "${key}":`, error)
2004
+ }
2005
+ const timer = setTimeout(() => {
2006
+ timers.delete(timer)
2007
+ if (disposed) return
2008
+ const next = candidates[index + 1]
2009
+ if (next === undefined) return
2010
+ if (activeIndex <= index) return
2011
+ onFallback?.(key, next)
2012
+ attempt(index + 1)
2013
+ }, SLOT_PROBE_MS)
2014
+ timers.add(timer)
2015
+ }
2016
+
2017
+ attempt(0)
2018
+ return () => {
2019
+ disposed = true
2020
+ for (const timer of timers) clearTimeout(timer)
2021
+ timers.clear()
2022
+ for (const entry of registrations.values()) {
2023
+ try {
2024
+ entry.dispose()
2025
+ } catch { /* already gone */ }
2026
+ }
2027
+ registrations.clear()
2028
+ for (const dispose of injected) {
2029
+ try {
2030
+ dispose()
2031
+ } catch { /* already gone */ }
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ /** Record which rung a registration actually landed on. */
2037
+ const noteSlot = (name, key) => {
2038
+ const slots = { ...(store.state.slots ?? {}), [name]: key }
2039
+ store.set({ slots })
2040
+ }
2041
+
2042
+ disposers.push(firstLiveSlot(
2043
+ ['shell.overlay', 'conversation.input.dock', 'conversation.composer.dock'],
2044
+ slotKey => ({
2045
+ options: { name: slotKey, id: IDS.overlay, order: 45, label: () => t('triggerLabel') },
2046
+ component: () => h(OverlayRoot, { actions }),
2047
+ }),
2048
+ (from, to) => {
2049
+ console.info(`[${PLUGIN_ID}] ${from} 不可用,浮层改挂 ${to}`)
2050
+ noteSlot('overlay', to)
2051
+ },
2052
+ ))
2053
+ noteSlot('overlay', 'shell.overlay')
2054
+
2055
+ disposers.push(firstLiveSlot(
2056
+ ['settings.section', 'settings.plugins.tab'],
2057
+ slotKey => ({
2058
+ options: { name: slotKey, id: IDS.settings, order: 60, label: () => t('settingsTitle') },
2059
+ component: () => h(SettingsSection, { actions }),
2060
+ }),
2061
+ (from, to) => {
2062
+ console.info(`[${PLUGIN_ID}] ${from} 不可用,设置页改挂 ${to}`)
2063
+ noteSlot('settings', to)
2064
+ },
2065
+ ))
2066
+ noteSlot('settings', 'settings.section')
2067
+
2068
+ disposers.push(firstLiveSlot(
2069
+ ['conversation.input.right', 'conversation.input.left', 'conversation.composer.dock', 'conversation.input.dock'],
2070
+ slotKey => ({
2071
+ options: { name: slotKey, id: IDS.sessionProbe, order: 90 },
2072
+ component: SessionProbe,
2073
+ }),
2074
+ (from, to) => {
2075
+ console.info(`[${PLUGIN_ID}] ${from} 不可用,会话 id 采集改挂 ${to}`)
2076
+ noteSlot('sessionProbe', to)
2077
+ },
2078
+ ))
2079
+ noteSlot('sessionProbe', 'conversation.input.right')
2080
+
2081
+ // ── side-card surfaces ───────────────────────────────────────────
2082
+ // The native right rail: its registry accepts tab types and its
2083
+ // controller opens them by kind. Every call is defensive — an older
2084
+ // harness simply leaves `native` unavailable and `auto` falls through.
2085
+ //
2086
+ // PLACEHOLDER: native-rightbar — the call shapes below were taken from
2087
+ // the native surface's own consumer (`sidebarRightTabs.register` with a
2088
+ // `guide[]` entry, `sidebarRight.openTab(kind, { params })`, and the
2089
+ // keyed `sidebar.right.pane.tab` slot with `inject: sessionId => …`).
2090
+ // They are NOT verified on this machine (no right-rail composition),
2091
+ // so the whole block is capability-probed and try/caught: a mismatch
2092
+ // degrades to the next surface and logs, never breaks the plugin.
2093
+ // See README §6 for the replacement checklist.
2094
+ try {
2095
+ ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => {
2096
+ try {
2097
+ const registry = injected.get('sidebarRightTabs')
2098
+ const controller = injected.get('sidebarRight')
2099
+ if (registry === null || registry === undefined) return
2100
+ surfaces.native.registry = registry
2101
+ surfaces.native.controller = controller ?? null
2102
+ if (typeof registry.register === 'function') {
2103
+ const off = registry.register({
2104
+ id: CARD_KIND,
2105
+ kind: CARD_KIND,
2106
+ title: () => t('answerTitle'),
2107
+ guide: [{
2108
+ id: CARD_KIND,
2109
+ order: 120,
2110
+ title: () => t('settingsTitle'),
2111
+ description: () => t('settingsDesc'),
2112
+ }],
2113
+ })
2114
+ surfaces.native.dispose = typeof off === 'function' ? off : null
2115
+ }
2116
+ const offBody = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2117
+ name: 'sidebar.right.pane.tab',
2118
+ key: CARD_KIND,
2119
+ inject: sessionId => ({ sessionId }),
2120
+ }, (props) => h(CardHost, { props, actions })))
2121
+ const offTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
2122
+ name: 'sidebar.right.pane.tab.title',
2123
+ key: CARD_KIND,
2124
+ }, () => h('span', null, t('answerTitle'))))
2125
+ disposers.push(() => {
2126
+ try { offBody() } catch { /* already disposed */ }
2127
+ try { offTitle() } catch { /* already disposed */ }
2128
+ try { surfaces.native.dispose?.() } catch { /* already disposed */ }
2129
+ })
2130
+ } catch (error) {
2131
+ surfaces.native.error = String(error?.message ?? error)
2132
+ console.error(`[${PLUGIN_ID}] native right-rail adapter unavailable:`, error)
2133
+ }
2134
+ })
2135
+ } catch (error) {
2136
+ surfaces.native.error = String(error?.message ?? error)
2137
+ }
2138
+
2139
+ // The dsh-better-sidebar plugin, when installed: its client service
2140
+ // registers a tab type and opens one per card. `features.tabMeta`
2141
+ // gates the meta-carried card id, so an older version of that plugin
2142
+ // degrades to the built-in flow card instead of showing an empty tab.
2143
+ try {
2144
+ ctx.inject(['betterSidebar'], (injected) => {
2145
+ try {
2146
+ const service = injected.get('betterSidebar')
2147
+ if (service === null || service === undefined) return
2148
+ const features = Array.isArray(service.features) ? service.features : []
2149
+ surfaces.better.features = features
2150
+ if (typeof service.registerTab !== 'function') return
2151
+ const off = service.registerTab({
2152
+ id: CARD_KIND,
2153
+ title: () => t('answerTitle'),
2154
+ description: () => t('settingsDesc'),
2155
+ order: 120,
2156
+ dedupeKey: tab => (tab?.meta?.cardId !== undefined ? String(tab.meta.cardId) : undefined),
2157
+ component: props => h(CardHost, {
2158
+ props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
2159
+ actions,
2160
+ }),
2161
+ })
2162
+ surfaces.better.service = service
2163
+ surfaces.better.dispose = typeof off === 'function' ? off : null
2164
+ disposers.push(() => {
2165
+ try { surfaces.better.dispose?.() } catch { /* already disposed */ }
2166
+ })
2167
+ } catch (error) {
2168
+ surfaces.better.error = String(error?.message ?? error)
2169
+ console.error(`[${PLUGIN_ID}] better-sidebar adapter unavailable:`, error)
2170
+ }
2171
+ })
2172
+ } catch (error) {
2173
+ surfaces.better.error = String(error?.message ?? error)
2174
+ }
2175
+
2176
+ /** Embedded card body used by both external hosts. */
2177
+ function CardHost({ props }) {
2178
+ const state = useStoreState()
2179
+ const cardId = props?.tab?.meta?.cardId
2180
+ const card = state.cards.find(item => item.id === cardId)
2181
+ // The proof hook MUST run before any early return (hook order), and
2182
+ // it is what lets `submit` detect a host that never rendered us.
2183
+ React.useEffect(() => {
2184
+ if (typeof cardId !== 'string' || cardId === '') return undefined
2185
+ renderedCardId = cardId
2186
+ return () => {
2187
+ if (renderedCardId === cardId) renderedCardId = null
2188
+ }
2189
+ }, [cardId])
2190
+ if (card === undefined) {
2191
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
2192
+ h(Styles, null),
2193
+ h('div', { className: 'dsa-muted' }, t('answerEmpty')))
2194
+ }
2195
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '', style: { padding: '10px' } },
2196
+ h(Styles, null),
2197
+ h(AnswerCard, {
2198
+ card,
2199
+ surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
2200
+ onCopy: element => actions.copy(card, element),
2201
+ onFollowUp: text => actions.followUp(card, text),
2202
+ onRetry: () => actions.retry(card),
2203
+ onCancel: () => actions.cancel(card),
2204
+ onSendToMain: () => { void actions.sendToMain(card) },
2205
+ onClose: () => actions.close(card),
2206
+ }))
2207
+ }
2208
+
2209
+ // ── boot ─────────────────────────────────────────────────────────
2210
+ void refreshState().catch(() => { /* the settings page shows the failure */ })
2211
+ disposers.push(() => {
2212
+ for (const controller of controllers.values()) controller.abort()
2213
+ controllers.clear()
2214
+ for (const proof of pendingProofs) clearTimeout(proof)
2215
+ pendingProofs.clear()
2216
+ if (toastTimer !== null) clearTimeout(toastTimer)
2217
+ store.set({
2218
+ trigger: null,
2219
+ popover: null,
2220
+ cards: [],
2221
+ toast: null,
2222
+ sessionId: null,
2223
+ surface: 'flow',
2224
+ })
2225
+ })
2226
+
2227
+ ctx.effect(() => () => {
2228
+ for (const dispose of disposers.splice(0)) {
2229
+ try {
2230
+ dispose()
2231
+ } catch (error) {
2232
+ console.error(`[${PLUGIN_ID}] cleanup failed:`, error)
2233
+ }
2234
+ }
2235
+ }, 'sidecard-ask: client activation')
2236
+ },
2237
+
2238
+ /**
2239
+ * Test seam: pure helpers, frozen. The DSH client runtime only reads
2240
+ * `inject`/`apply`; `test/*.mjs` loads this file with a synthetic
2241
+ * `window` and exercises these without a browser.
2242
+ */
2243
+ api: Object.freeze({
2244
+ version: '1.1.0',
2245
+ /** Read-only state accessor for diagnostics and the test harness. */
2246
+ snapshot: () => store.state,
2247
+ pure: Object.freeze({
2248
+ CLIENT_DEFAULTS,
2249
+ IDS,
2250
+ CARD_KIND,
2251
+ parseSseBlock,
2252
+ truncateSelection,
2253
+ parseShortcut,
2254
+ matchesShortcut,
2255
+ classifyZone,
2256
+ slotPathOf,
2257
+ shouldOffer,
2258
+ selectionSignature,
2259
+ composeMainPrompt,
2260
+ askInMainConversation,
2261
+ renderRichText,
2262
+ pickSurface,
2263
+ dict: DICT,
2264
+ }),
2265
+ }),
2266
+ }
2267
+ },
2268
+ })