dsh-chat-embed 0.0.0-stage → 0.6.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,3870 @@
1
+ /**
2
+ * dsh-chat-embed — Client half.
3
+ *
4
+ * A left-navigation entry ("聊天") that opens a **multi-tab AI chat surface**
5
+ * in the central panel of the DeepSeek Harness desktop app: one tab per web
6
+ * chat service (DeepSeek, 豆包, ChatGPT, MiniMax, Kimi, Gemini and Grok by default), each in its own desktop webview and its own storage partition.
7
+ *
8
+ * ## Why a desktop webview instead of an iframe
9
+ *
10
+ * Every service here refuses framing (`frame-ancestors 'none'` or X-Frame
11
+ * Options), so no document may embed it — not the Web GUI, not a browser
12
+ * profile, not this panel. An Electron `<webview>` is a separate top-level
13
+ * WebContents and ignores those directives, which is why the embedded surface
14
+ * only exists in the desktop app.
15
+ *
16
+ * ## Why the lease handshake
17
+ *
18
+ * The desktop shell refuses every `<webview>` it did not hand a lease to
19
+ * (`will-attach-webview`): the element's `src` must be exactly
20
+ * `about:blank#<lease>` and its `partition` must be the one issued with that
21
+ * lease. `window.dshDesktop.browser.acquire(workspace)` is the only source of
22
+ * leases, and client plugin bundles are injected as same-origin classic
23
+ * scripts, so this file runs in the same main world as that bridge.
24
+ *
25
+ * The workspace string is a storage identity, not a secret: the shell caches
26
+ * one partition per identity for the process lifetime. Giving every provider
27
+ * its own identity keeps their logins isolated, and keeps each service signed
28
+ * in for as long as the app runs.
29
+ *
30
+ * ## Why the guest lives in the seat's own layout box
31
+ *
32
+ * Electron destroys a webview's WebContents as soon as the element leaves the
33
+ * DOM, and re-parenting does not help either: a move is a detach plus an
34
+ * attach. So the surface is rendered *inside* the panel or tab body showing
35
+ * it (`.dce-panel.dce-inline`, `width/height:100%`) and the browser owns the
36
+ * geometry - no measurement, no per-frame JS.
37
+ *
38
+ * Two seats can show it: the central panel (`main`) and the right sidebar tab.
39
+ * They are mutually exclusive because the right sidebar belongs to a Session,
40
+ * so whichever seat the user opens claims the guests. Switching seats rebuilds
41
+ * the guest once; the last conversation URL is restored, so no re-login.
42
+ *
43
+ * ## Per-tab lifecycle
44
+ *
45
+ * idle → acquiring → navigating → ready
46
+ * ↘ ↘
47
+ * failed ← (retry acquires a *fresh* lease; a consumed
48
+ * lease can never be reused)
49
+ *
50
+ * A tab's guest is created the first time that tab is activated and then kept
51
+ * for the app run. Activating a tab never touches another tab's guest.
52
+ *
53
+ * ## Adding a service
54
+ *
55
+ * Append one entry to `PROVIDERS`: `id`, `label`, `url`, `origin`, and a unique
56
+ * `workspace` (storage identity). Nothing else is provider-specific.
57
+ */
58
+
59
+ window.__ModuleLoader__.load({
60
+ id: 'dsh-chat-embed',
61
+ factory(require) {
62
+ 'use strict'
63
+
64
+ // The browser module table publishes React; installing a second copy here
65
+ // would break hooks, so the bundle requires the shared instance.
66
+ const React = require('react')
67
+ const h = React.createElement
68
+
69
+ // ------------------------------------------------------------------
70
+ // Identity
71
+ // ------------------------------------------------------------------
72
+
73
+ /** Locale namespace this plugin's UI strings register under. */
74
+ const NS = 'dshChatEmbed'
75
+
76
+ /** Sidebar entry id; the sidebar dispatches the `main` key of the same name. */
77
+ const PANEL_ID = 'chat-embed'
78
+
79
+ /** Right sidebar: one tab type, its body seat, its chip title seat. */
80
+ const RIGHT_TAB_SLOT = 'sidebar.right.pane.tab'
81
+ const RIGHT_TAB_TITLE_SLOT = 'sidebar.right.pane.tab.title'
82
+ const RIGHT_TAB_ID = 'dsh-chat-embed'
83
+ const RIGHT_TAB_KIND = 'chat-embed'
84
+
85
+ /**
86
+ * Right-aligned Session utilities (a list slot). The far-right corner is a
87
+ * single slot already owned by another plugin, and shadowing it would hide
88
+ * that plugin's control, so this entry joins the utilities instead.
89
+ */
90
+ const DOCK_SLOT = 'conversation.session.header.utilities'
91
+ const DOCK_ID = 'chat-embed-dock'
92
+
93
+ /**
94
+ * Right-sidebar wiring state. The right column is optional and may reject a
95
+ * foreign slot or load after this plugin, so every step is attempted
96
+ * independently and reported in the diagnostics card instead of throwing.
97
+ */
98
+ let rightSeatAttempts = []
99
+ let rightSeatsInstalled = false
100
+ let rightTypeState = { state: 'idle', detail: '' }
101
+ let rightTabTypeDispose
102
+ let rightDisposers = []
103
+ let openRightTabImpl = () => {}
104
+
105
+ /** Build identity, surfaced in the panel so a packaged app can be diagnosed. */
106
+ const BUILD = '0.6.0'
107
+
108
+ /** Where a re-applied instance finds the one it must take over from. */
109
+ const INSTANCE_KEY = '__DSH_CHAT_EMBED_INSTANCE__'
110
+
111
+ /** Where the last session's active tab is remembered. */
112
+ const ACTIVE_TAB_KEY = 'dsh-chat-embed:active-tab'
113
+
114
+ /** Where the user's own tab order is remembered. */
115
+ const TAB_ORDER_KEY = 'dsh-chat-embed:tab-order'
116
+
117
+ /** Where the set of enabled providers is remembered. */
118
+ const ENABLED_KEY = 'dsh-chat-embed:enabled'
119
+
120
+ /**
121
+ * The services this surface can host. Order is the tab order.
122
+ *
123
+ * `workspace` is the shell's storage identity and the partition key: give
124
+ * every provider its own, otherwise two providers share one cookie jar.
125
+ * Keep the value stable — changing it signs the user out.
126
+ */
127
+ const PROVIDERS = Object.freeze([
128
+ {
129
+ id: 'deepseek',
130
+ label: 'DeepSeek',
131
+ url: 'https://chat.deepseek.com/',
132
+ origin: 'https://chat.deepseek.com',
133
+ workspace: 'dsh-chat-embed:chat:v1',
134
+ },
135
+ {
136
+ id: 'doubao',
137
+ label: '豆包',
138
+ url: 'https://www.doubao.com/chat/',
139
+ origin: 'https://www.doubao.com',
140
+ workspace: 'dsh-chat-embed:doubao:v1',
141
+ },
142
+ {
143
+ id: 'chatgpt',
144
+ label: 'ChatGPT',
145
+ url: 'https://chatgpt.com/',
146
+ origin: 'https://chatgpt.com',
147
+ workspace: 'dsh-chat-embed:chatgpt:v1',
148
+ },
149
+ {
150
+ id: 'minimax',
151
+ label: 'MiniMax',
152
+ url: 'https://chat.minimaxi.com/',
153
+ origin: 'https://chat.minimaxi.com',
154
+ workspace: 'dsh-chat-embed:minimax:v1',
155
+ },
156
+ {
157
+ id: 'kimi',
158
+ label: 'Kimi',
159
+ url: 'https://www.kimi.com/',
160
+ origin: 'https://www.kimi.com',
161
+ workspace: 'dsh-chat-embed:kimi:v1',
162
+ },
163
+ {
164
+ id: 'gemini',
165
+ label: 'Gemini',
166
+ url: 'https://gemini.google.com/',
167
+ origin: 'https://gemini.google.com',
168
+ workspace: 'dsh-chat-embed:gemini:v1',
169
+ },
170
+ {
171
+ id: 'grok',
172
+ label: 'Grok',
173
+ url: 'https://grok.com/',
174
+ origin: 'https://grok.com',
175
+ workspace: 'dsh-chat-embed:grok:v1',
176
+ },
177
+ ].map(Object.freeze))
178
+
179
+ const DEFAULT_PROVIDER = 'deepseek'
180
+
181
+ /**
182
+ * Identity providers that refuse to complete OAuth inside an embedded
183
+ * browser. Relaying their popups would only navigate the guest into a blank
184
+ * page, so they are reported instead of followed.
185
+ */
186
+ const OAUTH_HOSTS = Object.freeze([
187
+ 'accounts.google.com',
188
+ 'accounts.youtube.com',
189
+ 'appleid.apple.com',
190
+ 'login.microsoftonline.com',
191
+ 'login.live.com',
192
+ ])
193
+
194
+ // ------------------------------------------------------------------
195
+ // Timing
196
+ // ------------------------------------------------------------------
197
+
198
+ /** The first `dom-ready` can beat the shell's lease registration. */
199
+ const NAVIGATE_RETRY_MS = 350
200
+ const NAVIGATE_MAX_ATTEMPTS = 5
201
+
202
+ /**
203
+ * How long to wait after an OAuth roundtrip lands back on the site before
204
+ * checking whether the callback page actually rendered. Overridable in the
205
+ * test hook so the suite does not sleep for seconds.
206
+ */
207
+ const TIMINGS = {
208
+ oauthLandingDelayMs: 1800,
209
+ oauthBlankWatchMs: 2200,
210
+ oauthHandoffProbeMs: 1200,
211
+ oauthRecoveryReloadMs: 700,
212
+ }
213
+
214
+ const PHASE = {
215
+ IDLE: 'idle',
216
+ ACQUIRING: 'acquiring',
217
+ NAVIGATING: 'navigating',
218
+ READY: 'ready',
219
+ FAILED: 'failed',
220
+ UNSUPPORTED: 'unsupported',
221
+ }
222
+
223
+ // ------------------------------------------------------------------
224
+ // Locale
225
+ // ------------------------------------------------------------------
226
+
227
+ const DICTS = {
228
+ en: {
229
+ navLabel: 'Chat',
230
+ statusIdle: 'Idle',
231
+ statusAcquiring: 'Requesting an embedded view…',
232
+ statusNavigating: 'Opening…',
233
+ statusReady: 'Connected',
234
+ statusFailedShort: 'Unavailable',
235
+ statusUnsupportedShort: 'Desktop app required',
236
+ statusFailed: 'This tab could not start',
237
+ statusUnsupported: 'Embedded chat needs the DSH desktop app',
238
+ failedHint:
239
+ 'The desktop shell did not hand back a usable webview lease for this tab. Retrying asks for a fresh one.',
240
+ unsupportedHint:
241
+ 'These chat services refuse to be framed, so the Web GUI can only link out. Open this panel in the DSH desktop app to chat inside the app.',
242
+ retry: 'Retry',
243
+ reload: 'Reload',
244
+ openInBrowser: 'Open in browser',
245
+ partitionLabel: 'Partition',
246
+ tabHint: 'Open {label}',
247
+ dismiss: 'Dismiss',
248
+ dragHint: 'Drag to reorder',
249
+ settings: 'Settings',
250
+ menuTitle: 'Chat tabs',
251
+ lastTabHint: 'At least one tab must stay enabled',
252
+ logging: 'Debug log',
253
+ loggingHint: 'Print plugin events to the console (off by default)',
254
+ loggingOn: 'on',
255
+ loggingOff: 'off',
256
+ loggingCopy: 'Copy log',
257
+ persistLabel: 'Login across restarts',
258
+ persistOn: 'on (persist: partition)',
259
+ persistOff: 'off (memory partition)',
260
+ persistLost: 'lost (the shell update replaced the patch)',
261
+ persistUnknown: 'not detected yet',
262
+ persistHint: 'Needs the shell patch; a plugin cannot change how the shell names partitions.',
263
+ persistRisk:
264
+ 'Experimental: the patch rewrites the app.asar of the desktop shell and needs write access to its install folder. A shell update replaces it, so the patch has to be applied again.',
265
+ persistCopy: 'Copy the patch instructions',
266
+ persistCheck: 'Check the patch setup',
267
+ persistCheckRunning: 'Checking…',
268
+ persistReportCopied: 'Self-check copied',
269
+ persistReportTitle: 'Shell patch self-check',
270
+ copyDiag: 'Copy diagnostics',
271
+ copied: 'Copied',
272
+ copyFailed: 'Copy failed',
273
+ dockTitle: 'Open chat in the right sidebar',
274
+ rightTabTitle: 'Chat',
275
+ goHome: 'Go to start page',
276
+ diagTitle: 'Tab diagnostics',
277
+ diagUrl: 'URL',
278
+ diagPhase: 'State',
279
+ diagEvent: 'Last event',
280
+ diagHint: 'Send these lines when reporting a broken page.',
281
+ diagHandoff: 'Handoff probe',
282
+ diagRight: 'Right column',
283
+ diagSeat: 'Seat',
284
+ selfTest: 'Self-test',
285
+ rect: 'left {left} top {top} {width}×{height}',
286
+ seatMain: 'center panel',
287
+ seatRight: 'right column',
288
+ seatNone: 'none',
289
+ seatMissing: '{seat} (no host)',
290
+ seatDescribe: '{seat} host={host} (css-owned)',
291
+ rightRegistered: 'registered',
292
+ rightPending: 'pending registration',
293
+ rightFailed: 'registration failed',
294
+ rightUnavailable: 'unavailable',
295
+ rightSeatBody: 'body',
296
+ rightSeatTitle: 'title',
297
+ rightSeatDock: 'dock',
298
+ rightDetail: ' · {detail}',
299
+ seatListSep: ', ',
300
+ selfTestInline: 'inline seat={seat} host={host} width=100% (layout owns the box; no measurement, no polling)',
301
+ selfTestNoSeat: 'no inline seat',
302
+ selfTestLostHost: 'the inline seat lost its host',
303
+ eventStopLoading: 'page stopped loading',
304
+ eventOauthDeadEnd: 'sign-in page dead end',
305
+ eventOauthProbeFailed: 'sign-in dead end (probe failed)',
306
+ eventRendererGone: 'renderer gone',
307
+ oauthBlockedTitle: 'Google sign-in cannot finish here',
308
+ oauthBlockedBody:
309
+ 'A normal browser tab works because the site opens a real popup and Google posts the credential back to it through window.opener. A panel cannot open that popup, so the flow stops on Google’s gsi/transform page. Use the site’s own email sign-in instead.',
310
+ },
311
+ zh: {
312
+ navLabel: '聊天',
313
+ statusIdle: '空闲',
314
+ statusAcquiring: '正在申请内嵌视图…',
315
+ statusNavigating: '正在打开…',
316
+ statusReady: '已连接',
317
+ statusFailedShort: '不可用',
318
+ statusUnsupportedShort: '需要桌面版',
319
+ statusFailed: '该标签页启动失败',
320
+ statusUnsupported: '内嵌聊天需要 DSH 桌面版',
321
+ failedHint: '桌面壳没有为这个标签页返回可用的 webview 租约。点击重试会重新申请一张新租约。',
322
+ unsupportedHint:
323
+ '这些 AI 站点都禁止被 frame 嵌入,Web 端只能外开。请在 DSH 桌面版中打开本面板,直接在内嵌页里聊天。',
324
+ retry: '重试',
325
+ reload: '重新加载',
326
+ openInBrowser: '在浏览器打开',
327
+ partitionLabel: '分区',
328
+ tabHint: '打开 {label}',
329
+ dismiss: '知道了',
330
+ dragHint: '拖动可排序',
331
+ settings: '设置',
332
+ menuTitle: '对话窗口',
333
+ lastTabHint: '至少保留一个对话窗口',
334
+ logging: '调试日志',
335
+ loggingHint: '在控制台输出插件事件(默认关闭)',
336
+ loggingOn: '开',
337
+ loggingOff: '关',
338
+ loggingCopy: '复制日志',
339
+ persistLabel: '跨重启保持登录',
340
+ persistOn: '已开启(persist: 分区)',
341
+ persistOff: '未开启(内存分区)',
342
+ persistLost: '已失效(桌面壳升级覆盖了补丁)',
343
+ persistUnknown: '尚未检测',
344
+ persistHint: '需要桌面壳补丁;插件无法改变壳的分区命名。',
345
+ persistRisk: '实验性功能:补丁会改写桌面壳的 app.asar,需要安装目录的写权限;桌面壳升级会覆盖补丁,需要重新打一次。',
346
+ persistCopy: '复制补丁说明',
347
+ persistCheck: '检查补丁配置',
348
+ persistCheckRunning: '正在检查…',
349
+ persistReportCopied: '自检报告已复制',
350
+ persistReportTitle: '壳补丁自检报告',
351
+ copyDiag: '复制诊断信息',
352
+ copied: '已复制',
353
+ copyFailed: '复制失败',
354
+ dockTitle: '在右侧栏打开聊天',
355
+ rightTabTitle: '聊天',
356
+ goHome: '回到首页',
357
+ diagTitle: '标签页诊断',
358
+ diagUrl: '地址',
359
+ diagPhase: '状态',
360
+ diagEvent: '最近事件',
361
+ diagHint: '反馈问题时把这几行发出来即可。',
362
+ diagHandoff: '凭据探测',
363
+ diagRight: '右栏',
364
+ diagSeat: '占位',
365
+ selfTest: '自检',
366
+ rect: '左{left} 上{top} {width}×{height}',
367
+ seatMain: '中央面板',
368
+ seatRight: '右栏',
369
+ seatNone: '无',
370
+ seatMissing: '{seat}(无宿主)',
371
+ seatDescribe: '{seat} 宿主={host}(CSS 决定)',
372
+ rightRegistered: '已注册',
373
+ rightPending: '等待注册',
374
+ rightFailed: '注册失败',
375
+ rightUnavailable: '不可用',
376
+ rightSeatBody: '主体',
377
+ rightSeatTitle: '标题',
378
+ rightSeatDock: '按钮',
379
+ rightDetail: '({detail})',
380
+ seatListSep: ',',
381
+ selfTestInline: '内联席位={seat} 宿主={host} 宽度=100%(布局决定,无测量、无轮询)',
382
+ selfTestNoSeat: '没有内联席位',
383
+ selfTestLostHost: '内联席位丢失宿主',
384
+ eventStopLoading: '页面已停止加载',
385
+ eventOauthDeadEnd: '登录页陷入死路',
386
+ eventOauthProbeFailed: '登录页死路(探测失败)',
387
+ eventRendererGone: '渲染进程退出',
388
+ oauthBlockedTitle: 'Google 登录在内嵌面板里无法完成',
389
+ oauthBlockedBody:
390
+ '普通浏览器标签页能成功,是因为站点开了一个真正的弹窗,Google 通过 window.opener 把凭据交回给它;内嵌面板开不出这个弹窗,流程就停在 Google 的 gsi/transform 页。请改用站点自己的「使用电子邮箱继续」。',
391
+ },
392
+ }
393
+
394
+ /** Locale keys for diagnostic values that are not free text. */
395
+ const SEAT_KEY = { main: 'seatMain', right: 'seatRight', none: 'seatNone' }
396
+ const RIGHT_STATE_KEY = {
397
+ registered: 'rightRegistered',
398
+ pending: 'rightPending',
399
+ failed: 'rightFailed',
400
+ unavailable: 'rightUnavailable',
401
+ }
402
+ const RIGHT_SEAT_KEY = { body: 'rightSeatBody', title: 'rightSeatTitle', dock: 'rightSeatDock' }
403
+ const EVENT_KEY = {
404
+ 'did-stop-loading': 'eventStopLoading',
405
+ 'oauth-dead-end': 'eventOauthDeadEnd',
406
+ 'oauth-dead-end (probe failed)': 'eventOauthProbeFailed',
407
+ 'renderer-gone': 'eventRendererGone',
408
+ }
409
+
410
+ /** @param {Record<string, string>} map @param {string} value @returns {string} */
411
+ function valueText(t, map, value) {
412
+ const key = map[value]
413
+ return key !== undefined ? t(key) : value
414
+ }
415
+
416
+ /** Fill {name} placeholders in a translated template. */
417
+ function fill(template, params) {
418
+ if (params === undefined) return template
419
+ return String(template).replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
420
+ }
421
+
422
+ /** Phase → locale key for the status text. */
423
+ const STATUS_KEY = {
424
+ [PHASE.IDLE]: 'statusIdle',
425
+ [PHASE.ACQUIRING]: 'statusAcquiring',
426
+ [PHASE.NAVIGATING]: 'statusNavigating',
427
+ [PHASE.READY]: 'statusReady',
428
+ [PHASE.FAILED]: 'statusFailedShort',
429
+ [PHASE.UNSUPPORTED]: 'statusUnsupportedShort',
430
+ }
431
+
432
+ /** @param {(key: string) => string} t @param {string} phase @returns {string} */
433
+ function phaseText(t, phase) {
434
+ const key = STATUS_KEY[phase]
435
+ return key !== undefined ? t(key) : phase
436
+ }
437
+
438
+ /** Debug-only translator: probe readouts stay Chinese. */
439
+ const probeT = (key, params) => fill(DICTS.zh[key] !== undefined ? DICTS.zh[key] : key, params)
440
+
441
+ // ------------------------------------------------------------------
442
+ // Pure helpers
443
+ // ------------------------------------------------------------------
444
+
445
+ /**
446
+ * Drop the desktop shell's own User-Agent tokens.
447
+ *
448
+ * chat.deepseek.com and friends overlay a "使用环境异常" risk-control dialog
449
+ * when they recognise an Electron UA, so every guest presents as plain
450
+ * Chrome. The raw UA is read at mount time, which keeps this correct across
451
+ * shell upgrades without pinning a Chrome version here.
452
+ *
453
+ * @param {string} raw
454
+ * @returns {string}
455
+ */
456
+ function normalizedUserAgent(raw) {
457
+ const source = typeof raw === 'string' ? raw : ''
458
+ const kept = source
459
+ .split(/\s+/)
460
+ .filter(Boolean)
461
+ .filter((token) => !/^(electron|dsh-desktop|deepseek-harness)\//i.test(token))
462
+ return kept.join(' ')
463
+ }
464
+
465
+ /** A plain-Chrome User-Agent used when the renderer exposes none. */
466
+ const FALLBACK_USER_AGENT =
467
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36'
468
+
469
+ /** @returns {string} the guest User-Agent for this renderer. */
470
+ function currentUserAgent() {
471
+ const raw = typeof navigator === 'undefined' || navigator === null ? '' : navigator.userAgent
472
+ const normalized = normalizedUserAgent(raw)
473
+ return normalized === '' ? FALLBACK_USER_AGENT : normalized
474
+ }
475
+
476
+ /**
477
+ * The desktop shell's leased-browser bridge, when this page runs inside it.
478
+ *
479
+ * @param {object} [scope] override for tests.
480
+ * @returns {{ acquire: Function, release: Function } | undefined}
481
+ */
482
+ function desktopBrowser(scope) {
483
+ const shell = scope === undefined ? window : scope
484
+ const bridge = shell === undefined || shell === null ? undefined : shell.dshDesktop
485
+ const browser = bridge === undefined || bridge === null ? undefined : bridge.browser
486
+ if (browser === undefined || browser === null) return undefined
487
+ if (typeof browser.acquire !== 'function' || typeof browser.release !== 'function') return undefined
488
+ return browser
489
+ }
490
+
491
+ /** @returns {boolean} whether this page can host a leased webview. */
492
+ function isEmbedSupported() {
493
+ return desktopBrowser() !== undefined
494
+ }
495
+
496
+ /** @returns {Promise<void>} */
497
+ function delay(ms) {
498
+ return new Promise((resolve) => {
499
+ setTimeout(resolve, ms)
500
+ })
501
+ }
502
+
503
+ /** Best-effort human text for an unknown thrown value. */
504
+ function messageOf(error) {
505
+ if (error === undefined || error === null) return ''
506
+ if (typeof error === 'string') return error
507
+ const message = error.message
508
+ return typeof message === 'string' ? message : String(error)
509
+ }
510
+
511
+ /** Last four characters of an opaque id, for the diagnostic readout. */
512
+ function shortId(value) {
513
+ if (typeof value !== 'string' || value === '') return '—'
514
+ return value.length <= 4 ? value : value.slice(-4)
515
+ }
516
+
517
+ /**
518
+ * The plain-text diagnostics report behind the copy button. Kept pure so it
519
+ * can be checked without a renderer or a clipboard.
520
+ *
521
+ * @param {{ labels: Record<string, string>, url: string, frame: any, phase: string, detail: string, partition: string, mounts: number, lastEvent: string, handoff: string, build: string }} input
522
+ * @returns {string}
523
+ */
524
+ function formatDiagReport(input) {
525
+ const labels = input.labels
526
+ const lines = [labels.diagTitle]
527
+ lines.push(labels.diagUrl + ': ' + (input.url === '' ? '—' : input.url))
528
+ lines.push(labels.diagPhase + ': ' + input.phase + (input.detail === '' ? '' : ' · ' + input.detail))
529
+ lines.push(labels.partitionLabel + ': ' + input.partition + ' · #' + input.mounts)
530
+ lines.push(labels.diagEvent + ': ' + (input.lastEvent === '' ? '—' : input.lastEvent))
531
+ if (input.handoff !== '') lines.push(labels.diagHandoff + ': ' + input.handoff)
532
+ if (labels.diagRight !== undefined && input.right !== undefined && input.right !== '') {
533
+ lines.push(labels.diagRight + ': ' + input.right)
534
+ }
535
+ if (labels.diagSeat !== undefined && input.seat !== undefined && input.seat !== '') {
536
+ lines.push(labels.diagSeat + ': ' + input.seat)
537
+ }
538
+ if (labels.selfTest !== undefined && input.selfTest !== undefined && input.selfTest !== '') {
539
+ lines.push(labels.selfTest + ': ' + input.selfTest)
540
+ }
541
+ lines.push('dsh-chat-embed ' + input.build)
542
+ return lines.join('\n')
543
+ }
544
+
545
+ /**
546
+ * Copy text to the clipboard, with an execCommand fallback for renderers
547
+ * where the async clipboard API is unavailable.
548
+ *
549
+ * @param {string} text
550
+ * @returns {Promise<boolean>}
551
+ */
552
+ function copyText(text) {
553
+ const api = typeof navigator !== 'undefined' && navigator !== null ? navigator.clipboard : undefined
554
+ if (api !== undefined && api !== null && typeof api.writeText === 'function') {
555
+ return api.writeText(text).then(
556
+ () => true,
557
+ () => fallbackCopy(text),
558
+ )
559
+ }
560
+ return Promise.resolve(fallbackCopy(text))
561
+ }
562
+
563
+ /** @param {string} text */
564
+ function fallbackCopy(text) {
565
+ try {
566
+ if (typeof document === 'undefined' || document === null) return false
567
+ if (typeof document.createElement !== 'function') return false
568
+ const area = document.createElement('textarea')
569
+ area.value = text
570
+ area.setAttribute('readonly', 'true')
571
+ area.style.position = 'fixed'
572
+ area.style.top = '-1000px'
573
+ const body = document.body
574
+ if (body !== null && body !== undefined) body.appendChild(area)
575
+ if (typeof area.select === 'function') area.select()
576
+ const ok = typeof document.execCommand === 'function' ? document.execCommand('copy') === true : false
577
+ if (body !== null && body !== undefined) body.removeChild(area)
578
+ return ok
579
+ } catch (error) {
580
+ return false
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Whether a URL belongs to an identity provider that blocks embedded OAuth.
586
+ *
587
+ * @param {string} url
588
+ * @returns {boolean}
589
+ */
590
+ function isOAuthUrl(url) {
591
+ if (typeof url !== 'string') return false
592
+ try {
593
+ const parsed = new URL(url)
594
+ return OAUTH_HOSTS.indexOf(parsed.hostname) !== -1
595
+ } catch (error) {
596
+ return false
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Google Identity Services ends a popup sign-in on a `gsi/transform` page
602
+ * whose only job is `window.opener.postMessage(...)` followed by close.
603
+ * Replayed in a normal tab it has no opener to talk to and never finishes,
604
+ * so it is a dead end rather than a blank page to retry.
605
+ *
606
+ * @param {string} url
607
+ * @returns {boolean}
608
+ */
609
+ function isOAuthDeadEnd(url) {
610
+ if (typeof url !== 'string' || url === '') return false
611
+ return url.indexOf('accounts.google.com/gsi/') !== -1
612
+ }
613
+
614
+ /**
615
+ * @param {string} url
616
+ * @returns {string | undefined}
617
+ */
618
+ function hostOf(url) {
619
+ if (typeof url !== 'string' || url === '') return undefined
620
+ try {
621
+ return new URL(url).hostname
622
+ } catch (error) {
623
+ return undefined
624
+ }
625
+ }
626
+
627
+ /** @param {string} id */
628
+ function findProvider(id) { for (const provider of PROVIDERS) if (provider.id === id) return provider
629
+ return undefined
630
+ }
631
+
632
+ /**
633
+ * Whether a guest URL means "the page really opened". Any http(s) document
634
+ * counts: a provider is free to redirect to its own login host without that
635
+ * being a failure.
636
+ *
637
+ * @param {string} url
638
+ */
639
+ function isLiveUrl(url) {
640
+ return typeof url === 'string' && url.indexOf('https://') === 0
641
+ }
642
+
643
+ // ------------------------------------------------------------------
644
+ // Store: one revision counter for status, surface and active tab
645
+ // ------------------------------------------------------------------
646
+
647
+ // ------------------------------------------------------------------
648
+ // Debug log
649
+ //
650
+ // Off by default: nothing is printed and nothing is buffered until the
651
+ // user turns it on from the settings menu (one click) or from a probe.
652
+ // While on, events go to the console and to a small ring buffer that the
653
+ // diagnostics card can copy, which is what makes a bug report useful.
654
+ // ------------------------------------------------------------------
655
+
656
+ /** localStorage key for the on/off switch. */
657
+ const LOG_KEY = 'dsh-chat-embed:log'
658
+
659
+ /** localStorage key remembering how the last guest was partitioned. */
660
+ const PERSIST_KEY = 'dsh-chat-embed:persist'
661
+
662
+ /**
663
+ * localStorage key remembering that a persistent partition was seen once.
664
+ * A shell update replaces the patch, so "was on, now off" is a lost patch
665
+ * rather than a first run: the difference is what the settings row reports.
666
+ */
667
+ const PERSIST_SEEN_KEY = 'dsh-chat-embed:persist-seen'
668
+
669
+ /** How many entries the ring buffer keeps. */
670
+ const LOG_LIMIT = 200
671
+
672
+ /** @returns {boolean} the persisted switch; off unless the user opted in */
673
+ function readStoredLogging() {
674
+ try {
675
+ if (typeof localStorage === 'undefined' || localStorage === null) return false
676
+ return localStorage.getItem(LOG_KEY) === '1'
677
+ } catch (error) {
678
+ void error
679
+ return false
680
+ }
681
+ }
682
+
683
+ let logEnabled = readStoredLogging()
684
+
685
+ /** @type {{ seq: number, time: string, level: string, message: string, detail: any }[]} */
686
+ let logEntries = []
687
+
688
+ let logSeq = 0
689
+
690
+ /** @returns {boolean} whether the debug log is on */
691
+ function getLogging() {
692
+ return logEnabled
693
+ }
694
+
695
+ /** @param {boolean} enabled @returns {boolean} the resulting switch */
696
+ function setLogging(enabled) {
697
+ const next = enabled === true
698
+ logEnabled = next
699
+ try {
700
+ if (typeof localStorage !== 'undefined' && localStorage !== null) localStorage.setItem(LOG_KEY, next ? '1' : '0')
701
+ } catch (error) {
702
+ void error
703
+ }
704
+ if (next) writeLog('info', 'debug log enabled', undefined)
705
+ notify()
706
+ return logEnabled
707
+ }
708
+
709
+ function clearLog() {
710
+ logEntries = []
711
+ logSeq = 0
712
+ }
713
+
714
+ /** @returns {typeof logEntries} a copy of the ring buffer */
715
+ function readLogEntries() {
716
+ return logEntries.slice()
717
+ }
718
+
719
+ /**
720
+ * Record one event; a no-op while the switch is off.
721
+ *
722
+ * @param {string} level
723
+ * @param {string} message
724
+ * @param {any} [detail]
725
+ */
726
+ function log(level, message, detail) {
727
+ if (logEnabled !== true) return
728
+ writeLog(level, message, detail)
729
+ }
730
+
731
+ function writeLog(level, message, detail) {
732
+ logSeq += 1
733
+ const entry = {
734
+ seq: logSeq,
735
+ time: new Date().toISOString(),
736
+ level,
737
+ message,
738
+ detail: detail === undefined ? null : detail,
739
+ }
740
+ logEntries.push(entry)
741
+ if (logEntries.length > LOG_LIMIT) logEntries = logEntries.slice(logEntries.length - LOG_LIMIT)
742
+ const text = '[dsh-chat-embed] ' + entry.time + ' ' + entry.level.toUpperCase() + ' ' + entry.message
743
+ try {
744
+ if (typeof console === 'undefined' || console === null) return
745
+ if (level === 'error') console.error(text, entry.detail === null ? '' : entry.detail)
746
+ else if (level === 'warn') console.warn(text, entry.detail === null ? '' : entry.detail)
747
+ else console.log(text, entry.detail === null ? '' : entry.detail)
748
+ } catch (error) {
749
+ void error
750
+ }
751
+ }
752
+ const watchers = new Set()
753
+
754
+ function notify() {
755
+ for (const watcher of Array.from(watchers)) {
756
+ try {
757
+ watcher()
758
+ } catch (error) {
759
+ void error
760
+ }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * @param {() => void} watcher
766
+ * @returns {() => void}
767
+ */
768
+ function watch(watcher) {
769
+ watchers.add(watcher)
770
+ return () => {
771
+ watchers.delete(watcher)
772
+ }
773
+ }
774
+
775
+ // ------------------------------------------------------------------
776
+ // Active tab
777
+ // ------------------------------------------------------------------
778
+
779
+ function readStoredActive() {
780
+ try {
781
+ if (typeof localStorage === 'undefined' || localStorage === null) return DEFAULT_PROVIDER
782
+ const stored = localStorage.getItem(ACTIVE_TAB_KEY)
783
+ return findProvider(stored) === undefined ? DEFAULT_PROVIDER : stored
784
+ } catch (error) {
785
+ return DEFAULT_PROVIDER
786
+ }
787
+ }
788
+
789
+ /** @param {string} id */
790
+ function storeActive(id) {
791
+ try {
792
+ if (typeof localStorage === 'undefined' || localStorage === null) return
793
+ localStorage.setItem(ACTIVE_TAB_KEY, id)
794
+ } catch (error) {
795
+ void error
796
+ }
797
+ }
798
+
799
+ // ------------------------------------------------------------------
800
+ // Tab order (drag to reorder)
801
+ // ------------------------------------------------------------------
802
+
803
+ /** @param {string[]} list */
804
+ function normalizeOrder(list) {
805
+ const known = new Set(PROVIDERS.map((provider) => provider.id))
806
+ const seen = new Set()
807
+ const result = []
808
+ for (const id of list) {
809
+ if (!known.has(id) || seen.has(id)) continue
810
+ seen.add(id)
811
+ result.push(id)
812
+ }
813
+ for (const provider of PROVIDERS) {
814
+ if (!seen.has(provider.id)) result.push(provider.id)
815
+ }
816
+ return result
817
+ }
818
+
819
+ /** @returns {string[]} */
820
+ function readStoredOrder() {
821
+ try {
822
+ if (typeof localStorage === 'undefined' || localStorage === null) return []
823
+ const raw = localStorage.getItem(TAB_ORDER_KEY)
824
+ if (raw === null) return []
825
+ const parsed = JSON.parse(raw)
826
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
827
+ } catch (error) {
828
+ return []
829
+ }
830
+ }
831
+
832
+ /** Provider ids in the user's order. */
833
+ let tabOrder = normalizeOrder(readStoredOrder())
834
+
835
+ /** @returns {object[]} every provider in the user's order (enabled or not). */
836
+ function providersInOrder() {
837
+ const result = []
838
+ for (const id of tabOrder) {
839
+ const provider = findProvider(id)
840
+ if (provider !== undefined) result.push(provider)
841
+ }
842
+ return result
843
+ }
844
+
845
+ /** @returns {object[]} the providers whose tabs are currently shown. */
846
+ function orderedProviders() {
847
+ return providersInOrder().filter((provider) => isEnabled(provider.id))
848
+ }
849
+
850
+ /** @returns {string[]} */
851
+ function getTabOrder() {
852
+ return tabOrder.slice()
853
+ }
854
+
855
+ // ------------------------------------------------------------------
856
+ // Enabled providers (settings menu)
857
+ // ------------------------------------------------------------------
858
+
859
+ /**
860
+ * Keep only known ids, drop duplicates, and fall back to "all enabled" so a
861
+ * corrupt or empty preference never leaves the panel without tabs.
862
+ *
863
+ * @param {string[]} list
864
+ * @returns {string[]}
865
+ */
866
+ function normalizeEnabled(list) {
867
+ const known = new Set(PROVIDERS.map((provider) => provider.id))
868
+ const seen = new Set()
869
+ const result = []
870
+ for (const id of list) {
871
+ if (!known.has(id) || seen.has(id)) continue
872
+ seen.add(id)
873
+ result.push(id)
874
+ }
875
+ return result.length === 0 ? PROVIDERS.map((provider) => provider.id) : result
876
+ }
877
+
878
+ /** @returns {string[]} */
879
+ function readStoredEnabled() {
880
+ try {
881
+ if (typeof localStorage === 'undefined' || localStorage === null) return []
882
+ const raw = localStorage.getItem(ENABLED_KEY)
883
+ if (raw === null) return []
884
+ const parsed = JSON.parse(raw)
885
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
886
+ } catch (error) {
887
+ return []
888
+ }
889
+ }
890
+
891
+ /** Enabled provider ids, normalised once at startup. */
892
+ let enabledIds = normalizeEnabled(readStoredEnabled())
893
+
894
+ /** @param {string} id */
895
+ function isEnabled(id) {
896
+ return enabledIds.indexOf(id) !== -1
897
+ }
898
+
899
+ /** @returns {string[]} */
900
+ function getEnabled() {
901
+ return enabledIds.slice()
902
+ }
903
+
904
+ /** @returns {string} */
905
+ function firstEnabled() {
906
+ return enabledIds.length === 0 ? DEFAULT_PROVIDER : enabledIds[0]
907
+ }
908
+
909
+ function storeEnabled() {
910
+ try {
911
+ if (typeof localStorage === 'undefined' || localStorage === null) return
912
+ localStorage.setItem(ENABLED_KEY, JSON.stringify(enabledIds))
913
+ } catch (error) {
914
+ void error
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Show or hide one provider's tab. The last enabled provider cannot be
920
+ * hidden; hiding the active one moves the panel to the next enabled tab.
921
+ *
922
+ * @param {string} id
923
+ * @param {boolean} on
924
+ * @returns {boolean} whether the change was applied
925
+ */
926
+ function setProviderEnabled(id, on) {
927
+ const provider = findProvider(id)
928
+ if (provider === undefined) return false
929
+ const currently = isEnabled(id)
930
+ if (currently === on) return true
931
+ log('info', 'provider toggled', { provider: id, on: on === true })
932
+ if (on === false && enabledIds.length <= 1) return false
933
+
934
+ if (on) {
935
+ // Re-insert following the user's tab order, not at the end.
936
+ enabledIds = normalizeEnabled(tabOrder.filter((entryId) => entryId === id || isEnabled(entryId)))
937
+ } else {
938
+ enabledIds = enabledIds.filter((entryId) => entryId !== id)
939
+ const record = guestRecord(id)
940
+ if (record.element !== undefined && record.element !== null) dropGuest(record)
941
+ }
942
+ storeEnabled()
943
+
944
+ if (on === false && state.active === id) {
945
+ const next = firstEnabled()
946
+ state.active = next
947
+ storeActive(next)
948
+ syncLayers()
949
+ notify()
950
+ void ensureActiveGuest()
951
+ return true
952
+ }
953
+ notify()
954
+ if (on) void ensureActiveGuest()
955
+ return true
956
+ }
957
+
958
+ /** Move the active tab to a still-enabled provider when it was hidden. */
959
+ function reconcileActive() {
960
+ if (isEnabled(state.active)) return
961
+ const next = findProvider(firstEnabled())
962
+ if (next === undefined) return
963
+ state.active = next.id
964
+ storeActive(next.id)
965
+ }
966
+
967
+ /**
968
+ * Move a tab to a new index and remember the order.
969
+ *
970
+ * @param {string} fromId
971
+ * @param {number} toIndex
972
+ */
973
+ function moveProvider(fromId, toIndex) {
974
+ const from = tabOrder.indexOf(fromId)
975
+ if (from === -1) return
976
+ const next = tabOrder.filter((id) => id !== fromId)
977
+ const index = Math.max(0, Math.min(next.length, toIndex))
978
+ next.splice(index, 0, fromId)
979
+ if (next.join('\u0000') === tabOrder.join('\u0000')) return
980
+ tabOrder = next
981
+ try {
982
+ if (typeof localStorage !== 'undefined' && localStorage !== null) {
983
+ localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabOrder))
984
+ }
985
+ } catch (error) {
986
+ void error
987
+ }
988
+ notify()
989
+ }
990
+
991
+ /** @type {{ active: string, surface: { host: any } }} */
992
+ const state = {
993
+ active: readStoredActive(),
994
+ surface: { host: null },
995
+ }
996
+
997
+ reconcileActive()
998
+
999
+ /** @returns {object} the active provider record. */
1000
+ function activeProvider() {
1001
+ const provider = findProvider(state.active)
1002
+ if (provider !== undefined && isEnabled(provider.id)) return provider
1003
+ const fallback = findProvider(firstEnabled())
1004
+ return fallback !== undefined ? fallback : PROVIDERS[0]
1005
+ }
1006
+
1007
+ // ------------------------------------------------------------------
1008
+ // Guest registry (one guest per provider)
1009
+ // ------------------------------------------------------------------
1010
+
1011
+ /**
1012
+ * @typedef {object} GuestRecord
1013
+ * @property {any} element
1014
+ * @property {string | undefined} lease
1015
+ * @property {string | undefined} partition
1016
+ * @property {any} layer
1017
+ * @property {boolean} mounting
1018
+ * @property {number} generation
1019
+ * @property {number} mounts
1020
+ * @property {boolean} navigated
1021
+ * @property {string} phase
1022
+ * @property {string} detail
1023
+ * @property {{ ready: Function, failed: Function } | undefined} handlers
1024
+ * @property {(() => void) | undefined} popupOff
1025
+ * @property {boolean} oauthPending
1026
+ * @property {any} recoveryTimer
1027
+ * @property {string} lastUrl
1028
+ * @property {string} lastEvent
1029
+ * @property {boolean} diagOpen
1030
+ * @property {boolean} oauthBlocked
1031
+ * @property {any} reloadTimer
1032
+ * @property {string} handoff
1033
+ */
1034
+
1035
+ /** @type {Map<string, GuestRecord>} */
1036
+ const guests = new Map()
1037
+
1038
+ /**
1039
+ * The last page each service was on, kept outside the guest record so it
1040
+ * survives a guest teardown: closing and reopening the tab then returns to
1041
+ * the same conversation instead of the service home page.
1042
+ *
1043
+ * @type {Map<string, string>}
1044
+ */
1045
+ const lastUrls = new Map()
1046
+
1047
+ /**
1048
+ * Where a freshly built guest should open.
1049
+ *
1050
+ * @param {any} provider
1051
+ * @returns {string}
1052
+ */
1053
+ function startUrlFor(provider) {
1054
+ const remembered = lastUrls.get(provider.id)
1055
+ if (typeof remembered !== 'string' || remembered === '') return provider.url
1056
+ if (!remembered.startsWith(provider.origin)) return provider.url
1057
+ if (!isLiveUrl(remembered)) return provider.url
1058
+ if (isOAuthDeadEnd(remembered)) return provider.url
1059
+ return remembered
1060
+ }
1061
+
1062
+ /** @returns {GuestRecord} */
1063
+ function guestRecord(id) {
1064
+ let record = guests.get(id)
1065
+ if (record === undefined) {
1066
+ record = {
1067
+ element: undefined,
1068
+ lease: undefined,
1069
+ partition: undefined,
1070
+ layer: undefined,
1071
+ mounting: false,
1072
+ generation: 0,
1073
+ mounts: 0,
1074
+ navigated: false,
1075
+ phase: PHASE.IDLE,
1076
+ detail: '',
1077
+ handlers: undefined,
1078
+ popupOff: undefined,
1079
+ oauthPending: false,
1080
+ recoveryTimer: undefined,
1081
+ lastUrl: '',
1082
+ lastEvent: '',
1083
+ diagOpen: false,
1084
+ oauthBlocked: false,
1085
+ reloadTimer: undefined,
1086
+ handoff: '',
1087
+ selfTest: '',
1088
+ }
1089
+ guests.set(id, record)
1090
+ }
1091
+ return record
1092
+ }
1093
+
1094
+ /** @returns {{ phase: string, detail: string }} */
1095
+ function getStatus() {
1096
+ const record = guestRecord(state.active)
1097
+ return { phase: record.phase, detail: record.detail }
1098
+ }
1099
+
1100
+ /**
1101
+ * @param {string} id
1102
+ * @param {string} phase
1103
+ * @param {string} [detail]
1104
+ */
1105
+ function setPhase(id, phase, detail) {
1106
+ const record = guestRecord(id)
1107
+ if (record.phase === phase && record.detail === (detail === undefined ? '' : detail)) return
1108
+ record.phase = phase
1109
+ record.detail = detail === undefined ? '' : detail
1110
+ notify()
1111
+ }
1112
+
1113
+ /**
1114
+ * Hand a lease back to the shell. Failures are swallowed on purpose: a
1115
+ * rejected release must never leave the panel stuck in a failed state.
1116
+ *
1117
+ * @param {string | undefined} lease
1118
+ */
1119
+ function releaseLease(lease) {
1120
+ const bridge = desktopBrowser()
1121
+ if (bridge === undefined || typeof lease !== 'string' || lease === '') return
1122
+ try {
1123
+ const pending = bridge.release(lease)
1124
+ if (pending !== undefined && pending !== null && typeof pending.catch === 'function') {
1125
+ pending.catch(() => {})
1126
+ }
1127
+ } catch (error) {
1128
+ void error
1129
+ }
1130
+ }
1131
+
1132
+ /** @param {GuestRecord} record */
1133
+ function dropGuest(record) {
1134
+ const element = record.element
1135
+ const lease = record.lease
1136
+ const handlers = record.handlers
1137
+ const popupOff = record.popupOff
1138
+
1139
+ record.element = undefined
1140
+ record.lease = undefined
1141
+ record.partition = undefined
1142
+ record.navigated = false
1143
+ record.handlers = undefined
1144
+ record.popupOff = undefined
1145
+ record.oauthPending = false
1146
+ record.lastUrl = ''
1147
+ record.lastEvent = ''
1148
+ record.diagOpen = false
1149
+ record.selfTest = ''
1150
+ // `oauthBlocked` is deliberately kept: the explanation must survive the
1151
+ // recovery reload that replaces the stuck guest.
1152
+
1153
+ if (record.reloadTimer !== undefined) {
1154
+ try {
1155
+ clearTimeout(record.reloadTimer)
1156
+ } catch (error) {
1157
+ void error
1158
+ }
1159
+ record.reloadTimer = undefined
1160
+ }
1161
+
1162
+ if (record.recoveryTimer !== undefined) {
1163
+ try {
1164
+ clearTimeout(record.recoveryTimer)
1165
+ } catch (error) {
1166
+ void error
1167
+ }
1168
+ record.recoveryTimer = undefined
1169
+ }
1170
+
1171
+ if (typeof popupOff === 'function') {
1172
+ try {
1173
+ popupOff()
1174
+ } catch (error) {
1175
+ void error
1176
+ }
1177
+ }
1178
+
1179
+ if (element !== undefined && element !== null) {
1180
+ try {
1181
+ if (handlers !== undefined) {
1182
+ element.removeEventListener('dom-ready', handlers.ready)
1183
+ element.removeEventListener('did-fail-load', handlers.failed)
1184
+ element.removeEventListener('did-navigate', handlers.navigate)
1185
+ element.removeEventListener('did-navigate-in-page', handlers.navigateInPage)
1186
+ element.removeEventListener('did-stop-loading', handlers.stop)
1187
+ element.removeEventListener('render-process-gone', handlers.crash)
1188
+ element.removeEventListener('crashed', handlers.crash)
1189
+ }
1190
+ } catch (error) {
1191
+ void error
1192
+ }
1193
+ try {
1194
+ if (element.parentNode !== null && element.parentNode !== undefined) {
1195
+ element.parentNode.removeChild(element)
1196
+ } else if (typeof element.remove === 'function') {
1197
+ element.remove()
1198
+ }
1199
+ } catch (error) {
1200
+ void error
1201
+ }
1202
+ }
1203
+ if (lease !== undefined) releaseLease(lease)
1204
+
1205
+ const layer = record.layer
1206
+ record.layer = undefined
1207
+ if (layer !== undefined && layer !== null && layer.isConnected !== false) {
1208
+ try {
1209
+ if (layer.parentNode !== null && layer.parentNode !== undefined) {
1210
+ layer.parentNode.removeChild(layer)
1211
+ }
1212
+ } catch (error) {
1213
+ void error
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * @param {any} element
1220
+ * @returns {string}
1221
+ */
1222
+ function guestUrl(element) {
1223
+ if (element === undefined || element === null || typeof element.getURL !== 'function') return ''
1224
+ try {
1225
+ const url = element.getURL()
1226
+ return typeof url === 'string' ? url : ''
1227
+ } catch (error) {
1228
+ return ''
1229
+ }
1230
+ }
1231
+
1232
+ /** @param {object} provider */
1233
+ function layerFor(provider) {
1234
+ const record = guestRecord(provider.id)
1235
+ if (record.layer !== undefined && record.layer !== null && record.layer.isConnected !== false) {
1236
+ return record.layer
1237
+ }
1238
+ const host = state.surface.host
1239
+ if (host === null || host === undefined) return undefined
1240
+ const layer = document.createElement('div')
1241
+ layer.className = 'dce-guest-layer'
1242
+ layer.setAttribute('data-provider', provider.id)
1243
+ layer.setAttribute('data-active', provider.id === state.active ? 'true' : 'false')
1244
+ host.appendChild(layer)
1245
+ record.layer = layer
1246
+ return layer
1247
+ }
1248
+
1249
+ /** Show only the active provider's layer; hidden layers keep their guest. */
1250
+ function syncLayers() {
1251
+ for (const provider of PROVIDERS) {
1252
+ const record = guests.get(provider.id)
1253
+ if (record === undefined || record.layer === undefined || record.layer === null) continue
1254
+ const want = provider.id === state.active ? 'true' : 'false'
1255
+ if (record.layer.getAttribute('data-active') !== want) record.layer.setAttribute('data-active', want)
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * Create a provider's guest the first time its tab is shown (idempotent).
1261
+ *
1262
+ * @param {string} id
1263
+ * @returns {Promise<void>}
1264
+ */
1265
+ async function mountGuest(id) {
1266
+ const provider = findProvider(id)
1267
+ if (provider === undefined) return
1268
+ const record = guestRecord(id)
1269
+
1270
+ if (record.mounting === true) return
1271
+ if (record.element !== undefined && record.element !== null) {
1272
+ if (record.element.isConnected !== false) return
1273
+ // The host was torn down underneath the guest; rebuild it.
1274
+ dropGuest(record)
1275
+ }
1276
+
1277
+ const layer = layerFor(provider)
1278
+ if (layer === undefined) return
1279
+
1280
+ record.mounting = true
1281
+ record.generation += 1
1282
+ const generation = record.generation
1283
+
1284
+ try {
1285
+ const bridge = desktopBrowser()
1286
+ if (bridge === undefined) {
1287
+ setPhase(id, PHASE.UNSUPPORTED, '')
1288
+ return
1289
+ }
1290
+
1291
+ setPhase(id, PHASE.ACQUIRING, '')
1292
+ let lease
1293
+ let partition
1294
+ try {
1295
+ const acquired = await bridge.acquire(provider.workspace)
1296
+ lease = acquired === null || acquired === undefined ? undefined : acquired.lease
1297
+ partition = acquired === null || acquired === undefined ? undefined : acquired.partition
1298
+ if (typeof lease !== 'string' || lease === '') throw new Error('desktop shell returned no lease')
1299
+ if (typeof partition !== 'string' || partition === '') {
1300
+ throw new Error('desktop shell returned no partition')
1301
+ }
1302
+ } catch (error) {
1303
+ if (generation === record.generation) setPhase(id, PHASE.FAILED, messageOf(error))
1304
+ return
1305
+ }
1306
+
1307
+ log('info', 'lease acquired', { provider: id, partition: shortId(partition) })
1308
+
1309
+ // Teardown or reload happened while the lease was in flight.
1310
+ if (generation !== record.generation) {
1311
+ releaseLease(lease)
1312
+ return
1313
+ }
1314
+
1315
+ const element = document.createElement('webview')
1316
+ element.className = 'dce-guest'
1317
+ element.setAttribute('src', 'about:blank#' + lease)
1318
+ element.setAttribute('partition', partition)
1319
+ element.setAttribute('useragent', currentUserAgent())
1320
+ // OAuth buttons ("Continue with Google") are popups. Without this the
1321
+ // request dies inside the guest and the site reports a generic sign-in
1322
+ // error; with it, the shell relays the URL to us instead of creating a
1323
+ // window, and `onGuestPopup` continues the flow in place.
1324
+ element.setAttribute('allowpopups', '')
1325
+ element.style.cssText = 'display:inline-flex;width:100%;height:100%;border:0;background:transparent;'
1326
+
1327
+ const handlers = {
1328
+ ready: (event) => onGuestReady(id, event),
1329
+ failed: (event) => onGuestFailed(id, event),
1330
+ navigate: (event) => onGuestNavigated(id, event !== undefined && event !== null ? event.url : ''),
1331
+ navigateInPage: (event) => onGuestLocation(id, event !== undefined && event !== null ? event.url : ''),
1332
+ stop: () => onGuestStopped(id),
1333
+ crash: () => onGuestCrashed(id),
1334
+ }
1335
+ element.addEventListener('dom-ready', handlers.ready)
1336
+ element.addEventListener('did-fail-load', handlers.failed)
1337
+ element.addEventListener('did-navigate', handlers.navigate)
1338
+ element.addEventListener('did-navigate-in-page', handlers.navigateInPage)
1339
+ element.addEventListener('did-stop-loading', handlers.stop)
1340
+ element.addEventListener('render-process-gone', handlers.crash)
1341
+ element.addEventListener('crashed', handlers.crash)
1342
+
1343
+ record.element = element
1344
+ record.lease = lease
1345
+ record.partition = partition
1346
+ record.navigated = false
1347
+ record.handlers = handlers
1348
+ record.mounts += 1
1349
+
1350
+ try {
1351
+ layer.appendChild(element)
1352
+ } catch (error) {
1353
+ dropGuest(record)
1354
+ setPhase(id, PHASE.FAILED, messageOf(error))
1355
+ return
1356
+ }
1357
+
1358
+ // Popup requests are relayed per lease; keep the disposer with the guest
1359
+ // so a reload or teardown cannot leave a listener behind.
1360
+ if (typeof bridge.onOpenRequested === 'function') {
1361
+ try {
1362
+ record.popupOff = bridge.onOpenRequested(lease, (url) => onGuestPopup(id, url))
1363
+ } catch (error) {
1364
+ record.popupOff = undefined
1365
+ }
1366
+ }
1367
+ setPhase(id, PHASE.NAVIGATING, '')
1368
+ } finally {
1369
+ record.mounting = false
1370
+ syncLayers()
1371
+ }
1372
+ }
1373
+
1374
+ /**
1375
+ * Navigate a guest to its provider's entry URL.
1376
+ *
1377
+ * `loadURL()` and `src` writes are ignored after attachment; only a
1378
+ * document-level navigation from inside the guest moves it. Retries cover
1379
+ * the first `dom-ready` arriving before the shell registered the lease.
1380
+ *
1381
+ * @param {string} id
1382
+ * @param {any} element
1383
+ * @returns {Promise<void>}
1384
+ */
1385
+ async function navigateGuest(id, element) {
1386
+ const provider = findProvider(id)
1387
+ if (provider === undefined) return
1388
+ const record = guestRecord(id)
1389
+
1390
+ for (let attempt = 0; attempt < NAVIGATE_MAX_ATTEMPTS; attempt += 1) {
1391
+ if (element !== record.element) return
1392
+ try {
1393
+ await element.executeJavaScript('location.href = ' + JSON.stringify(startUrlFor(provider)) + ';')
1394
+ } catch (error) {
1395
+ // The guest document is not executing yet; keep trying.
1396
+ }
1397
+ await delay(NAVIGATE_RETRY_MS)
1398
+ if (element !== record.element) return
1399
+ const landed = guestUrl(element)
1400
+ if (isLiveUrl(landed)) {
1401
+ if (typeof landed === 'string' && landed !== '') lastUrls.set(id, landed)
1402
+ setPhase(id, PHASE.READY, '')
1403
+ return
1404
+ }
1405
+ }
1406
+ if (element !== record.element) return
1407
+ setPhase(id, PHASE.FAILED, 'navigation-timeout')
1408
+ }
1409
+
1410
+ /**
1411
+ * @param {string} id
1412
+ * @param {any} event
1413
+ */
1414
+ function onGuestReady(id, event) {
1415
+ const record = guestRecord(id)
1416
+ const element =
1417
+ event !== undefined && event !== null && event.currentTarget !== undefined ? event.currentTarget : record.element
1418
+ if (element === undefined || element !== record.element) return
1419
+ if (record.navigated) return
1420
+ record.navigated = true
1421
+ void navigateGuest(id, element)
1422
+ }
1423
+
1424
+ /**
1425
+ * @param {string} id
1426
+ * @param {any} event
1427
+ */
1428
+ function onGuestFailed(id, event) {
1429
+ const record = guestRecord(id)
1430
+ const element =
1431
+ event !== undefined && event !== null && event.currentTarget !== undefined ? event.currentTarget : record.element
1432
+ if (element === undefined || element !== record.element) return
1433
+ if (event !== undefined && event !== null && event.errorCode === -3) return
1434
+ setPhase(id, PHASE.FAILED, 'load-failed')
1435
+ log('warn', 'guest failed to load', { provider: id })
1436
+ }
1437
+
1438
+ /**
1439
+ * Remember where the guest is (in-page navigations included).
1440
+ *
1441
+ * @param {string} id
1442
+ * @param {string} url
1443
+ */
1444
+ function onGuestLocation(id, url) {
1445
+ const record = guestRecord(id)
1446
+ if (typeof url === 'string' && url !== '') {
1447
+ record.lastUrl = url
1448
+ lastUrls.set(id, url)
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * Describe — never dump — the credential Google left on the handoff page.
1454
+ *
1455
+ * Records only the shape (hash/query length and top-level key names) so the
1456
+ * diagnostics card can answer "is interception even possible here?" without
1457
+ * copying a live token anywhere.
1458
+ *
1459
+ * @param {any} element
1460
+ * @param {GuestRecord} record
1461
+ * @returns {Promise<void>}
1462
+ */
1463
+ async function inspectOAuthHandoff(element, record) {
1464
+ try {
1465
+ const raw = await Promise.race([
1466
+ element.executeJavaScript(
1467
+ '(function(){try{var h=String(location.hash||"");var q=String(location.search||"");var keys=[];' +
1468
+ 'if(h.length>1){try{var o=JSON.parse(decodeURIComponent(h.slice(1)));if(o&&typeof o==="object")keys=Object.keys(o)}catch(e){}}' +
1469
+ 'return JSON.stringify({hash:h.length,search:q.length,keys:keys.slice(0,10)})}catch(e){return ""}})()',
1470
+ ),
1471
+ delay(TIMINGS.oauthHandoffProbeMs).then(() => ''),
1472
+ ])
1473
+ record.lastEvent =
1474
+ typeof raw === 'string' && raw !== '' ? 'oauth-dead-end ' + raw : 'oauth-dead-end (no js response)'
1475
+ // Kept on the record (not reset with the guest) so the diagnostics card
1476
+ // still explains the handoff after the recovery reload.
1477
+ record.handoff = typeof raw === 'string' && raw !== '' ? raw : '(无 JS 返回)'
1478
+ notify()
1479
+ } catch (error) {
1480
+ record.lastEvent = 'oauth-dead-end (probe failed)'
1481
+ record.handoff = '(探测失败)'
1482
+ notify()
1483
+ }
1484
+ }
1485
+
1486
+ /**
1487
+ * A load finished. Every load gets one blank inspection — this is what turns
1488
+ * an unreadable white page into a card with buttons, even when the OAuth
1489
+ * heuristic above never armed.
1490
+ *
1491
+ * @param {string} id
1492
+ */
1493
+ function onGuestStopped(id) {
1494
+ const record = guestRecord(id)
1495
+ const element = record.element
1496
+ if (element === undefined || element === null) return
1497
+ const url = guestUrl(element)
1498
+ record.lastUrl = url
1499
+ if (typeof url === 'string' && url !== '' && isLiveUrl(url)) lastUrls.set(id, url)
1500
+ record.lastEvent = 'did-stop-loading'
1501
+
1502
+ // Google's popup-handoff page cannot finish without an opener: return to
1503
+ // a usable page and explain, instead of inspecting a white box.
1504
+ if (isOAuthDeadEnd(url)) {
1505
+ record.lastEvent = 'oauth-dead-end'
1506
+ record.oauthBlocked = true
1507
+ record.oauthPending = false
1508
+ notify()
1509
+ // Best effort only: describe the handoff page for the diagnostics card.
1510
+ void inspectOAuthHandoff(element, record)
1511
+ // Recovery must not depend on that page executing scripts — replace the
1512
+ // guest instead, which is the only reliable way out of a dead document.
1513
+ if (record.reloadTimer !== undefined) {
1514
+ try {
1515
+ clearTimeout(record.reloadTimer)
1516
+ } catch (error) {
1517
+ void error
1518
+ }
1519
+ }
1520
+ record.reloadTimer = setTimeout(() => {
1521
+ record.reloadTimer = undefined
1522
+ if (record.element !== element) return
1523
+ void reloadGuest(id)
1524
+ }, TIMINGS.oauthRecoveryReloadMs)
1525
+ return
1526
+ }
1527
+
1528
+ // The initial `about:blank#<lease>` load is blank by design.
1529
+ if (url.indexOf('about:') === 0) return
1530
+ notify()
1531
+ }
1532
+
1533
+ /**
1534
+ * The guest's renderer died — surface it instead of leaving a white box.
1535
+ *
1536
+ * @param {string} id
1537
+ */
1538
+ function onGuestCrashed(id) {
1539
+ const record = guestRecord(id)
1540
+ if (record.element === undefined || record.element === null) return
1541
+ setPhase(id, PHASE.FAILED, 'renderer-gone')
1542
+ record.lastEvent = 'renderer-gone'
1543
+ log('error', 'renderer gone', { provider: id })
1544
+ notify()
1545
+ }
1546
+
1547
+ /**
1548
+ * A guest asked to open a window (OAuth button, "open in new window" link).
1549
+ *
1550
+ * The desktop shell never creates that window: it validates the URL against
1551
+ * its own navigation policy and hands it here instead. A plugin cannot spawn
1552
+ * a real popup, so the flow continues **in place** — that is exactly what an
1553
+ * OAuth redirect expects, and it keeps the sign-in inside the same partition.
1554
+ *
1555
+ * @param {string} id
1556
+ * @param {string} url
1557
+ */
1558
+ function onGuestPopup(id, url) {
1559
+ const record = guestRecord(id)
1560
+ const element = record.element
1561
+ if (element === undefined || element === null) return
1562
+ if (typeof url !== 'string' || url.indexOf('https://') !== 0) return
1563
+
1564
+ // Google/Apple complete sign-in but their callback page cannot finish in
1565
+ // an embedded WebContents, so arm the dead-end tracking from here.
1566
+ if (isOAuthUrl(url)) {
1567
+ record.oauthPending = true
1568
+ notify()
1569
+ }
1570
+
1571
+ navigateInPlace(element, url)
1572
+ }
1573
+
1574
+ /**
1575
+ * Track where a guest lands.
1576
+ *
1577
+ * OAuth can start as a popup (handled in `onGuestPopup`) *or* as a plain
1578
+ * full-page redirect, so the identity-provider hop is detected here too.
1579
+ * When the roundtrip comes back to the site's own origin, recovery is
1580
+ * scheduled.
1581
+ *
1582
+ * @param {string} id
1583
+ * @param {string} url
1584
+ */
1585
+ function onGuestNavigated(id, url) {
1586
+ const record = guestRecord(id)
1587
+ const provider = findProvider(id)
1588
+ if (provider === undefined) return
1589
+ const host = hostOf(url)
1590
+ if (host === undefined) return
1591
+
1592
+ if (isOAuthUrl(url)) {
1593
+ if (record.oauthPending !== true) {
1594
+ record.oauthPending = true
1595
+ notify()
1596
+ }
1597
+ return
1598
+ }
1599
+
1600
+ if (record.oauthPending !== true) return
1601
+ if (host !== hostOf(provider.origin)) return
1602
+ record.oauthPending = false
1603
+ scheduleOAuthRecovery(id, provider)
1604
+ }
1605
+
1606
+ /** @param {GuestRecord} record */
1607
+ function clearRecoveryTimer(record) {
1608
+ if (record.recoveryTimer === undefined) return
1609
+ try {
1610
+ clearTimeout(record.recoveryTimer)
1611
+ } catch (error) {
1612
+ void error
1613
+ }
1614
+ record.recoveryTimer = undefined
1615
+ }
1616
+
1617
+ /**
1618
+ * Continue a document in place — the only navigation a leased guest honors.
1619
+ *
1620
+ * @param {any} element
1621
+ * @param {string} url
1622
+ */
1623
+ function navigateInPlace(element, url) {
1624
+ try {
1625
+ const pending = element.executeJavaScript('location.href = ' + JSON.stringify(url) + ';')
1626
+ if (pending !== undefined && pending !== null && typeof pending.catch === 'function') pending.catch(() => {})
1627
+ } catch (error) {
1628
+ void error
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Recover from a blank OAuth callback in one bounded step.
1634
+ *
1635
+ * The callback route is written for a popup that reports back through
1636
+ * `window.opener`; replayed as a normal tab it can render nothing even
1637
+ * though the sign-in cookie already landed. Loading the site's entry URL
1638
+ * once (same partition) therefore usually lands the user signed in. No
1639
+ * recovery card: the page is left to the site itself.
1640
+ *
1641
+ * @param {string} id
1642
+ * @param {object} provider
1643
+ */
1644
+ function scheduleOAuthRecovery(id, provider) {
1645
+ const record = guestRecord(id)
1646
+ clearRecoveryTimer(record)
1647
+
1648
+ record.recoveryTimer = setTimeout(() => {
1649
+ record.recoveryTimer = undefined
1650
+ const element = record.element
1651
+ if (element === undefined || element === null) return
1652
+ void (async () => {
1653
+ if ((await isGuestBlank(element)) !== true) return
1654
+ if (element !== record.element) return
1655
+ navigateInPlace(element, provider.url)
1656
+ record.notice = ''
1657
+ notify()
1658
+ })()
1659
+ }, TIMINGS.oauthLandingDelayMs)
1660
+ }
1661
+
1662
+ /**
1663
+ * Ask the guest whether its document is effectively empty.
1664
+ *
1665
+ * Text-only on purpose: a stuck callback can still carry the site's
1666
+ * `<title>`, so a title check would miss exactly the case this recovers.
1667
+ *
1668
+ * @param {any} element
1669
+ * @returns {Promise<boolean>}
1670
+ */
1671
+ async function isGuestBlank(element) {
1672
+ try {
1673
+ const raw = await element.executeJavaScript(
1674
+ '(function(){try{var b=document.body;var t=b?String(b.innerText||b.textContent||""):"";' +
1675
+ 'return JSON.stringify({text:t.trim().slice(0,160)})}' +
1676
+ 'catch(e){return ""}})()',
1677
+ )
1678
+ if (typeof raw !== 'string' || raw === '') return false
1679
+ const parsed = JSON.parse(raw)
1680
+ return String(parsed.text || '').length < 20
1681
+ } catch (error) {
1682
+ return false
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * Replace one provider's guest with a fresh lease.
1688
+ *
1689
+ * @param {string} id
1690
+ * @returns {Promise<void>}
1691
+ */
1692
+ async function reloadGuest(id) {
1693
+ const record = guestRecord(id)
1694
+ log('info', 'reload requested', { provider: id })
1695
+ record.generation += 1
1696
+ dropGuest(record)
1697
+ setPhase(id, PHASE.IDLE, '')
1698
+ await mountGuest(id)
1699
+ }
1700
+ /** Release every guest; used on plugin unload. */
1701
+ function destroyGuests() {
1702
+ for (const [id, record] of guests) {
1703
+ record.generation += 1
1704
+ dropGuest(record)
1705
+ setPhase(id, PHASE.IDLE, '')
1706
+ if (record.layer !== undefined && record.layer !== null) {
1707
+ try {
1708
+ if (record.layer.parentNode !== null && record.layer.parentNode !== undefined) {
1709
+ record.layer.parentNode.removeChild(record.layer)
1710
+ }
1711
+ } catch (error) {
1712
+ void error
1713
+ }
1714
+ record.layer = undefined
1715
+ }
1716
+ }
1717
+ }
1718
+
1719
+ /** @param {{left:number,top:number,width:number,height:number}} rect @returns {string} */
1720
+ function formatRect(rect, t) {
1721
+ if (rect === undefined || rect === null) return '—'
1722
+ return t('rect', {
1723
+ left: Math.round(rect.left),
1724
+ top: Math.round(rect.top),
1725
+ width: Math.round(rect.width),
1726
+ height: Math.round(rect.height),
1727
+ })
1728
+ }
1729
+
1730
+
1731
+
1732
+ // ------------------------------------------------------------------
1733
+ // Seats (central panel and the right sidebar tab)
1734
+ // ------------------------------------------------------------------
1735
+
1736
+ /**
1737
+ * Seat preference order. The preferred seat is tried first; the other one
1738
+ * is only a fallback, and only while it is actually painted.
1739
+ */
1740
+ const SEAT_ORDER = ['right', 'main']
1741
+
1742
+ /** @param {any} node */
1743
+ function isLiveSeat(node) {
1744
+ return node !== null && node !== undefined && node.isConnected !== false
1745
+ }
1746
+
1747
+
1748
+
1749
+
1750
+
1751
+
1752
+ // ------------------------------------------------------------------
1753
+ // Inline hosting
1754
+ //
1755
+ // The surface is rendered *inside* the panel's own layout box, so the
1756
+ // browser resizes it while a splitter is dragged — exactly how the shell's
1757
+ // terminal pane behaves. No measurement, no per-frame JS.
1758
+ // ------------------------------------------------------------------
1759
+
1760
+ /** @type {{ main: any, right: any }} the mounted inline viewport per seat */
1761
+ const inlineHosts = { main: null, right: null }
1762
+
1763
+ /**
1764
+ * The seat the user opened most recently. Clicking the left navigation entry
1765
+ * must show the chat *there*: a hint page is not an entry.
1766
+ *
1767
+ * @type {'main' | 'right' | undefined}
1768
+ */
1769
+ let preferredSeat = undefined
1770
+
1771
+ /**
1772
+ * Pick the seat that owns the guests: the user's most recent choice wins, and
1773
+ * the right tab is the fallback because its body stays mounted while another
1774
+ * tab or panel is selected.
1775
+ *
1776
+ * @returns {any} the chosen viewport element, or null
1777
+ */
1778
+ /**
1779
+ * A guest element cannot move between hosts (Electron destroys the
1780
+ * WebContents with its DOM), so guests that are not inside the new host are
1781
+ * dropped and rebuilt there.
1782
+ *
1783
+ * @param {any} host
1784
+ */
1785
+ function dropGuestsOutside(host) {
1786
+ for (const provider of PROVIDERS) {
1787
+ const record = guests.get(provider.id)
1788
+ if (record === undefined || record.element === undefined || record.element === null) continue
1789
+ const element = record.element
1790
+ const inside = typeof host.contains === 'function' ? host.contains(element) : false
1791
+ if (inside) continue
1792
+ dropGuest(record)
1793
+ }
1794
+ }
1795
+
1796
+ /** @returns {{ host: any }} the live guest viewport */
1797
+ function getSurface() {
1798
+ return state.surface
1799
+ }
1800
+
1801
+ /** @param {any} host */
1802
+ function setHost(host) {
1803
+ state.surface.host = host
1804
+ notify()
1805
+ }
1806
+
1807
+ /**
1808
+ * Compatibility entry for the tests and probes: only the guest viewport is
1809
+ * tracked, the browser owns the geometry.
1810
+ *
1811
+ * @param {{ host?: any }} patch
1812
+ */
1813
+ function updateSurface(patch) {
1814
+ if (patch !== undefined && patch !== null && patch.host !== undefined) setHost(patch.host)
1815
+ }
1816
+
1817
+ function resolveInlineHost() {
1818
+ const seat = inlineSeat()
1819
+ const chosen = seat === undefined ? null : inlineHosts[seat]
1820
+ if (chosen !== state.surface.host) log('info', 'host resolved', { seat: seat === undefined ? 'none' : seat })
1821
+ setHost(chosen)
1822
+ if (chosen === null) destroyGuests()
1823
+ else {
1824
+ dropGuestsOutside(chosen)
1825
+ void ensureActiveGuest()
1826
+ }
1827
+ return chosen
1828
+ }
1829
+
1830
+ /** @param {'main' | 'right'} seat @returns {boolean} */
1831
+ function hostsInline(seat) {
1832
+ return inlineSeat() === seat
1833
+ }
1834
+
1835
+ /**
1836
+ * Hand the guests to another seat; that seat's surface becomes the host and
1837
+ * the previous one shows a pointer instead of an empty frame.
1838
+ *
1839
+ * @param {'main' | 'right'} seat
1840
+ */
1841
+ function preferSeat(seat) {
1842
+ preferredSeat = seat
1843
+ log('info', 'seat preferred', { seat })
1844
+ resolveInlineHost()
1845
+ notify()
1846
+ }
1847
+
1848
+ /** @returns {string | undefined} the seat that currently owns the guests */
1849
+ /** @param {any} host @returns {number} painted area, 0 while hidden */
1850
+ function hostArea(host) {
1851
+ if (host === null || host === undefined || typeof host.getBoundingClientRect !== 'function') return 0
1852
+ const box = host.getBoundingClientRect()
1853
+ return Math.max(0, Math.round(box.width)) * Math.max(0, Math.round(box.height))
1854
+ }
1855
+
1856
+ function inlineSeat() {
1857
+ const order = preferredSeat === undefined ? SEAT_ORDER : [preferredSeat, ...SEAT_ORDER]
1858
+ let hidden
1859
+ for (let index = 0; index < order.length; index += 1) {
1860
+ const seat = order[index]
1861
+ const host = inlineHosts[seat]
1862
+ if (host === null || host === undefined) continue
1863
+ if (hidden === undefined) hidden = seat
1864
+ // A freshly mounted center panel has a zero rect for one commit, so the
1865
+ // *main* choice may win before it is measured. The right column is a
1866
+ // different story: a collapsed tab body stays at 0×0 forever, and moving
1867
+ // the page there would paint it into nothing.
1868
+ const chosen = preferredSeat !== undefined && index === 0 && seat === preferredSeat
1869
+ const zeroSized = hostArea(host) <= 0
1870
+ if (!zeroSized || (chosen && seat === 'main')) return seat
1871
+ }
1872
+ return hidden
1873
+ }
1874
+
1875
+ /**
1876
+ * The right sidebar tab type. `multiple: false` keeps a single chat tab per
1877
+ * pane; the guide entry is what makes the tab reachable from the sidebar's
1878
+ * own launcher.
1879
+ *
1880
+ * @param {string} title
1881
+ * @returns {object}
1882
+ */
1883
+ function rightTabType(title) {
1884
+ return {
1885
+ id: RIGHT_TAB_ID,
1886
+ kind: RIGHT_TAB_KIND,
1887
+ multiple: false,
1888
+ // Keep the body mounted while another tab is selected, exactly like the
1889
+ // shell's own browser type, so this tab keeps its guest and scroll
1890
+ // position while the user looks at another tab.
1891
+ keepMounted: true,
1892
+ title: () => title,
1893
+ guide: [
1894
+ {
1895
+ id: 'chat',
1896
+ commandId: 'chat-embed.open',
1897
+ order: 40,
1898
+ title: () => title,
1899
+ description: () => title,
1900
+ },
1901
+ ],
1902
+ }
1903
+ }
1904
+
1905
+ function openRightTab() {
1906
+ try {
1907
+ openRightTabImpl()
1908
+ } catch (error) {
1909
+ void error
1910
+ }
1911
+ }
1912
+
1913
+ /** @returns {string} a live self-test of the hosting seat and its box. */
1914
+ function runSeatSelfTest(t) {
1915
+ const translate = typeof t === 'function' ? t : probeT
1916
+ const seat = inlineSeat()
1917
+ if (seat !== undefined) {
1918
+ const host = inlineHosts[seat]
1919
+ if (host === null || host === undefined) return translate('selfTestLostHost')
1920
+ const box = typeof host.getBoundingClientRect === 'function' ? host.getBoundingClientRect() : null
1921
+ return translate('selfTestInline', {
1922
+ seat: valueText(translate, SEAT_KEY, seat),
1923
+ host: box === null ? '—' : formatRect(box, translate),
1924
+ })
1925
+ }
1926
+ return translate('selfTestNoSeat')
1927
+ }
1928
+
1929
+ /** @returns {object} structured live geometry for the Inspect probe. */
1930
+ function seatReportData() {
1931
+ const rect = (value) =>
1932
+ value === undefined || value === null
1933
+ ? undefined
1934
+ : {
1935
+ left: Math.round(value.left),
1936
+ top: Math.round(value.top),
1937
+ width: Math.round(value.width),
1938
+ height: Math.round(value.height),
1939
+ }
1940
+ const seat = inlineSeat()
1941
+ const host = seat === undefined ? undefined : inlineHosts[seat]
1942
+ return {
1943
+ build: BUILD,
1944
+ mode: 'inline',
1945
+ seat: seat || 'none',
1946
+ host: rect(host === undefined || host === null ? undefined : host.getBoundingClientRect()),
1947
+ visible: true,
1948
+ guest: (() => {
1949
+ if (host === undefined || host === null || typeof host.querySelectorAll !== 'function') {
1950
+ return { present: false, reason: 'no inline host' }
1951
+ }
1952
+ const webviews = host.querySelectorAll('webview')
1953
+ const layers = host.querySelectorAll('.dce-guest-layer')
1954
+ const active = host.querySelectorAll('.dce-guest-layer[data-active="true"]')
1955
+ const inner = document.querySelector('.dce-inline')
1956
+ return {
1957
+ present: webviews.length > 0,
1958
+ webviews: webviews.length,
1959
+ layers: layers.length,
1960
+ active: active.length,
1961
+ surfaceMounted: inner !== null && inner !== undefined,
1962
+ hostBox: rect(host.getBoundingClientRect()),
1963
+ }
1964
+ })(),
1965
+ right: rightSidebarStatus(),
1966
+ }
1967
+ }
1968
+
1969
+ /**
1970
+ * Register a read-only Client Inspect provider so the Host can read the live
1971
+ * geometry through `cordis_inspect_query`, with no UI interaction.
1972
+ *
1973
+ * @param {any} ctx
1974
+ * @returns {(() => void) | undefined}
1975
+ */
1976
+ function installInspect(ctx, translate) {
1977
+ const inspect = typeof ctx.get === 'function' ? ctx.get('cordisInspect') : undefined
1978
+ if (inspect === undefined || inspect === null || typeof inspect.register !== 'function') return undefined
1979
+ try {
1980
+ return inspect.register({
1981
+ manifest: {
1982
+ id: 'dsh-chat-embed',
1983
+ description: 'Live seat and host report for the embedded chat surface.',
1984
+ methods: [
1985
+ {
1986
+ name: 'seatReport',
1987
+ description: 'Return the hosting seat, its host box, guest state and a self-test verdict.',
1988
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1989
+ outputSchema: { type: 'object' },
1990
+ },
1991
+ {
1992
+ name: 'openPanel',
1993
+ description: 'Select this plugin central panel, the same thing the left entry does.',
1994
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1995
+ outputSchema: { type: 'object' },
1996
+ },
1997
+ {
1998
+ name: 'dragProbe',
1999
+ description: 'Drag the right column divider with synthetic pointers and report whether the inline surface followed.',
2000
+ inputSchema: {
2001
+ type: 'object',
2002
+ properties: { dx: { type: 'number', description: 'Horizontal drag distance; defaults to -48.' } },
2003
+ additionalProperties: false,
2004
+ },
2005
+ outputSchema: { type: 'object' },
2006
+ },
2007
+ {
2008
+ name: 'handles',
2009
+ description: 'List the resize handles the frame currently renders, with their boxes.',
2010
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
2011
+ outputSchema: { type: 'object' },
2012
+ },
2013
+ {
2014
+ name: 'layout',
2015
+ description: 'Report the layout service state and the left navigation entries.',
2016
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
2017
+ outputSchema: { type: 'object' },
2018
+ },
2019
+ {
2020
+ name: 'log',
2021
+ description: 'Read the debug log ring buffer; set enabled to switch logging on or off.',
2022
+ inputSchema: {
2023
+ type: 'object',
2024
+ properties: {
2025
+ enabled: { type: 'boolean', description: 'Switch the debug log on or off.' },
2026
+ clear: { type: 'boolean', description: 'Drop the buffered entries first.' },
2027
+ },
2028
+ additionalProperties: false,
2029
+ },
2030
+ outputSchema: { type: 'object' },
2031
+ },
2032
+ {
2033
+ name: 'locale',
2034
+ description: 'Report the active UI locale and the panel copy it currently resolves to.',
2035
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
2036
+ outputSchema: { type: 'object' },
2037
+ },
2038
+ {
2039
+ name: 'prefer',
2040
+ description: 'Hand the chat to a seat (main or right) and report the resulting host seat.',
2041
+ inputSchema: {
2042
+ type: 'object',
2043
+ properties: { seat: { type: 'string', description: 'main or right' } },
2044
+ additionalProperties: false,
2045
+ },
2046
+ outputSchema: { type: 'object' },
2047
+ },
2048
+ {
2049
+ name: 'dom',
2050
+ description: 'Report which chat surfaces and guests are actually in the document right now.',
2051
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
2052
+ outputSchema: { type: 'object' },
2053
+ },
2054
+ {
2055
+ name: 'openRight',
2056
+ description: 'Open the right sidebar chat tab, then report the resulting host seat.',
2057
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
2058
+ outputSchema: { type: 'object' },
2059
+ },
2060
+ ],
2061
+ },
2062
+ query(method, input) {
2063
+ if (method === 'locale') {
2064
+ const face = typeof ctx.locale.getLocale === 'function' ? ctx.locale.getLocale() : undefined
2065
+ const locales =
2066
+ face !== undefined && Array.isArray(face.locales) ? face.locales.map((entry) => entry.id) : []
2067
+ return {
2068
+ active: face !== undefined ? face.active : undefined,
2069
+ locales,
2070
+ htmlLang:
2071
+ typeof document !== 'undefined' &&
2072
+ document.documentElement !== null &&
2073
+ document.documentElement !== undefined
2074
+ ? document.documentElement.lang
2075
+ : '',
2076
+ sample: translate('persistLabel'),
2077
+ zhSample: DICTS.zh.persistLabel,
2078
+ enSample: DICTS.en.persistLabel,
2079
+ }
2080
+ }
2081
+ if (method === 'log') {
2082
+ const patch = input !== undefined && input !== null ? input : {}
2083
+ if (typeof patch.enabled === 'boolean') setLogging(patch.enabled)
2084
+ if (patch.clear === true) clearLog()
2085
+ return { enabled: logEnabled, entries: readLogEntries().slice(-50) }
2086
+ }
2087
+ if (method === 'layout') {
2088
+ const layout = typeof ctx.get === 'function' ? ctx.get('layout') : undefined
2089
+ const out = { service: layout !== undefined && layout !== null, keys: [], hasMain: undefined, activePanelId: undefined, nav: [] }
2090
+ if (out.service) {
2091
+ out.keys = Object.keys(layout)
2092
+ if (typeof layout.hasMainPanel === 'function') {
2093
+ try {
2094
+ out.hasMain = layout.hasMainPanel(PANEL_ID)
2095
+ } catch (error) {
2096
+ out.hasMain = 'throw: ' + messageOf(error)
2097
+ }
2098
+ }
2099
+ const read = (source, label) => {
2100
+ if (source === undefined || source === null) return
2101
+ try {
2102
+ let value = source
2103
+ if (typeof source.get === 'function') value = source.get()
2104
+ else if (typeof source.getState === 'function') value = source.getState()
2105
+ out[label] = value !== null && typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value
2106
+ } catch (error) {
2107
+ out[label] = 'throw: ' + messageOf(error)
2108
+ }
2109
+ }
2110
+ read(layout.panelInfo, 'panelInfo')
2111
+ read(layout.navigation, 'navigation')
2112
+ const store = layout.panels
2113
+ if (store !== undefined && store !== null) {
2114
+ out.storeKeys = Object.keys(store)
2115
+ read(store, 'panelStore')
2116
+ }
2117
+ }
2118
+ try {
2119
+ if (typeof document.querySelectorAll === 'function') {
2120
+ for (const node of document.querySelectorAll('[data-panel-id]')) {
2121
+ out.nav.push({ id: node.getAttribute('data-panel-id'), tag: node.tagName })
2122
+ }
2123
+ }
2124
+ } catch (error) {
2125
+ out.navError = messageOf(error)
2126
+ }
2127
+ return out
2128
+ }
2129
+ if (method === 'dom') {
2130
+ const count = (selector) => {
2131
+ try {
2132
+ return typeof document.querySelectorAll === 'function' ? document.querySelectorAll(selector).length : -1
2133
+ } catch (error) {
2134
+ return -1
2135
+ }
2136
+ }
2137
+ return {
2138
+ inlineSurfaces: count('.dce-panel.dce-inline'),
2139
+ logToggles: count('[data-log-toggle]'),
2140
+ toolbarButtons: count('.dce-tab-actions .dce-icon-button'),
2141
+ logEnabled,
2142
+ persistence: persistenceState(),
2143
+ webviews: count('webview'),
2144
+ selectedPanel: (() => {
2145
+ const layout = typeof ctx.get === 'function' ? ctx.get('layout') : undefined
2146
+ if (layout === undefined || layout === null || typeof layout.getPanel !== 'function') return undefined
2147
+ try {
2148
+ return layout.getPanel()
2149
+ } catch (error) {
2150
+ return undefined
2151
+ }
2152
+ })(),
2153
+ hosts: { main: inlineHosts.main !== null, right: inlineHosts.right !== null },
2154
+ preferred: preferredSeat || 'none',
2155
+ seat: inlineSeat() || 'none',
2156
+ }
2157
+ }
2158
+ if (method === 'openRight') {
2159
+ const sidebar = typeof ctx.get === 'function' ? ctx.get('sidebarRight') : undefined
2160
+ const report = { steps: [], expanded: undefined }
2161
+ if (sidebar === undefined || sidebar === null) {
2162
+ report.steps.push('no sidebarRight service')
2163
+ } else {
2164
+ try {
2165
+ report.expanded = typeof sidebar.isExpanded === 'function' ? sidebar.isExpanded() : undefined
2166
+ if (report.expanded !== true && typeof sidebar.toggleExpanded === 'function') {
2167
+ sidebar.toggleExpanded()
2168
+ report.steps.push('expanded')
2169
+ }
2170
+ } catch (error) {
2171
+ report.steps.push('expand: ' + messageOf(error))
2172
+ }
2173
+ try {
2174
+ if (typeof sidebar.openTab === 'function') sidebar.openTab(RIGHT_TAB_KIND, {})
2175
+ else report.steps.push('no openTab on the service')
2176
+ report.steps.push('openTab')
2177
+ } catch (error) {
2178
+ report.steps.push('openTab: ' + messageOf(error))
2179
+ }
2180
+ }
2181
+ return { ...report, requested: RIGHT_TAB_KIND, seat: inlineSeat() || 'none' }
2182
+ }
2183
+ if (method === 'prefer') {
2184
+ const seat = input !== undefined && input !== null && input.seat === 'right' ? 'right' : 'main'
2185
+ preferSeat(seat)
2186
+ return { seat: inlineSeat() || 'none', host: seatReportData().host }
2187
+ }
2188
+ if (method === 'handles') {
2189
+ const nodes = typeof document.querySelectorAll === 'function' ? document.querySelectorAll('[class*="andle"]') : []
2190
+ const seatsNow = { main: inlineHosts.main !== null, right: inlineHosts.right !== null }
2191
+ return {
2192
+ seats: seatsNow,
2193
+ handles: [...nodes].map((node) => ({
2194
+ cls: typeof node.className === 'string' ? node.className : '',
2195
+ side: typeof node.getAttribute === 'function' ? node.getAttribute('data-side') : null,
2196
+ box: (() => {
2197
+ const box = node.getBoundingClientRect()
2198
+ return {
2199
+ left: Math.round(box.left),
2200
+ top: Math.round(box.top),
2201
+ width: Math.round(box.width),
2202
+ height: Math.round(box.height),
2203
+ }
2204
+ })(),
2205
+ })),
2206
+ }
2207
+ }
2208
+ if (method === 'dragProbe') {
2209
+ const seat = inlineSeat()
2210
+ const host = seat === undefined ? null : inlineHosts[seat]
2211
+ const divider =
2212
+ typeof document.querySelector === 'function'
2213
+ ? document.querySelector('[data-side="rightbar"]') || document.querySelector('[data-dockkit-divider]')
2214
+ : null
2215
+ if (divider === null || divider === undefined) return { ok: false, reason: 'no divider in the document' }
2216
+ if (host === null || host === undefined) return { ok: false, reason: 'no inline host' }
2217
+ const dividerInfo = {
2218
+ cls: typeof divider.className === 'string' ? divider.className : '',
2219
+ side: typeof divider.getAttribute === 'function' ? divider.getAttribute('data-side') : null,
2220
+ }
2221
+ const box = divider.getBoundingClientRect()
2222
+ const x = box.left + box.width / 2
2223
+ const y = box.top + box.height / 2
2224
+ // The handle captures a real pointer; a synthetic one has no active
2225
+ // pointer to capture, so the capture calls are stubbed while the
2226
+ // probe runs and restored afterwards.
2227
+ const savedCapture = {
2228
+ set: divider.setPointerCapture,
2229
+ has: divider.hasPointerCapture,
2230
+ release: divider.releasePointerCapture,
2231
+ }
2232
+ try {
2233
+ divider.setPointerCapture = () => {}
2234
+ divider.hasPointerCapture = () => true
2235
+ divider.releasePointerCapture = () => {}
2236
+ } catch (error) {
2237
+ void error
2238
+ }
2239
+ const fire = (target, type, clientX) => {
2240
+ if (typeof PointerEvent !== 'function') return false
2241
+ try {
2242
+ target.dispatchEvent(
2243
+ new PointerEvent(type, {
2244
+ bubbles: true,
2245
+ cancelable: true,
2246
+ clientX,
2247
+ clientY: y,
2248
+ pointerId: 7,
2249
+ pointerType: 'mouse',
2250
+ isPrimary: true,
2251
+ buttons: type === 'pointerup' ? 0 : 1,
2252
+ }),
2253
+ )
2254
+ return true
2255
+ } catch (error) {
2256
+ return false
2257
+ }
2258
+ }
2259
+ const widthOf = () => Math.round(host.getBoundingClientRect().width)
2260
+ const dx = input !== undefined && input !== null && typeof input.dx === 'number' && Number.isFinite(input.dx) ? input.dx : -48
2261
+ const guest = typeof host.querySelector === 'function' ? host.querySelector('webview') : null
2262
+ const sweep = (distance, done) => {
2263
+ fire(divider, 'pointerdown', x)
2264
+ for (let step = 1; step <= 8; step += 1) fire(divider, 'pointermove', x + (distance * step) / 8)
2265
+ fire(divider, 'pointerup', x + distance)
2266
+ if (typeof setTimeout === 'function') setTimeout(done, 220)
2267
+ else done()
2268
+ }
2269
+ const before = widthOf()
2270
+ const guestWidth = () => {
2271
+ if (guest === null || guest === undefined) return undefined
2272
+ const box = guest.getBoundingClientRect()
2273
+ return Math.round(box.width)
2274
+ }
2275
+ const guestBefore = guestWidth()
2276
+ return new Promise((resolve) => {
2277
+ sweep(dx, () => {
2278
+ const dragged = widthOf()
2279
+ const guestDragged = guestWidth()
2280
+ const moved = dragged !== before
2281
+ // Put the column back where it started: the probe must not
2282
+ // leave the user's layout changed.
2283
+ sweep(-dx, () => {
2284
+ const restored = widthOf()
2285
+ const guestRestored = guestWidth()
2286
+ try {
2287
+ divider.setPointerCapture = savedCapture.set
2288
+ divider.hasPointerCapture = savedCapture.has
2289
+ divider.releasePointerCapture = savedCapture.release
2290
+ } catch (error) {
2291
+ void error
2292
+ }
2293
+ resolve({
2294
+ ok: moved,
2295
+ reason: moved
2296
+ ? 'the inline surface followed a real divider drag'
2297
+ : 'the divider did not move (the handle ignored synthetic pointers)',
2298
+ divider: dividerInfo,
2299
+ before,
2300
+ dragged,
2301
+ restored,
2302
+ delta: dragged - before,
2303
+ guest: guest !== null && guest !== undefined,
2304
+ guestWidths: { before: guestBefore, dragged: guestDragged, restored: guestRestored },
2305
+ guestFollowed: guestBefore !== undefined && guestDragged !== undefined && guestDragged !== guestBefore,
2306
+ })
2307
+ })
2308
+ })
2309
+ })
2310
+ }
2311
+ if (method === 'seatReport') return { ...seatReportData(), selfTest: runSeatSelfTest(probeT) }
2312
+ if (method === 'openPanel') {
2313
+ // The left navigation entry is the center panel; open that surface
2314
+ // so a probe can verify the seat the user actually clicks.
2315
+ const layout = typeof ctx.get === 'function' ? ctx.get('layout') : undefined
2316
+ if (layout === undefined || layout === null || typeof layout.selectPanel !== 'function') {
2317
+ return { opened: false, reason: 'layout service is unavailable' }
2318
+ }
2319
+ layout.selectPanel(PANEL_ID)
2320
+ return { opened: true, panel: PANEL_ID }
2321
+ }
2322
+ throw new Error('unknown dsh-chat-embed inspect method "' + method + '"')
2323
+ },
2324
+ })
2325
+ } catch (error) {
2326
+ return undefined
2327
+ }
2328
+ }
2329
+
2330
+ /** @returns {string} the live seat geometry, shown in the diagnostics card. */
2331
+ function describeSeat(t) {
2332
+ const translate = typeof t === 'function' ? t : probeT
2333
+ const seat = inlineSeat() || 'none'
2334
+ const name = valueText(translate, SEAT_KEY, seat)
2335
+ const host = seat === 'none' ? null : inlineHosts[seat]
2336
+ if (host === null || host === undefined || typeof host.getBoundingClientRect !== 'function') {
2337
+ return translate('seatMissing', { seat: name })
2338
+ }
2339
+ return translate('seatDescribe', { seat: name, host: formatRect(host.getBoundingClientRect(), translate) })
2340
+ }
2341
+
2342
+ /**
2343
+ * Whether the mounted guests use a persistent shell partition.
2344
+ *
2345
+ * The shell names the partition, so the only honest answer comes from the
2346
+ * partition a guest already received: `persist:` is the patched, disk-backed
2347
+ * case, anything else is memory-only. The answer is remembered so the
2348
+ * settings menu can show it right after a restart, before any guest mounts.
2349
+ *
2350
+ * `lost` reports the specific case that needs an action: a persistent
2351
+ * partition was seen before, and this run does not get one — the desktop
2352
+ * shell was updated and replaced the patch.
2353
+ *
2354
+ * @returns {'on' | 'off' | 'lost' | 'unknown'}
2355
+ */
2356
+ function persistenceState() {
2357
+ const live = livePartitionState()
2358
+ if (live === 'on') {
2359
+ storePersistence('on')
2360
+ storePersistenceSeen(true)
2361
+ return 'on'
2362
+ }
2363
+ if (live === 'off') {
2364
+ storePersistence('off')
2365
+ return readPersistedSeen() ? 'lost' : 'off'
2366
+ }
2367
+ try {
2368
+ if (typeof localStorage === 'undefined' || localStorage === null) return 'unknown'
2369
+ const stored = localStorage.getItem(PERSIST_KEY)
2370
+ if (stored === 'on') return 'on'
2371
+ if (stored === 'off') return readPersistedSeen() ? 'lost' : 'off'
2372
+ return readPersistedSeen() ? 'lost' : 'unknown'
2373
+ } catch (error) {
2374
+ void error
2375
+ return 'unknown'
2376
+ }
2377
+ }
2378
+
2379
+ /** @returns {'on' | 'off' | 'unknown'} what the mounted guests actually received */
2380
+ function livePartitionState() {
2381
+ for (const provider of PROVIDERS) {
2382
+ const record = guests.get(provider.id)
2383
+ if (record === undefined || typeof record.partition !== 'string' || record.partition === '') continue
2384
+ return record.partition.startsWith('persist:') ? 'on' : 'off'
2385
+ }
2386
+ return 'unknown'
2387
+ }
2388
+
2389
+ /** @returns {boolean} whether a persistent partition was ever observed */
2390
+ function readPersistedSeen() {
2391
+ try {
2392
+ if (typeof localStorage === 'undefined' || localStorage === null) return false
2393
+ return localStorage.getItem(PERSIST_SEEN_KEY) === '1'
2394
+ } catch (error) {
2395
+ void error
2396
+ return false
2397
+ }
2398
+ }
2399
+
2400
+ /** @param {boolean} seen */
2401
+ function storePersistenceSeen(seen) {
2402
+ try {
2403
+ if (typeof localStorage === 'undefined' || localStorage === null) return
2404
+ if (seen) localStorage.setItem(PERSIST_SEEN_KEY, '1')
2405
+ else localStorage.removeItem(PERSIST_SEEN_KEY)
2406
+ } catch (error) {
2407
+ void error
2408
+ }
2409
+ }
2410
+
2411
+ /** @param {'on' | 'off'} state */
2412
+ function storePersistence(state) {
2413
+ try {
2414
+ if (typeof localStorage === 'undefined' || localStorage === null) return
2415
+ localStorage.setItem(PERSIST_KEY, state)
2416
+ } catch (error) {
2417
+ void error
2418
+ }
2419
+ }
2420
+
2421
+ /** @param {(key: string) => string} t @param {string} state @returns {string} */
2422
+ function persistenceText(t, state) {
2423
+ if (state === 'on') return t('persistOn')
2424
+ if (state === 'off') return t('persistOff')
2425
+ if (state === 'lost') return t('persistLost')
2426
+ return t('persistUnknown')
2427
+ }
2428
+
2429
+ /**
2430
+ * The clipboard copy behind "copy the patch instructions": what to run,
2431
+ * where the tool is, and how to replace the archive safely.
2432
+ *
2433
+ * @param {(key: string) => string} t
2434
+ * @returns {string}
2435
+ */
2436
+ function patchInstructions(t) {
2437
+ return [
2438
+ t('persistLabel') + ':' + t('persistHint'),
2439
+ '',
2440
+ '1) 在插件包目录(或仓库根目录)执行:',
2441
+ ' node tools/shell-patch/patch-asar.cjs build',
2442
+ ' 脚本会自动探测安装目录并逐字节校验,生成 app.asar.new。',
2443
+ '',
2444
+ '2) 完全退出 DSH 后替换(任选其一):',
2445
+ ' - 运行 tools/shell-patch/watchdog.ps1,退出瞬间自动替换;',
2446
+ ' - 手动把 resources\\app.asar.new 改名为 app.asar。',
2447
+ '',
2448
+ '3) 重启 DSH,回到这里应显示「' + t('persistOn') + '」。',
2449
+ '',
2450
+ '回滚:restore-shell-patch.cmd(把 app.asar.orig 换回去)。',
2451
+ 'Windows 之外的安装目录请用 --resources 指定。',
2452
+ ].join('\n')
2453
+ }
2454
+
2455
+ /**
2456
+ * Host-side probe state for the "check the patch setup" button. The Host
2457
+ * half answers, so a renderer without Node access can still report where
2458
+ * the shell lives and whether the archive is writable.
2459
+ *
2460
+ * @type {{ running: boolean, report: string }}
2461
+ */
2462
+ const patchCheck = { running: false, report: '' }
2463
+
2464
+ /**
2465
+ * Ask the Host half for the shell facts and render a copyable report.
2466
+ * Nothing here writes: the button only inspects.
2467
+ *
2468
+ * @param {(key: string) => string} t
2469
+ * @returns {Promise<void>}
2470
+ */
2471
+ async function runPatchCheck(t) {
2472
+ if (patchCheck.running) return
2473
+ patchCheck.running = true
2474
+ notify()
2475
+ let report = ''
2476
+ try {
2477
+ // The Host half serves this route; a relative fetch therefore works in
2478
+ // both the desktop app and the Web GUI, under any deployment prefix.
2479
+ const response = await fetch('./dsh-chat-embed/patch-check', {
2480
+ method: 'GET',
2481
+ headers: { accept: 'application/json' },
2482
+ cache: 'no-store',
2483
+ })
2484
+ const probe = response.ok ? await response.json() : undefined
2485
+ report = formatPatchReport(t, probe)
2486
+ } catch (error) {
2487
+ report = formatPatchReport(t, undefined) + '\n' + t('copyFailed') + ': ' + messageOf(error)
2488
+ }
2489
+ patchCheck.running = false
2490
+ patchCheck.report = report
2491
+ notify()
2492
+ void copyText(report)
2493
+ }
2494
+
2495
+ /**
2496
+ * @param {(key: string) => string} t
2497
+ * @param {any} probe
2498
+ * @returns {string}
2499
+ */
2500
+ function formatPatchReport(t, probe) {
2501
+ const lines = [t('persistReportTitle')]
2502
+ lines.push(t('persistLabel') + ': ' + persistenceText(t, persistenceState()))
2503
+ if (probe === undefined || probe === null) {
2504
+ lines.push(t('persistHint'))
2505
+ return lines.join('\n')
2506
+ }
2507
+ const row = (label, value) => {
2508
+ if (value === undefined || value === null || value === '') return
2509
+ lines.push(label + ': ' + String(value))
2510
+ }
2511
+ row('resources', probe.resources)
2512
+ row('app.asar', probe.asar)
2513
+ row('app.asar.orig', probe.backup === true ? '有' : '无')
2514
+ row('app.asar.new', probe.staged === true ? '有(待替换)' : '无')
2515
+ row('可写', probe.writable === true ? '是' : '否')
2516
+ row('补丁点', probe.patchSite === true ? '存在' : probe.patchSite === false ? '不存在(壳版本不匹配)' : '未知')
2517
+ row('已打补丁', probe.patched === true ? '是' : probe.patched === false ? '否' : '未知')
2518
+ row('node', probe.node)
2519
+ row('说明', probe.detail)
2520
+ return lines.join('\n')
2521
+ }
2522
+
2523
+ /** @returns {{ state: string, detail: string, seats: any[] }} */
2524
+ function rightSidebarStatus() {
2525
+ return { state: rightTypeState.state, detail: rightTypeState.detail, seats: rightSeatAttempts.slice() }
2526
+ }
2527
+
2528
+ /** @returns {string} a one-line right-sidebar status for the diagnostics card. */
2529
+ function describeRightSidebar(t) {
2530
+ const status = rightSidebarStatus()
2531
+ const translate = typeof t === 'function' ? t : probeT
2532
+ const seats = status.seats
2533
+ .map((seat) => valueText(translate, RIGHT_SEAT_KEY, seat.label) + '=' + (seat.ok === true ? '✓' : '✗'))
2534
+ .join(translate('seatListSep'))
2535
+ const head =
2536
+ valueText(translate, RIGHT_STATE_KEY, status.state) +
2537
+ (status.detail === '' ? '' : translate('rightDetail', { detail: status.detail }))
2538
+ return seats === '' ? head : head + ' · ' + seats
2539
+ }
2540
+
2541
+ /**
2542
+ * Wire the optional right column: three seats plus the tab type.
2543
+ *
2544
+ * The slots are session-scoped and the registry belongs to another plugin,
2545
+ * so each step can be rejected or simply be absent at apply time. Every
2546
+ * attempt is isolated — the rest of the plugin must survive a failure here.
2547
+ *
2548
+ * @param {any} ctx
2549
+ * @param {(key: string) => string} t
2550
+ */
2551
+ function installRightSidebar(ctx, t) {
2552
+ const attempt = (label, run) => {
2553
+ try {
2554
+ const dispose = run()
2555
+ rightSeatAttempts.push({ label, ok: true })
2556
+ if (typeof dispose === 'function') rightDisposers.push(dispose)
2557
+ } catch (error) {
2558
+ rightSeatAttempts.push({ label, ok: false, detail: messageOf(error) })
2559
+ log('warn', 'right sidebar step failed', { step: label, detail: messageOf(error) })
2560
+ }
2561
+ }
2562
+
2563
+ if (rightSeatsInstalled !== true) {
2564
+ rightSeatsInstalled = true
2565
+ attempt('body', () =>
2566
+ ctx.slots.inject(RIGHT_TAB_SLOT, () =>
2567
+ ctx.slots.register(
2568
+ { name: RIGHT_TAB_SLOT, key: RIGHT_TAB_ID, locale: NS, registrant: 'dsh-chat-embed' },
2569
+ ChatRightBody,
2570
+ ),
2571
+ ),
2572
+ )
2573
+ attempt('title', () =>
2574
+ ctx.slots.inject(RIGHT_TAB_TITLE_SLOT, () =>
2575
+ ctx.slots.register(
2576
+ { name: RIGHT_TAB_TITLE_SLOT, key: RIGHT_TAB_ID, locale: NS, registrant: 'dsh-chat-embed' },
2577
+ ChatRightTitle,
2578
+ ),
2579
+ ),
2580
+ )
2581
+ attempt('dock', () =>
2582
+ ctx.slots.inject(DOCK_SLOT, () =>
2583
+ ctx.slots.register(
2584
+ { name: DOCK_SLOT, id: DOCK_ID, order: 20, locale: NS, registrant: 'dsh-chat-embed' },
2585
+ ChatDockButton,
2586
+ ),
2587
+ ),
2588
+ )
2589
+ }
2590
+
2591
+ const connect = () => {
2592
+ if (rightTabTypeDispose !== undefined) return true
2593
+ const read = (name) => (typeof ctx.get === 'function' ? ctx.get(name) : undefined)
2594
+ const tabs = read('sidebarRightTabs')
2595
+ const controller = read('sidebarRight')
2596
+ if (tabs === undefined || tabs === null || typeof tabs.register !== 'function') {
2597
+ rightTypeState = { state: 'waiting', detail: 'sidebarRightTabs is not available yet' }
2598
+ return false
2599
+ }
2600
+ try {
2601
+ const dispose = tabs.register(rightTabType(t('rightTabTitle')))
2602
+ rightTabTypeDispose = typeof dispose === 'function' ? dispose : () => {}
2603
+ rightTypeState = { state: 'registered', detail: '' }
2604
+ } catch (error) {
2605
+ rightTypeState = { state: 'failed', detail: messageOf(error) }
2606
+ return false
2607
+ }
2608
+ openRightTabImpl =
2609
+ controller !== undefined && controller !== null && typeof controller.openTab === 'function'
2610
+ ? () => {
2611
+ controller.openTab(RIGHT_TAB_KIND, {})
2612
+ }
2613
+ : () => {}
2614
+ return true
2615
+ }
2616
+
2617
+ try {
2618
+ if (connect()) return
2619
+ } catch (error) {
2620
+ rightTypeState = { state: 'failed', detail: messageOf(error) }
2621
+ return
2622
+ }
2623
+
2624
+ // The right column may be composed after this plugin: retry briefly.
2625
+ if (typeof setTimeout !== 'function') return
2626
+ let left = 5
2627
+ const retry = () => {
2628
+ left -= 1
2629
+ let connected = false
2630
+ try {
2631
+ connected = connect()
2632
+ } catch (error) {
2633
+ rightTypeState = { state: 'failed', detail: messageOf(error) }
2634
+ return
2635
+ }
2636
+ if (!connected && left > 0) setTimeout(retry, 500)
2637
+ }
2638
+ setTimeout(retry, 400)
2639
+ }
2640
+
2641
+ /**
2642
+ * Create the active tab's guest once its host viewport exists.
2643
+ *
2644
+ * @returns {Promise<void>}
2645
+ */
2646
+ function ensureActiveGuest() {
2647
+ const provider = activeProvider()
2648
+ const record = guestRecord(provider.id)
2649
+ // A guest whose element was detached with its seat is gone (Electron drops
2650
+ // the WebContents with the DOM), so a live element is the only reason to
2651
+ // skip the rebuild.
2652
+ const element = record.element
2653
+ if (element !== undefined && element !== null && element.isConnected !== false) return Promise.resolve()
2654
+ if (record.mounting === true) return Promise.resolve()
2655
+ // The host viewport inside the panel's layout is the only requirement.
2656
+ if (state.surface.host === null || state.surface.host === undefined) return Promise.resolve()
2657
+ return mountGuest(provider.id)
2658
+ }
2659
+
2660
+ /**
2661
+ * Switch tabs: remember the choice and make sure its guest exists. Other
2662
+ * tabs are untouched — their guests keep running in hidden layers.
2663
+ *
2664
+ * @param {string} id
2665
+ * @returns {Promise<void>}
2666
+ */
2667
+ function setActive(id) {
2668
+ const provider = findProvider(id)
2669
+ if (provider === undefined) return Promise.resolve()
2670
+ if (!isEnabled(provider.id)) return Promise.resolve()
2671
+ if (state.active !== provider.id) {
2672
+ state.active = provider.id
2673
+ storeActive(provider.id)
2674
+ syncLayers()
2675
+ }
2676
+ notify()
2677
+ return ensureActiveGuest()
2678
+ }
2679
+
2680
+ // ------------------------------------------------------------------
2681
+ // Styles
2682
+ // ------------------------------------------------------------------
2683
+
2684
+ const STYLE_ID = 'dsh-chat-embed-styles'
2685
+
2686
+ // Theme values come from the host's own tokens so the panel follows the
2687
+ // active theme instead of asserting a palette of its own.
2688
+ const CSS = [
2689
+ '.dce-panel{position:relative;display:flex;flex:1 1 auto;flex-direction:column;min-width:0;min-height:0;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#1f2329);overflow:hidden}',
2690
+ // Inline mode: the surface is a child of the panel's own layout box. Its
2691
+ // width and height are CSS facts, so dragging a splitter needs no JS.
2692
+ '.dce-inline{width:100%;height:100%;min-width:0;min-height:0}',
2693
+ '.dce-inline.dce-panel{flex:1 1 auto}',
2694
+ '.dce-inline>.dce-surface{position:absolute;inset:0}',
2695
+ '.dce-hotzone{position:absolute;top:0;left:0;right:0;height:10px;z-index:3}',
2696
+ '.dce-tabs{position:absolute;top:0;left:0;right:0;z-index:4;display:flex;align-items:stretch;padding:2px 4px 0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));background:var(--dsw-alias-bg-base,#fff);box-shadow:0 2px 10px rgba(0,0,0,.14);transform:translateY(-110%);opacity:0;pointer-events:none;transition:transform 140ms ease,opacity 140ms ease}',
2697
+ '.dce-tabs[data-visible="true"]{transform:translateY(0);opacity:1;pointer-events:auto}',
2698
+ '.dce-tab-list{display:flex;align-items:stretch;gap:2px;flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}',
2699
+ '.dce-tab-list::-webkit-scrollbar{display:none}',
2700
+ '.dce-tab{appearance:none;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#5f6368);font:inherit;font-size:12px;line-height:28px;padding:0 10px;cursor:pointer;white-space:nowrap;border-radius:6px 6px 0 0}',
2701
+ '.dce-tab:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2702
+ '.dce-tab:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4c6ef5);outline-offset:-2px}',
2703
+ '.dce-tab[data-active="true"]{color:var(--dsw-alias-label-primary,#1f2329);border-bottom-color:var(--dsw-alias-button-primary-fill,#4c6ef5);font-weight:600}',
2704
+ '.dce-tab[data-failed="true"]{color:var(--dsw-alias-state-error-primary,#d33)}',
2705
+ '.dce-tab{cursor:pointer;user-select:none}',
2706
+ '.dce-tab[data-dragging="true"]{opacity:.55;cursor:grabbing}',
2707
+ '.dce-tab[data-drop="before"]{box-shadow:inset 2px 0 0 var(--dsw-alias-button-primary-fill,#4c6ef5)}',
2708
+ '.dce-tab[data-drop="after"]{box-shadow:inset -2px 0 0 var(--dsw-alias-button-primary-fill,#4c6ef5)}',
2709
+ '.dce-tab-actions{display:flex;align-items:center;gap:2px;flex:0 0 auto;padding:0 2px 2px}',
2710
+ '.dce-icon-button{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#5f6368);cursor:pointer}',
2711
+ '.dce-icon-button[data-on="true"]{color:var(--dsw-alias-label-accent,#4c7dff);background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
2712
+ '.dce-icon-button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2713
+ '.dce-icon-button:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4c6ef5);outline-offset:-2px}',
2714
+ '.dce-status-bar{position:absolute;top:0;left:0;right:0;z-index:2;display:flex;align-items:center;gap:8px;box-sizing:border-box;padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));background:var(--dsw-alias-bg-base,#fff);font-size:12px}',
2715
+ '.dce-dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#9aa0a6)}',
2716
+ '.dce-dot[data-phase="ready"]{background:var(--dsw-alias-state-success-primary,#2ea043)}',
2717
+ '.dce-dot[data-phase="failed"]{background:var(--dsw-alias-state-error-primary,#d33)}',
2718
+ '.dce-dot[data-phase="acquiring"],.dce-dot[data-phase="navigating"]{background:var(--dsw-alias-state-warn-primary,#c98a00);animation:dce-pulse 1.4s ease-in-out infinite}',
2719
+ '@keyframes dce-pulse{0%,100%{opacity:.35}50%{opacity:1}}',
2720
+ '.dce-status{color:var(--dsw-alias-label-secondary,#5f6368);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
2721
+ '.dce-diag{color:var(--dsw-alias-label-tertiary,#9aa0a6);white-space:nowrap;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11px;overflow:hidden;text-overflow:ellipsis}',
2722
+ '.dce-spacer{flex:1 1 auto}',
2723
+ '.dce-button{appearance:none;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#5f6368);border-radius:6px;font:inherit;font-size:12px;line-height:20px;padding:2px 10px;cursor:pointer}',
2724
+ '.dce-button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2725
+ '.dce-button:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4c6ef5);outline-offset:2px}',
2726
+ '.dce-button-primary{border-color:currentColor;color:var(--dsw-alias-button-primary-fill,#4c6ef5)}',
2727
+ '.dce-surface{position:absolute;inset:0;display:flex;min-height:0}',
2728
+ '.dce-viewport{position:absolute;inset:0;display:flex;min-width:0;min-height:0}',
2729
+ '.dce-guest-layer{position:absolute;inset:0;display:none;min-width:0;min-height:0}',
2730
+ '.dce-guest-layer[data-active="true"]{display:flex}',
2731
+ '.dce-guest{display:inline-flex;flex:1 1 auto;width:100%;height:100%;border:0;background:transparent}',
2732
+ '.dce-card{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;gap:10px;box-sizing:border-box;width:auto;max-width:460px;height:max-content;margin:auto;padding:20px 22px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 12px 32px rgba(0,0,0,.12);font-size:13px}',
2733
+ '.dce-card-title{font-size:14px;font-weight:600}',
2734
+ '.dce-card-body{color:var(--dsw-alias-label-secondary,#5f6368);line-height:1.6}',
2735
+ '.dce-card-detail{font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:11px;color:var(--dsw-alias-label-tertiary,#9aa0a6);word-break:break-all}',
2736
+ '.dce-card-actions{display:flex;gap:8px;justify-content:flex-end}',
2737
+ '.dce-menu-scrim{position:absolute;inset:0;z-index:5}',
2738
+ '.dce-menu{position:absolute;top:34px;right:6px;z-index:6;min-width:196px;box-sizing:border-box;padding:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 12px 32px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:1px}',
2739
+ '.dce-menu-title{font-size:11px;color:var(--dsw-alias-label-tertiary,#9aa0a6);padding:6px 8px 4px;letter-spacing:.04em}',
2740
+ '.dce-menu-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:5px 8px;border-radius:6px;font-size:13px;color:var(--dsw-alias-label-primary,#1f2329)}',
2741
+ '.dce-menu-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2742
+ '.dce-menu-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
2743
+ '.dce-menu-value{flex:0 0 auto;font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a6)}',
2744
+ '.dce-menu-sep{margin:5px 0 3px;border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24))}',
2745
+ '.dce-menu-note{margin:4px 8px 6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#9aa0a6);white-space:pre-wrap;word-break:break-word}',
2746
+ '.dce-menu-note[data-persist-risk="true"]{color:var(--dsw-alias-label-warning,#c98b16)}',
2747
+ '.dce-menu-note[data-patch-report="true"]{max-height:180px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
2748
+ '.dce-menu-item{display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;box-sizing:border-box;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:inherit;font-size:13px;text-align:left;cursor:pointer}',
2749
+ '.dce-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2750
+ '.dce-right-title{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
2751
+ '.dce-right-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary,#19a15f);flex:0 0 auto}',
2752
+ '.dce-right-dot[data-phase="failed"]{background:var(--dsw-alias-state-error-primary,#d33)}',
2753
+ '.dce-right-dot[data-phase="acquiring"],.dce-right-dot[data-phase="navigating"]{background:var(--dsw-alias-state-warning-primary,#d9a514)}',
2754
+ '.dce-switch{appearance:none;border:0;padding:0;width:34px;height:20px;flex:0 0 auto;border-radius:10px;background:var(--dsw-alias-fill-3,rgba(127,127,127,.45));position:relative;cursor:pointer;transition:background 120ms ease}',
2755
+ '.dce-switch::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform 120ms ease}',
2756
+ '.dce-switch[data-on="true"]{background:var(--dsw-alias-button-primary-fill,#4c6ef5)}',
2757
+ '.dce-switch[data-on="true"]::after{transform:translateX(14px)}',
2758
+ '.dce-switch:disabled{opacity:.45;cursor:not-allowed}',
2759
+ ].join('\n')
2760
+
2761
+ /** @returns {any} the plugin's stylesheet element. */
2762
+ function injectStyles() {
2763
+ const existing = document.getElementById(STYLE_ID)
2764
+ if (existing !== null && existing !== undefined) return existing
2765
+ const element = document.createElement('style')
2766
+ element.id = STYLE_ID
2767
+ element.textContent = CSS
2768
+ const parent = document.head !== undefined && document.head !== null ? document.head : document.body
2769
+ parent.appendChild(element)
2770
+ return element
2771
+ }
2772
+
2773
+ // ------------------------------------------------------------------
2774
+ // React components
2775
+ // ------------------------------------------------------------------
2776
+
2777
+ // Fall back to useEffect on a React build without layout effects.
2778
+ const useMountEffect = typeof React.useLayoutEffect === 'function' ? React.useLayoutEffect : React.useEffect
2779
+
2780
+ /**
2781
+ * The locale-bound translator installed by apply. Components render through
2782
+ * it, so the panel follows the DSH language setting; a locale/change event
2783
+ * re-renders them immediately, no reload needed.
2784
+ *
2785
+ * @type {((key: string, params?: Record<string, string | number>) => string) | undefined}
2786
+ */
2787
+ let uiT
2788
+
2789
+ /**
2790
+ * @returns {(key: string, params?: Record<string, string | number>) => string}
2791
+ */
2792
+ function translator() {
2793
+ return (key, params) => {
2794
+ if (typeof uiT === 'function') {
2795
+ const value = uiT(key, params)
2796
+ if (typeof value === 'string' && value !== '' && value !== key) return value
2797
+ }
2798
+ const local = DICTS.zh[key]
2799
+ return local !== undefined ? fill(local, params) : key
2800
+ }
2801
+ }
2802
+
2803
+ /** Re-render on any store change, then read module state directly. */
2804
+ function useStore() {
2805
+ const [, force] = React.useState(0)
2806
+ React.useEffect(() => {
2807
+ let alive = true
2808
+ const bump = () => {
2809
+ if (alive) force((value) => value + 1)
2810
+ }
2811
+ const unsubscribe = watch(bump)
2812
+ bump()
2813
+ return () => {
2814
+ alive = false
2815
+ unsubscribe()
2816
+ }
2817
+ }, [])
2818
+ }
2819
+
2820
+ /**
2821
+ * @param {string} title
2822
+ * @param {string} body
2823
+ * @param {(key: string) => string} t
2824
+ * @param {{ phase: string, detail: string }} snapshot
2825
+ * @param {() => void} [onRetry]
2826
+ */
2827
+ function hintCard(title, body, t, snapshot, onRetry) {
2828
+ return h(
2829
+ 'div',
2830
+ { className: 'dce-card', role: 'status' },
2831
+ h('div', { className: 'dce-card-title' }, title),
2832
+ h('div', { className: 'dce-card-body' }, body),
2833
+ snapshot.detail === '' ? null : h('div', { className: 'dce-card-detail' }, snapshot.detail),
2834
+ h(
2835
+ 'div',
2836
+ { className: 'dce-card-actions' },
2837
+ onRetry === undefined
2838
+ ? null
2839
+ : h('button', { type: 'button', className: 'dce-button dce-button-primary', onClick: onRetry }, t('retry')),
2840
+ h('button', { type: 'button', className: 'dce-button', onClick: openInBrowser }, t('openInBrowser')),
2841
+ ),
2842
+ )
2843
+ }
2844
+
2845
+ /** Open the active service in the system browser. */
2846
+ function openInBrowser() {
2847
+ try {
2848
+ window.open(activeProvider().url, '_blank', 'noopener,noreferrer')
2849
+ } catch (error) {
2850
+ void error
2851
+ }
2852
+ }
2853
+
2854
+ /** @returns {any} a refresh glyph for the tab strip. */
2855
+ function reloadIcon() {
2856
+ return h(
2857
+ 'svg',
2858
+ {
2859
+ width: 14,
2860
+ height: 14,
2861
+ viewBox: '0 0 24 24',
2862
+ fill: 'none',
2863
+ stroke: 'currentColor',
2864
+ strokeWidth: 1.9,
2865
+ strokeLinecap: 'round',
2866
+ strokeLinejoin: 'round',
2867
+ 'aria-hidden': true,
2868
+ focusable: 'false',
2869
+ },
2870
+ h('path', { d: 'M20 12a8 8 0 1 1-2.34-5.66' }),
2871
+ h('path', { d: 'M20 4.5V9h-4.5' }),
2872
+ )
2873
+ }
2874
+
2875
+ /** @returns {any} an external-link glyph for the tab strip. */
2876
+ function externalIcon() {
2877
+ return h(
2878
+ 'svg',
2879
+ {
2880
+ width: 14,
2881
+ height: 14,
2882
+ viewBox: '0 0 24 24',
2883
+ fill: 'none',
2884
+ stroke: 'currentColor',
2885
+ strokeWidth: 1.9,
2886
+ strokeLinecap: 'round',
2887
+ strokeLinejoin: 'round',
2888
+ 'aria-hidden': true,
2889
+ focusable: 'false',
2890
+ },
2891
+ h('path', { d: 'M13.5 4H20v6.5' }),
2892
+ h('path', { d: 'M20 4l-8.5 8.5' }),
2893
+ h('path', { d: 'M18.5 14.5V19a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V6.5A1.5 1.5 0 0 1 5 5h4.5' }),
2894
+ )
2895
+ }
2896
+
2897
+ /** @returns {any} an info glyph for the tab strip. */
2898
+ function infoIcon() {
2899
+ return h(
2900
+ 'svg',
2901
+ {
2902
+ width: 14,
2903
+ height: 14,
2904
+ viewBox: '0 0 24 24',
2905
+ fill: 'none',
2906
+ stroke: 'currentColor',
2907
+ strokeWidth: 1.9,
2908
+ strokeLinecap: 'round',
2909
+ strokeLinejoin: 'round',
2910
+ 'aria-hidden': true,
2911
+ focusable: 'false',
2912
+ },
2913
+ h('circle', { cx: 12, cy: 12, r: 8.5 }),
2914
+ h('path', { d: 'M12 11.2v5' }),
2915
+ h('path', { d: 'M12 7.8v.6' }),
2916
+ )
2917
+ }
2918
+
2919
+ function dockIcon() {
2920
+ return h(
2921
+ 'svg',
2922
+ {
2923
+ width: 14,
2924
+ height: 14,
2925
+ viewBox: '0 0 24 24',
2926
+ fill: 'none',
2927
+ stroke: 'currentColor',
2928
+ strokeWidth: 1.9,
2929
+ strokeLinecap: 'round',
2930
+ strokeLinejoin: 'round',
2931
+ 'aria-hidden': true,
2932
+ focusable: 'false',
2933
+ },
2934
+ h('rect', { x: 3, y: 4.5, width: 18, height: 15, rx: 3 }),
2935
+ h('path', { d: 'M15 4.5v15' }),
2936
+ )
2937
+ }
2938
+
2939
+ function settingsIcon() { return h(
2940
+ 'svg',
2941
+ {
2942
+ width: 14,
2943
+ height: 14,
2944
+ viewBox: '0 0 24 24',
2945
+ fill: 'none',
2946
+ stroke: 'currentColor',
2947
+ strokeWidth: 1.9,
2948
+ strokeLinecap: 'round',
2949
+ strokeLinejoin: 'round',
2950
+ 'aria-hidden': true,
2951
+ focusable: 'false',
2952
+ },
2953
+ h('circle', { cx: 12, cy: 12, r: 3 }),
2954
+ h('path', {
2955
+ d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z',
2956
+ }),
2957
+ )
2958
+ }
2959
+
2960
+
2961
+
2962
+ /**
2963
+ * The right sidebar tab body. `keepMounted` keeps it alive while another tab
2964
+ * is selected, so the guest survives a tab switch; the shell hides bodies it
2965
+ * is not showing, and a hidden `<webview>` keeps its guest running.
2966
+ */
2967
+ function ChatRightBody(props) {
2968
+ const active = props === undefined || props === null || props.active !== false
2969
+ return h(ChatSurface, { seat: 'right', active })
2970
+ }
2971
+
2972
+ /**
2973
+ * The left-navigation page: the chat itself, hosted by the center panel.
2974
+ *
2975
+ * The center panel is torn down whenever another panel is selected, so this
2976
+ * seat is suitable while the user keeps the chat open; the right tab (below)
2977
+ * is the seat that survives panel switches.
2978
+ */
2979
+ function ChatCenterPage() {
2980
+ return h(ChatSurface, { seat: 'main' })
2981
+ }
2982
+
2983
+ /** The right sidebar tab's chip title. */
2984
+ function ChatRightTitle() {
2985
+ useStore()
2986
+ const provider = activeProvider()
2987
+ const phase = getStatus().phase
2988
+ return h(
2989
+ 'span',
2990
+ { className: 'dce-right-title' },
2991
+ h('span', { className: 'dce-right-dot', 'data-phase': phase, 'aria-hidden': 'true' }),
2992
+ h('span', null, provider.label),
2993
+ )
2994
+ }
2995
+
2996
+ /** Session-header control that opens (and focuses) the right sidebar tab. */
2997
+ function ChatDockButton() {
2998
+ const t = translator({})
2999
+ return h(
3000
+ 'button',
3001
+ {
3002
+ type: 'button',
3003
+ className: 'dce-icon-button',
3004
+ title: t('dockTitle'),
3005
+ 'aria-label': t('dockTitle'),
3006
+ onClick: () => {
3007
+ openRightTab()
3008
+ },
3009
+ },
3010
+ dockIcon(),
3011
+ )
3012
+ }
3013
+
3014
+ /**
3015
+ * One seat's chat surface: tab strip, toolbar, status line and the guest
3016
+ * viewport. It renders inline, so the seat's own layout box sizes it.
3017
+ */
3018
+ function ChatSurface(props) {
3019
+ const t = translator(props)
3020
+ useStore()
3021
+ const snapshot = getStatus()
3022
+
3023
+ const provider = activeProvider()
3024
+ const hostRef = React.useRef(null)
3025
+ const listRef = React.useRef(null)
3026
+ /** @type {React.MutableRefObject<{ id: string, startX: number, active: boolean } | undefined>} */
3027
+ const dragRef = React.useRef(undefined)
3028
+ /** @type {React.MutableRefObject<{ id: string, side: string } | undefined>} */
3029
+ const dropRef = React.useRef(undefined)
3030
+ const suppressClickRef = React.useRef(false)
3031
+ const [, bumpDrag] = React.useState(0)
3032
+ const repaintDrag = () => bumpDrag((value) => value + 1)
3033
+
3034
+ const seatName = props !== undefined && props !== null && props.seat === 'right' ? 'right' : 'main'
3035
+
3036
+ // Mounting a seat is the user opening the chat there: only one of the two
3037
+ // seats can exist at a time (the right sidebar needs a Session surface),
3038
+ // so the seat that just rendered is the one that owns the guests.
3039
+ useMountEffect(() => {
3040
+ const visible = seatName === 'main' || props.active !== false
3041
+ if (visible) preferSeat(seatName)
3042
+ return undefined
3043
+ }, [])
3044
+
3045
+ // `preferredSeat === seatName` must count as hosting even before this seat
3046
+ // is registered: the registration effect only runs once the viewport is
3047
+ // committed, so requiring a registered host first would deadlock.
3048
+ const hostingSeat = inlineSeat()
3049
+ const hosting =
3050
+ hostingSeat === undefined ||
3051
+ hostingSeat === seatName ||
3052
+ preferredSeat === seatName ||
3053
+ // A selected right tab is a user request too: it must render its
3054
+ // viewport so it can register and take the guests over.
3055
+ (seatName === 'right' && props.active === true)
3056
+ React.useEffect(() => {
3057
+ const node = hostRef.current
3058
+ if (hosting !== true || node === null || node === undefined) return undefined
3059
+ inlineHosts[seatName] = node
3060
+ if (preferredSeat === undefined) preferredSeat = seatName
3061
+ resolveInlineHost()
3062
+ notify()
3063
+ // A collapsed right tab is 0×0 and must not steal the page; when the user
3064
+ // expands the column the tab gets a box, and that is the moment to hand
3065
+ // the guests over. The observer only re-runs the seat choice.
3066
+ let ro
3067
+ if (typeof ResizeObserver === 'function' && preferredSeat === seatName) {
3068
+ ro = new ResizeObserver(() => {
3069
+ if (preferredSeat === seatName) resolveInlineHost()
3070
+ })
3071
+ ro.observe(node)
3072
+ }
3073
+ return () => {
3074
+ if (ro !== undefined) ro.disconnect()
3075
+ if (inlineHosts[seatName] === node) inlineHosts[seatName] = null
3076
+ if (preferredSeat === seatName) preferredSeat = undefined
3077
+ resolveInlineHost()
3078
+ notify()
3079
+ }
3080
+ }, [hosting])
3081
+
3082
+ // Selecting the right tab hands the chat to that column, so the panel the
3083
+ // user is actually looking at is the one that shows it.
3084
+ React.useEffect(() => {
3085
+ if (seatName !== 'right' || props.active === false) return undefined
3086
+ if (preferredSeat !== 'right') preferSeat('right')
3087
+ return undefined
3088
+ }, [props.active])
3089
+
3090
+ // Pointer-driven tab reordering. Dragging is armed on pointerdown and
3091
+ // only starts after a small threshold, so a plain click still selects.
3092
+ useMountEffect(() => {
3093
+ const findTab = (x, y) => {
3094
+ const element = typeof document.elementFromPoint === 'function' ? document.elementFromPoint(x, y) : null
3095
+ if (element === null || element === undefined) return null
3096
+ return typeof element.closest === 'function' ? element.closest('[data-tab-id]') : null
3097
+ }
3098
+ const onMove = (event) => {
3099
+ const drag = dragRef.current
3100
+ if (drag === undefined) return
3101
+ if (drag.active !== true) {
3102
+ if (Math.abs(event.clientX - drag.startX) < 6) return
3103
+ drag.active = true
3104
+ }
3105
+ const tab = findTab(event.clientX, event.clientY)
3106
+ let next
3107
+ if (tab !== null) {
3108
+ const rect = tab.getBoundingClientRect()
3109
+ next = {
3110
+ id: tab.getAttribute('data-tab-id'),
3111
+ side: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after',
3112
+ }
3113
+ }
3114
+ const current = dropRef.current
3115
+ const unchanged =
3116
+ current === next ||
3117
+ (current !== undefined &&
3118
+ next !== undefined &&
3119
+ current.id === next.id &&
3120
+ current.side === next.side)
3121
+ if (unchanged) return
3122
+ dropRef.current = next
3123
+ repaintDrag()
3124
+ }
3125
+ const onUp = () => {
3126
+ const drag = dragRef.current
3127
+ const drop = dropRef.current
3128
+ dragRef.current = undefined
3129
+ dropRef.current = undefined
3130
+ repaintDrag()
3131
+ if (drag === undefined || drag.active !== true) return
3132
+ // Swallow the click that follows a drag so it does not also switch tabs.
3133
+ suppressClickRef.current = true
3134
+ if (drop === undefined || drop.id === drag.id) return
3135
+ const targetIndex = tabOrder.indexOf(drop.id)
3136
+ const fromIndex = tabOrder.indexOf(drag.id)
3137
+ if (targetIndex === -1 || fromIndex === -1) return
3138
+ let insertAt = drop.side === 'after' ? targetIndex + 1 : targetIndex
3139
+ if (fromIndex < insertAt) insertAt -= 1
3140
+ moveProvider(drag.id, insertAt)
3141
+ }
3142
+ window.addEventListener('pointermove', onMove)
3143
+ window.addEventListener('pointerup', onUp)
3144
+ window.addEventListener('pointercancel', onUp)
3145
+ return () => {
3146
+ window.removeEventListener('pointermove', onMove)
3147
+ window.removeEventListener('pointerup', onUp)
3148
+ window.removeEventListener('pointercancel', onUp)
3149
+ }
3150
+ }, [])
3151
+
3152
+ const beginTabDrag = (id, event) => {
3153
+ if (event.button !== 0) return
3154
+ dragRef.current = { id, startX: event.clientX, active: false }
3155
+ dropRef.current = undefined
3156
+ }
3157
+ const onTabWheel = (event) => {
3158
+ const list = event.currentTarget
3159
+ if (list === null || list === undefined) return
3160
+ if (list.scrollWidth <= list.clientWidth) return
3161
+ if (event.deltaY === 0) return
3162
+ list.scrollLeft += event.deltaY
3163
+ }
3164
+
3165
+ // Auto-hiding tab bar: hidden until the pointer reaches the top hot zone.
3166
+ const [barOpen, setBarOpen] = React.useState(false)
3167
+ const [menuOpen, setMenuOpen] = React.useState(false)
3168
+ const [copyState, setCopyState] = React.useState('')
3169
+ const barTimerRef = React.useRef(0)
3170
+ const cancelBarTimer = () => {
3171
+ if (barTimerRef.current !== 0) {
3172
+ clearTimeout(barTimerRef.current)
3173
+ barTimerRef.current = 0
3174
+ }
3175
+ }
3176
+ const openBar = () => {
3177
+ cancelBarTimer()
3178
+ setBarOpen(true)
3179
+ }
3180
+ const closeBarNow = () => {
3181
+ cancelBarTimer()
3182
+ setBarOpen(false)
3183
+ }
3184
+ const scheduleCloseBar = () => {
3185
+ cancelBarTimer()
3186
+ barTimerRef.current = window.setTimeout(() => {
3187
+ barTimerRef.current = 0
3188
+ const drag = dragRef.current
3189
+ if (drag !== undefined && drag.active === true) {
3190
+ // Never yank the bar away while a tab is being dragged.
3191
+ scheduleCloseBar()
3192
+ return
3193
+ }
3194
+ setBarOpen(false)
3195
+ }, 400)
3196
+ }
3197
+
3198
+ const copyDiagnostics = () => {
3199
+ const report = formatDiagReport({
3200
+ labels: {
3201
+ diagTitle: t('diagTitle'),
3202
+ diagUrl: t('diagUrl'),
3203
+ diagPhase: t('diagPhase'),
3204
+ partitionLabel: t('partitionLabel'),
3205
+ diagEvent: t('diagEvent'),
3206
+ diagHandoff: t('diagHandoff'),
3207
+ diagRight: t('diagRight'),
3208
+ diagSeat: t('diagSeat'),
3209
+ selfTest: t('selfTest'),
3210
+ },
3211
+ right: describeRightSidebar(t),
3212
+ seat: describeSeat(t),
3213
+ selfTest: record.selfTest,
3214
+ url: record.lastUrl === '' ? guestUrl(record.element) || '' : record.lastUrl,
3215
+ phase: phaseText(t, snapshot.phase),
3216
+ detail: snapshot.detail,
3217
+ partition: shortId(record.partition),
3218
+ mounts: record.mounts,
3219
+ lastEvent: record.lastEvent === '' ? '' : valueText(t, EVENT_KEY, record.lastEvent),
3220
+ handoff: record.handoff,
3221
+ build: BUILD,
3222
+ })
3223
+ void copyText(report).then((ok) => {
3224
+ setCopyState(ok ? 'copied' : 'failed')
3225
+ window.setTimeout(() => {
3226
+ setCopyState('')
3227
+ }, 1600)
3228
+ })
3229
+ }
3230
+ const onReload = () => {
3231
+ void reloadGuest(provider.id)
3232
+ }
3233
+ const record = guestRecord(provider.id)
3234
+
3235
+ // The status strip only earns its space while something is happening or
3236
+ // wrong; a healthy tab shows nothing but its tabs.
3237
+ const showStatus = snapshot.phase !== PHASE.READY && snapshot.phase !== PHASE.IDLE
3238
+ // A problem keeps the bar reachable without hunting for it.
3239
+ const barVisible =
3240
+ barOpen ||
3241
+ menuOpen ||
3242
+ record.diagOpen ||
3243
+ record.oauthBlocked ||
3244
+ snapshot.phase === PHASE.FAILED
3245
+ const reloadTitle =
3246
+ t('reload') +
3247
+ (record.partition === undefined
3248
+ ? ''
3249
+ : ' · ' + t('partitionLabel') + ' ' + shortId(record.partition) + ' · #' + record.mounts)
3250
+
3251
+ return h(
3252
+ 'div',
3253
+ {
3254
+ className: 'dce-panel dce-inline',
3255
+ 'data-visible': 'true',
3256
+ 'data-mode': 'inline',
3257
+ 'data-seat': seatName,
3258
+ 'data-build': BUILD,
3259
+ 'aria-hidden': 'false',
3260
+ },
3261
+ h(
3262
+ 'div',
3263
+ { className: 'dce-panel', onPointerLeave: closeBarNow },
3264
+ h('div', { className: 'dce-hotzone', onPointerEnter: openBar, 'aria-hidden': 'true' }),
3265
+ h(
3266
+ 'div',
3267
+ {
3268
+ className: 'dce-tabs',
3269
+ 'data-visible': barVisible ? 'true' : 'false',
3270
+ onPointerEnter: cancelBarTimer,
3271
+ onPointerLeave: scheduleCloseBar,
3272
+ },
3273
+ h(
3274
+ 'div',
3275
+ {
3276
+ className: 'dce-tab-list',
3277
+ role: 'tablist',
3278
+ 'aria-label': 'AI chat services',
3279
+ ref: listRef,
3280
+ onWheel: onTabWheel,
3281
+ },
3282
+ orderedProviders().map((entry) =>
3283
+ h(
3284
+ 'button',
3285
+ {
3286
+ key: entry.id,
3287
+ type: 'button',
3288
+ role: 'tab',
3289
+ className: 'dce-tab',
3290
+ 'data-tab-id': entry.id,
3291
+ 'data-active': entry.id === provider.id ? 'true' : 'false',
3292
+ 'data-failed': guestRecord(entry.id).phase === PHASE.FAILED ? 'true' : 'false',
3293
+ 'data-dragging':
3294
+ dragRef.current !== undefined &&
3295
+ dragRef.current.active === true &&
3296
+ dragRef.current.id === entry.id
3297
+ ? 'true'
3298
+ : 'false',
3299
+ 'data-drop':
3300
+ dropRef.current !== undefined && dropRef.current.id === entry.id
3301
+ ? dropRef.current.side
3302
+ : 'none',
3303
+ 'aria-selected': entry.id === provider.id ? 'true' : 'false',
3304
+ title: entry.url + ' · ' + t('dragHint'),
3305
+ onPointerDown: (event) => beginTabDrag(entry.id, event),
3306
+ onClick: () => {
3307
+ if (suppressClickRef.current === true) {
3308
+ suppressClickRef.current = false
3309
+ return
3310
+ }
3311
+ void setActive(entry.id)
3312
+ },
3313
+ },
3314
+ entry.label,
3315
+ ),
3316
+ ),
3317
+ ),
3318
+ h(
3319
+ 'div',
3320
+ { className: 'dce-tab-actions' },
3321
+ h(
3322
+ 'button',
3323
+ {
3324
+ type: 'button',
3325
+ className: 'dce-icon-button',
3326
+ 'aria-label': t('reload'),
3327
+ title: reloadTitle,
3328
+ onClick: onReload,
3329
+ },
3330
+ reloadIcon(),
3331
+ ),
3332
+ h(
3333
+ 'button',
3334
+ {
3335
+ type: 'button',
3336
+ className: 'dce-icon-button',
3337
+ 'aria-label': t('openInBrowser'),
3338
+ title: t('openInBrowser'),
3339
+ onClick: openInBrowser,
3340
+ },
3341
+ externalIcon(),
3342
+ ),
3343
+
3344
+
3345
+ h(
3346
+ 'button',
3347
+ {
3348
+ type: 'button',
3349
+ className: 'dce-icon-button',
3350
+ 'aria-label': t('settings'),
3351
+ title: t('settings'),
3352
+ 'aria-expanded': menuOpen ? 'true' : 'false',
3353
+ onClick: () => {
3354
+ setMenuOpen((value) => !value)
3355
+ openBar()
3356
+ },
3357
+ },
3358
+ settingsIcon(),
3359
+ ),
3360
+ ),
3361
+ ),
3362
+ menuOpen
3363
+ ? h('div', {
3364
+ className: 'dce-menu-scrim',
3365
+ onPointerDown: () => {
3366
+ setMenuOpen(false)
3367
+ },
3368
+ })
3369
+ : null,
3370
+ menuOpen
3371
+ ? h(
3372
+ 'div',
3373
+ { className: 'dce-menu', role: 'menu' },
3374
+ h('div', { className: 'dce-menu-title' }, t('menuTitle')),
3375
+ providersInOrder().map((entry) => {
3376
+ const on = isEnabled(entry.id)
3377
+ const lastOne = on && enabledIds.length <= 1
3378
+ return h(
3379
+ 'div',
3380
+ { key: entry.id, className: 'dce-menu-row' },
3381
+ h('span', { className: 'dce-menu-label' }, entry.label),
3382
+ h('button', {
3383
+ type: 'button',
3384
+ role: 'switch',
3385
+ className: 'dce-switch',
3386
+ 'data-on': on ? 'true' : 'false',
3387
+ 'aria-checked': on ? 'true' : 'false',
3388
+ 'aria-label': entry.label,
3389
+ title: lastOne ? t('lastTabHint') : entry.label,
3390
+ disabled: lastOne,
3391
+ onClick: () => {
3392
+ setProviderEnabled(entry.id, !on)
3393
+ },
3394
+ }),
3395
+ )
3396
+ }),
3397
+ h('div', { className: 'dce-menu-sep', 'aria-hidden': 'true' }),
3398
+ h(
3399
+ 'div',
3400
+ { className: 'dce-menu-row' },
3401
+ h('span', { className: 'dce-menu-label' }, t('persistLabel')),
3402
+ h(
3403
+ 'span',
3404
+ { className: 'dce-menu-value', title: t('persistHint') },
3405
+ persistenceText(t, persistenceState()),
3406
+ ),
3407
+ ),
3408
+ persistenceState() === 'on'
3409
+ ? null
3410
+ : h('div', { className: 'dce-menu-note', 'data-persist-risk': 'true' }, t('persistRisk')),
3411
+ persistenceState() === 'on'
3412
+ ? null
3413
+ : h(
3414
+ 'button',
3415
+ {
3416
+ type: 'button',
3417
+ className: 'dce-menu-item',
3418
+ 'data-patch-help': 'true',
3419
+ onClick: () => {
3420
+ void copyText(patchInstructions(t))
3421
+ },
3422
+ },
3423
+ h('span', { className: 'dce-menu-label' }, t('persistCopy')),
3424
+ ),
3425
+ h(
3426
+ 'button',
3427
+ {
3428
+ type: 'button',
3429
+ className: 'dce-menu-item',
3430
+ 'data-patch-check': 'true',
3431
+ disabled: patchCheck.running,
3432
+ onClick: () => {
3433
+ void runPatchCheck(t)
3434
+ },
3435
+ },
3436
+ h('span', { className: 'dce-menu-label' }, patchCheck.running ? t('persistCheckRunning') : t('persistCheck')),
3437
+ ),
3438
+ patchCheck.report === ''
3439
+ ? null
3440
+ : h('div', { className: 'dce-menu-note', 'data-patch-report': 'true' }, patchCheck.report),
3441
+ h(
3442
+ 'button',
3443
+ {
3444
+ type: 'button',
3445
+ className: 'dce-menu-item',
3446
+ 'aria-label': t('diagTitle'),
3447
+ onClick: () => {
3448
+ setMenuOpen(false)
3449
+ record.diagOpen = !record.diagOpen
3450
+ notify()
3451
+ },
3452
+ },
3453
+ infoIcon(),
3454
+ h('span', { className: 'dce-menu-label' }, t('diagTitle') + '…'),
3455
+ ),
3456
+ h(
3457
+ 'div',
3458
+ { className: 'dce-menu-row' },
3459
+ h('span', { className: 'dce-menu-label' }, t('logging')),
3460
+ h(
3461
+ 'button',
3462
+ {
3463
+ type: 'button',
3464
+ role: 'switch',
3465
+ className: 'dce-switch',
3466
+ 'data-on': logEnabled ? 'true' : 'false',
3467
+ 'aria-checked': logEnabled ? 'true' : 'false',
3468
+ 'data-log-toggle': 'true',
3469
+ 'aria-label': t('logging'),
3470
+ title: t('loggingHint'),
3471
+ onClick: () => {
3472
+ setLogging(!logEnabled)
3473
+ },
3474
+ },
3475
+ ),
3476
+ ),
3477
+ )
3478
+ : null,
3479
+ showStatus
3480
+ ? h(
3481
+ 'div',
3482
+ { className: 'dce-status-bar' },
3483
+ h('span', { className: 'dce-dot', 'data-phase': snapshot.phase, 'aria-hidden': true }),
3484
+ h('span', { className: 'dce-status' }, t(STATUS_KEY[snapshot.phase] || 'statusIdle')),
3485
+ snapshot.detail === '' ? null : h('span', { className: 'dce-diag' }, snapshot.detail),
3486
+ )
3487
+ : null,
3488
+ h(
3489
+ 'div',
3490
+ { className: 'dce-surface' },
3491
+ h('div', { className: 'dce-viewport', ref: hostRef }),
3492
+ snapshot.phase === PHASE.UNSUPPORTED
3493
+ ? hintCard(t('statusUnsupported'), t('unsupportedHint'), t, snapshot)
3494
+ : null,
3495
+ snapshot.phase === PHASE.FAILED
3496
+ ? hintCard(t('statusFailed'), t('failedHint'), t, snapshot, onReload)
3497
+ : null,
3498
+ record.oauthBlocked
3499
+ ? h(
3500
+ 'div',
3501
+ { className: 'dce-card', role: 'alert' },
3502
+ h('div', { className: 'dce-card-title' }, t('oauthBlockedTitle')),
3503
+ h('div', { className: 'dce-card-body' }, t('oauthBlockedBody')),
3504
+ record.lastUrl === '' ? null : h('div', { className: 'dce-card-detail' }, record.lastUrl),
3505
+ h(
3506
+ 'div',
3507
+ { className: 'dce-card-actions' },
3508
+ h(
3509
+ 'button',
3510
+ { type: 'button', className: 'dce-button dce-button-primary', onClick: onReload },
3511
+ t('goHome'),
3512
+ ),
3513
+ h(
3514
+ 'button',
3515
+ { type: 'button', className: 'dce-button', onClick: openInBrowser },
3516
+ t('openInBrowser'),
3517
+ ),
3518
+ h(
3519
+ 'button',
3520
+ {
3521
+ type: 'button',
3522
+ className: 'dce-button',
3523
+ onClick: () => {
3524
+ record.oauthBlocked = false
3525
+ notify()
3526
+ },
3527
+ },
3528
+ t('dismiss'),
3529
+ ),
3530
+ ),
3531
+ )
3532
+ : null,
3533
+ record.diagOpen
3534
+ ? h(
3535
+ 'div',
3536
+ { className: 'dce-card', role: 'dialog' },
3537
+ h('div', { className: 'dce-card-title' }, t('diagTitle')),
3538
+ h(
3539
+ 'div',
3540
+ { className: 'dce-card-detail' },
3541
+ t('diagUrl') + ': ' + (record.lastUrl === '' ? guestUrl(record.element) || '—' : record.lastUrl),
3542
+ ),
3543
+ h('div', { className: 'dce-card-detail' }, t('diagRight') + ': ' + describeRightSidebar(t)),
3544
+ h('div', { className: 'dce-card-detail' }, t('diagSeat') + ': ' + describeSeat(t)),
3545
+ record.selfTest === ''
3546
+ ? null
3547
+ : h('div', { className: 'dce-card-detail' }, t('selfTest') + ': ' + record.selfTest),
3548
+ h(
3549
+ 'div',
3550
+ { className: 'dce-card-detail' },
3551
+ t('diagPhase') + ': ' + phaseText(t, snapshot.phase) + (snapshot.detail === '' ? '' : ' · ' + snapshot.detail),
3552
+ ),
3553
+ h(
3554
+ 'div',
3555
+ { className: 'dce-card-detail' },
3556
+ t('partitionLabel') + ': ' + shortId(record.partition) + ' · #' + record.mounts,
3557
+ ),
3558
+ h(
3559
+ 'div',
3560
+ { className: 'dce-card-detail' },
3561
+ t('diagEvent') + ': ' + (record.lastEvent === '' ? '—' : valueText(t, EVENT_KEY, record.lastEvent)),
3562
+ ),
3563
+ h(
3564
+ 'div',
3565
+ { className: 'dce-card-detail' },
3566
+ t('logging') + ': ' + (logEnabled ? t('loggingOn') : t('loggingOff')),
3567
+ ),
3568
+ logEnabled
3569
+ ? h(
3570
+ 'button',
3571
+ {
3572
+ type: 'button',
3573
+ className: 'dce-button',
3574
+ onClick: () => {
3575
+ void copyText(JSON.stringify(readLogEntries(), null, 2))
3576
+ },
3577
+ },
3578
+ t('loggingCopy'),
3579
+ )
3580
+ : null,
3581
+ record.handoff === ''
3582
+ ? null
3583
+ : h('div', { className: 'dce-card-detail' }, t('diagHandoff') + ': ' + record.handoff),
3584
+ h('div', { className: 'dce-card-body' }, t('diagHint')),
3585
+ h(
3586
+ 'div',
3587
+ { className: 'dce-card-actions' },
3588
+ h(
3589
+ 'button',
3590
+ {
3591
+ type: 'button',
3592
+ className: 'dce-button',
3593
+ onClick: () => {
3594
+ record.selfTest = runSeatSelfTest(t)
3595
+ notify()
3596
+ },
3597
+ },
3598
+ t('selfTest'),
3599
+ ),
3600
+ h(
3601
+ 'button',
3602
+ { type: 'button', className: 'dce-button dce-button-primary', onClick: copyDiagnostics },
3603
+ copyState === 'copied' ? t('copied') : copyState === 'failed' ? t('copyFailed') : t('copyDiag'),
3604
+ ),
3605
+ h('button', { type: 'button', className: 'dce-button', onClick: onReload }, t('reload')),
3606
+ h(
3607
+ 'button',
3608
+ {
3609
+ type: 'button',
3610
+ className: 'dce-button',
3611
+ onClick: () => {
3612
+ record.diagOpen = false
3613
+ notify()
3614
+ },
3615
+ },
3616
+ t('dismiss'),
3617
+ ),
3618
+ ),
3619
+ )
3620
+ : null,
3621
+ ),
3622
+ ),
3623
+ )
3624
+ }
3625
+
3626
+ /** The left-navigation entry; inherits color so it follows the theme. */
3627
+ function ChatNavIcon(props) {
3628
+ const size = props !== undefined && props !== null && typeof props.size === 'number' && props.size > 0 ? props.size : 20
3629
+ const active = props !== undefined && props !== null && props.active === true
3630
+ return h(
3631
+ 'svg',
3632
+ {
3633
+ width: size,
3634
+ height: size,
3635
+ viewBox: '0 0 24 24',
3636
+ fill: 'none',
3637
+ stroke: 'currentColor',
3638
+ strokeWidth: active ? 2 : 1.7,
3639
+ strokeLinecap: 'round',
3640
+ strokeLinejoin: 'round',
3641
+ 'aria-hidden': true,
3642
+ focusable: 'false',
3643
+ style: { display: 'block', opacity: active ? 1 : 0.82 },
3644
+ },
3645
+ h('path', { d: 'M4.2 6.6c0-1.2 1-2.2 2.2-2.2h11.2c1.2 0 2.2 1 2.2 2.2v7.9c0 1.2-1 2.2-2.2 2.2H10l-4.6 3.1c-.5.3-1.2 0-1.2-.7V6.6Z' }),
3646
+ h('path', { d: 'M8.2 9.4h7.6M8.2 12.8h4.8' }),
3647
+ )
3648
+ }
3649
+
3650
+ // ------------------------------------------------------------------
3651
+ // Plugin record
3652
+ // ------------------------------------------------------------------
3653
+
3654
+ // Test hook: dead code in the app, because dsh never sets this flag. It
3655
+ // lets the suite drive the lease handshake against a fake shell instead of
3656
+ // mounting the React UI.
3657
+ if (globalThis.__DSH_CHAT_EMBED_TEST__ === true) {
3658
+ globalThis.__DSHCE_TEST__ = {
3659
+ NS,
3660
+ PANEL_ID,
3661
+ PROVIDERS,
3662
+ DEFAULT_PROVIDER,
3663
+ ACTIVE_TAB_KEY,
3664
+ TAB_ORDER_KEY,
3665
+ BUILD,
3666
+ PHASE,
3667
+ DICTS,
3668
+ CSS,
3669
+ TIMINGS,
3670
+ isOAuthUrl,
3671
+ isGuestBlank,
3672
+ normalizedUserAgent,
3673
+ currentUserAgent,
3674
+ desktopBrowser,
3675
+ isEmbedSupported,
3676
+ shortId,
3677
+ isLiveUrl,
3678
+ findProvider,
3679
+ getStatus,
3680
+ watch,
3681
+ activeProvider,
3682
+ setActive,
3683
+ guestRecord,
3684
+ mountGuest,
3685
+ reloadGuest,
3686
+ destroyGuests,
3687
+ navigateGuest,
3688
+ startUrlFor,
3689
+ lastUrls,
3690
+ orderedProviders,
3691
+ getTabOrder,
3692
+ moveProvider,
3693
+ normalizeOrder,
3694
+ isEnabled,
3695
+ getEnabled,
3696
+ setProviderEnabled,
3697
+ providersInOrder,
3698
+ ENABLED_KEY,
3699
+ getSurface,
3700
+ formatDiagReport,
3701
+ copyText,
3702
+ ensureActiveGuest,
3703
+ inlineHosts,
3704
+ resolveInlineHost,
3705
+ hostsInline,
3706
+ inlineSeat,
3707
+ ChatCenterPage,
3708
+ updateSurface,
3709
+ getLogging,
3710
+ persistenceState,
3711
+ readPersistedSeen,
3712
+ storePersistenceSeen,
3713
+ livePartitionState,
3714
+ patchInstructions,
3715
+ formatPatchReport,
3716
+ runPatchCheck,
3717
+ patchCheck,
3718
+ setLogging,
3719
+ clearLog,
3720
+ readLogEntries,
3721
+ log,
3722
+ preferSeat,
3723
+ ChatSurface,
3724
+ ChatNavIcon,
3725
+ ChatRightBody,
3726
+ ChatRightTitle,
3727
+ ChatDockButton,
3728
+ RIGHT_TAB_ID,
3729
+ RIGHT_TAB_KIND,
3730
+ RIGHT_TAB_SLOT,
3731
+ RIGHT_TAB_TITLE_SLOT,
3732
+ DOCK_SLOT,
3733
+ DOCK_ID,
3734
+ PANEL_ID,
3735
+ describeSeat,
3736
+ runSeatSelfTest,
3737
+ seatReportData,
3738
+ installInspect,
3739
+ formatRect,
3740
+ translator,
3741
+ rightTabType,
3742
+ openRightTab,
3743
+ rightSidebarStatus,
3744
+ setOpenRightTabImpl: (impl) => {
3745
+ openRightTabImpl = typeof impl === 'function' ? impl : () => {}
3746
+ },
3747
+ }
3748
+ }
3749
+
3750
+ return {
3751
+ inject: ['slots', 'locale'],
3752
+ apply(ctx) {
3753
+ // A hot reload can apply this bundle again while the previous instance is
3754
+ // still alive. Its slots, tab type and observers would then block or
3755
+ // shadow ours (duplicate id, single-slot collision), so take over first.
3756
+ const carrier = typeof window !== 'undefined' && window !== null ? window : undefined
3757
+ const previous = carrier === undefined ? undefined : carrier[INSTANCE_KEY]
3758
+ if (previous !== undefined && previous !== null && typeof previous.dispose === 'function') {
3759
+ try {
3760
+ previous.dispose()
3761
+ } catch (error) {
3762
+ void error
3763
+ }
3764
+ }
3765
+
3766
+ const disposers = []
3767
+ const disposeAll = () => {
3768
+ while (disposers.length > 0) {
3769
+ const dispose = disposers.pop()
3770
+ try {
3771
+ dispose()
3772
+ } catch (error) {
3773
+ void error
3774
+ }
3775
+ }
3776
+ if (typeof rightTabTypeDispose === 'function') {
3777
+ try {
3778
+ rightTabTypeDispose()
3779
+ } catch (error) {
3780
+ void error
3781
+ }
3782
+ }
3783
+ rightTabTypeDispose = undefined
3784
+ rightSeatsInstalled = false
3785
+ rightSeatAttempts = []
3786
+ rightTypeState = { state: 'idle', detail: '' }
3787
+ openRightTabImpl = () => {}
3788
+ try {
3789
+ destroyGuests()
3790
+ } catch (error) {
3791
+ void error
3792
+ }
3793
+ }
3794
+ if (carrier !== undefined) carrier[INSTANCE_KEY] = { version: BUILD, dispose: disposeAll }
3795
+
3796
+ // Locale dictionaries first: the entry label reads them.
3797
+ disposers.push(ctx.effect(() => ctx.locale.register(NS, DICTS), 'dsh-chat-embed: dictionaries'))
3798
+ const t = ctx.locale.bind(NS)
3799
+ uiT = t
3800
+ if (typeof ctx.on === 'function') {
3801
+ // Switching the DSH language re-renders the panel immediately; a
3802
+ // language change never needs a reload.
3803
+ disposers.push(ctx.on('locale/change', () => notify()))
3804
+ }
3805
+
3806
+ // Slots: the chat page in the center panel, its sidebar icon, the right
3807
+ // sidebar tab (body + chip title) and the session header button.
3808
+ disposers.push(
3809
+ ctx.effect(() => {
3810
+ const entries = [
3811
+ ctx.slots.inject('main', () =>
3812
+ ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS, registrant: 'dsh-chat-embed' }, ChatCenterPage),
3813
+ ),
3814
+ ctx.slots.inject('sidebar.panellist', () =>
3815
+ ctx.slots.register(
3816
+ {
3817
+ name: 'sidebar.panellist',
3818
+ id: PANEL_ID,
3819
+ order: 30,
3820
+ label: () => t('navLabel'),
3821
+ locale: NS,
3822
+ registrant: 'dsh-chat-embed',
3823
+ },
3824
+ ChatNavIcon,
3825
+ ),
3826
+ ),
3827
+ ]
3828
+ return () => {
3829
+ for (const dispose of entries) dispose()
3830
+ }
3831
+ }, 'dsh-chat-embed: entries'),
3832
+ )
3833
+
3834
+ // Styles and the guests share one lifetime: unloading the plugin must
3835
+ // release every lease and leave no detached surface behind.
3836
+ disposers.push(
3837
+ ctx.effect(() => {
3838
+ const style = injectStyles()
3839
+ return () => {
3840
+ destroyGuests()
3841
+ if (style !== null && style !== undefined && style.parentNode !== null && style.parentNode !== undefined) {
3842
+ style.parentNode.removeChild(style)
3843
+ }
3844
+ }
3845
+ }, 'dsh-chat-embed: surface'),
3846
+ )
3847
+
3848
+ // The right column is an optional neighbour. Wire it up in isolated
3849
+ // steps: a rejected seat or a service that shows up later must never
3850
+ // take the whole plugin (and its left entry) down with it.
3851
+ installRightSidebar(ctx, t)
3852
+ disposers.push(() => {
3853
+ while (rightDisposers.length > 0) {
3854
+ const dispose = rightDisposers.pop()
3855
+ try {
3856
+ dispose()
3857
+ } catch (error) {
3858
+ void error
3859
+ }
3860
+ }
3861
+ })
3862
+
3863
+ // Read-only inspection channel: the live geometry can be read from the
3864
+ // Host without user interaction.
3865
+ const disposeInspect = installInspect(ctx, t)
3866
+ if (typeof disposeInspect === 'function') disposers.push(disposeInspect)
3867
+ },
3868
+ }
3869
+ },
3870
+ })