dsh-chat-embed 0.0.0-stage → 0.5.4

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