dsh-live-browser 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,835 @@
1
+ /**
2
+ * dsh-live-browser client half.
3
+ *
4
+ * The right-sidebar browser you already see is DSH's own
5
+ * `@deepseek-ai/dsh-client-ui-sidebar-browser`: it owns the tab strip, the
6
+ * address bar, the guest <webview> and the browser session. This plugin adds the
7
+ * one thing that package cannot do — let the agent drive the page.
8
+ *
9
+ * How it attaches: the official plugin tags every guest with
10
+ * `data-sidebar-browser-frame="webview"`, so this bundle finds those elements in
11
+ * the live DOM, injects a command surface into each document, and polls the host
12
+ * for work. No DSH file is modified, so app upgrades cannot break it.
13
+ *
14
+ * Two command families, handled differently:
15
+ *
16
+ * - Page commands (click/type/snapshot/...) run *inside* the guest through the
17
+ * injected script.
18
+ * - Browser commands (navigate/back/forward/reload) drive the guest element
19
+ * itself, exactly the way the official plugin's toolbar does.
20
+ *
21
+ * Wrapped in an IIFE: DSH batches several plugin bundles into one combo
22
+ * <script>, so a top-level `const` here would collide with a sibling bundle.
23
+ */
24
+
25
+ ;(function () {
26
+ 'use strict'
27
+
28
+ /** Live guests are found by this attribute, owned by the official plugin. */
29
+ const GUEST_SELECTOR = 'webview[data-sidebar-browser-frame="webview"]'
30
+
31
+ /** One per conversation: DSH keeps a sidebar mounted for each chat it visited. */
32
+ const SESSION_SELECTOR = '[data-sidebar-right-session]'
33
+
34
+ /**
35
+ * How long one poll request may be held open by the host.
36
+ *
37
+ * The old design woke the panel every 50 ms: every command paid an average
38
+ * 25 ms of pure hand-off latency and an idle panel generated 20 HTTP requests
39
+ * per second. A long poll removes both — the host answers the moment a
40
+ * command is queued. The value stays below the browser's request patience.
41
+ */
42
+ const POLL_WAIT_MS = 25000
43
+
44
+ /** Only used to retry after a failed poll, never on the happy path. */
45
+ const POLL_RETRY_MS = 250
46
+
47
+ /** Browser-level commands and the guest method that performs each. */
48
+ const GUEST_METHODS = { back: 'goBack', forward: 'goForward', reload: 'reload' }
49
+
50
+ /** Normalize a typed or model-supplied address. */
51
+ function normalizeAddress(raw) {
52
+ const text = String(raw ?? '').trim()
53
+ if (text === '') return undefined
54
+ // localhost / bare IP first: "localhost:5173" otherwise matches the scheme
55
+ // pattern and turns into a nonsense URL.
56
+ if (/^localhost(:\d+)?(\/|$)/i.test(text)) return `http://${text}`
57
+ if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/|$)/.test(text)) return `http://${text}`
58
+ if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return text
59
+ return `https://${text}`
60
+ }
61
+
62
+ /** Make a page-produced value safe to send back as JSON. */
63
+ function normalizeValue(value) {
64
+ if (value === undefined) return null
65
+ if (value === null || typeof value !== 'object') return value
66
+ try {
67
+ return JSON.parse(JSON.stringify(value))
68
+ } catch {
69
+ return String(value)
70
+ }
71
+ }
72
+
73
+ /** The bridge to the host half: ask for work, post results. */
74
+ function createRuntime() {
75
+ const channel = '/dsh-live-browser'
76
+ const call = async (endpoint, payload = {}) => {
77
+ const rpcId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
78
+ const response = await fetch(`${channel}/${endpoint}`, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload }),
82
+ })
83
+ if (!response.ok) throw new Error(`浏览器桥接不可用:HTTP ${response.status}`)
84
+ const body = await response.json()
85
+ const result = body?.result
86
+ if (result?.ok === false) throw new Error(String(result?.error?.message ?? '浏览器操作失败'))
87
+ return result?.value
88
+ }
89
+ return {
90
+ // The panel announces which conversation it is rendering so the host can
91
+ // hand it only that conversation's commands.
92
+ poll: (panelId, sessionId) => call('poll', { panelId, sessionId, waitMs: POLL_WAIT_MS }),
93
+ settle: (panelId, id, outcome) => call('settle', { panelId, id, outcome }),
94
+ /** The injected script is served by the host half, so it always matches. */
95
+ source: () => call('source', {}).then((value) => String(value?.source ?? '')),
96
+ /** Behaviour flags the host owns; read right before an auto-open. */
97
+ config: () => call('config', {}),
98
+ /** Tab management is a host concern: only it can drive the sidebar. */
99
+ tabCommand: (action, params) => call('tabCommand', { action, params }),
100
+ /** Read a local file the agent wants to attach to a file input. */
101
+ readUpload: (path) => call('readUpload', { path }),
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Drive whichever sidebar-browser guests exist.
107
+ *
108
+ * @param {{ bridge: ReturnType<typeof createRuntime>, loadSource: () => Promise<string>, openPanel?: (url?: string, sessionId?: string) => Promise<object>, documentRef?: Document, currentSessionId?: () => string | undefined }} options
109
+ */
110
+ function createDriver({ bridge, loadSource, openPanel, documentRef, currentSessionId }) {
111
+ const panelId = `panel-${Math.random().toString(36).slice(2, 10)}`
112
+ /** @type {Map<Element, {ready: boolean, installed: boolean, url: string, failure?: string}>} */
113
+ const known = new Map()
114
+ let source = ''
115
+ let stopped = false
116
+ let busy = false
117
+ let timer
118
+ /** Set when the last poll round threw, so the loop backs off once. */
119
+ let tickFailed = false
120
+
121
+ const scope = () => documentRef ?? (typeof document === 'undefined' ? undefined : document)
122
+
123
+ /**
124
+ * The sidebar the human is looking at.
125
+ *
126
+ * Re-read on every tick rather than cached: switching chats swaps which
127
+ * session view is shown without this component remounting, and a stale
128
+ * element would let one chat's command run against another chat's page.
129
+ */
130
+ const view = () => foregroundSessionView(scope())
131
+
132
+ /**
133
+ * The conversation this panel is showing right now.
134
+ *
135
+ * The id and the guests are read from that same view, so the pair can never
136
+ * disagree: whatever gets reported belongs to the sidebar the agent will act
137
+ * on. Without a mounted sidebar the shell's current conversation is the only
138
+ * source left, and it is exactly where a cold start has to open the tab.
139
+ */
140
+ const sessionId = () => {
141
+ const shown = view()
142
+ if (shown !== undefined) return viewSessionId(shown)
143
+ return currentSessionId?.()
144
+ }
145
+
146
+ /**
147
+ * The sidebar of one conversation, or the one on screen when none is named.
148
+ *
149
+ * A command names its conversation; the guest has to come from that same
150
+ * chat's sidebar even when the user has switched to another one meanwhile.
151
+ */
152
+ const viewFor = (target) => {
153
+ if (target === undefined) return view()
154
+ return sessionViews(scope()).find((candidate) => viewSessionId(candidate) === target)
155
+ }
156
+
157
+ const findGuests = (target) => {
158
+ const shown = viewFor(target)
159
+ return shown === undefined ? [] : [...shown.querySelectorAll(GUEST_SELECTOR)]
160
+ }
161
+
162
+ /**
163
+ * Whether the settings still allow opening the sidebar browser by ourselves.
164
+ *
165
+ * Read per attempt rather than cached: the setting lives on the host and can
166
+ * change without reloading the page. A failed read keeps the documented
167
+ * default (allowed), so a transient host hiccup does not disable the feature.
168
+ */
169
+ const canAutoOpen = async () => {
170
+ try {
171
+ return (await bridge.config())?.autoOpenPanel !== false
172
+ } catch {
173
+ return true
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The guest the agent should act on: the one holding focus, else the last
179
+ * one that is actually laid out.
180
+ *
181
+ * Every visited tab keeps its body mounted (`keepMounted`), and DSH hides the
182
+ * inactive ones with the `hidden` attribute. Document order therefore says
183
+ * nothing about what the human sees: the last mounted guest is often a tab
184
+ * that is not on screen.
185
+ */
186
+ const targetGuest = (target) => {
187
+ const guests = findGuests(target)
188
+ if (guests.length === 0) return undefined
189
+ const doc = scope()
190
+ const focused = guests.find((element) => element === doc?.activeElement)
191
+ if (focused !== undefined) return focused
192
+ const onScreen = guests.filter(isLaidOut)
193
+ if (onScreen.length > 0) return onScreen[onScreen.length - 1]
194
+ return guests[guests.length - 1]
195
+ }
196
+
197
+ /**
198
+ * Wait for a usable guest to appear, tracking anything new on the way.
199
+ *
200
+ * @param {number} timeoutMs how long to wait
201
+ * @param {string} [target] conversation whose sidebar to search
202
+ * @returns {Promise<Element | undefined>} a ready guest, or undefined
203
+ */
204
+ const waitForGuest = async (timeoutMs, target) => {
205
+ const deadline = Date.now() + timeoutMs
206
+ while (Date.now() < deadline) {
207
+ for (const candidate of findGuests(target)) {
208
+ if (!known.has(candidate)) track(candidate)
209
+ }
210
+ const ready = findGuests(target).find((candidate) => known.get(candidate)?.ready === true)
211
+ if (ready !== undefined) return ready
212
+ await new Promise((resolve) => setTimeout(resolve, 100))
213
+ }
214
+ return undefined
215
+ }
216
+
217
+ /** Track a guest element and notice each new document it loads. */
218
+ const track = (element) => {
219
+ const state = { ready: false, installed: false, url: '', failure: undefined }
220
+ known.set(element, state)
221
+ const refresh = () => {
222
+ try {
223
+ state.url = element.getURL()
224
+ state.ready = state.url !== ''
225
+ } catch {
226
+ /* the element is between documents */
227
+ }
228
+ // A new document means the injected surface is gone.
229
+ state.installed = false
230
+ state.failure = undefined
231
+ }
232
+ element.addEventListener('dom-ready', refresh)
233
+ element.addEventListener('did-navigate', refresh)
234
+ element.addEventListener('did-navigate-in-page', refresh)
235
+ element.addEventListener('did-start-navigation', () => { state.installed = false })
236
+ // A page that never loads otherwise reports "still loading, retry later"
237
+ // forever: the guest emits this instead of ever becoming ready.
238
+ element.addEventListener('did-fail-load', (event) => {
239
+ // ERR_ABORTED (-3) is what every ordinary redirect produces, so it is not
240
+ // a failure worth reporting.
241
+ if (event?.errorCode === -3) return
242
+ state.failure = String(event?.errorDescription ?? `加载失败(${event?.errorCode ?? '?'})`)
243
+ state.ready = false
244
+ state.installed = false
245
+ })
246
+ try {
247
+ if (typeof element.getURL === 'function') refresh()
248
+ } catch {
249
+ /* not attached yet */
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Inject the command surface, skipping the work when it is already there.
255
+ *
256
+ * The bundle is ~29 KB of source. After a same-document navigation the
257
+ * global survives, so running a one-line probe first avoids re-sending and
258
+ * re-evaluating the whole script on every SPA route change.
259
+ */
260
+ const ensureInstalled = async (element) => {
261
+ const state = known.get(element)
262
+ if (state === undefined) return false
263
+ if (state.installed) return true
264
+ if (source === '') return false
265
+ let present = false
266
+ try {
267
+ present = await element.executeJavaScript(
268
+ "typeof window.__dshBrowserAgent === 'object' && typeof window.__dshBrowserAgent.state === 'function'",
269
+ false,
270
+ )
271
+ } catch {
272
+ present = false
273
+ }
274
+ if (present !== true) await element.executeJavaScript(source, false)
275
+ state.installed = true
276
+ return true
277
+ }
278
+
279
+ const describeGuest = (element) => {
280
+ const state = known.get(element)
281
+ let url = state?.url ?? ''
282
+ let title = ''
283
+ let canGoBack = false
284
+ let canGoForward = false
285
+ let loading = false
286
+ try {
287
+ url = element.getURL() || url
288
+ title = element.getTitle()
289
+ canGoBack = element.canGoBack()
290
+ canGoForward = element.canGoForward()
291
+ loading = element.isLoading()
292
+ } catch {
293
+ /* the element is between documents */
294
+ }
295
+ return { id: url, url, title, canGoBack, canGoForward, loading }
296
+ }
297
+
298
+ /** Perform one command on the chosen guest. */
299
+ const dispatch = async (element, command, target) => {
300
+ const method = String(command.method)
301
+
302
+ // Tab management goes through the host: the sidebar owns the tab strip, and
303
+ // a guest element cannot create or close its siblings.
304
+ if (method === 'tabNew' || method === 'tabClose' || method === 'tabActivate') {
305
+ return bridge.tabCommand(method, command.params ?? {})
306
+ }
307
+
308
+ // Browser-level commands drive the element, like the toolbar does.
309
+ if (method in GUEST_METHODS) {
310
+ element[GUEST_METHODS[method]]()
311
+ return describeGuest(element)
312
+ }
313
+ if (method === 'navigate') {
314
+ const url = normalizeAddress(command.params?.url)
315
+ if (url === undefined) throw new Error('地址为空')
316
+ await element.loadURL(url)
317
+ return describeGuest(element)
318
+ }
319
+ if (method === 'tabs') {
320
+ return { tabs: findGuests(target).map(describeGuest) }
321
+ }
322
+ if (method === 'state') {
323
+ const guest = describeGuest(element ?? targetGuest(target))
324
+ // Page state adds the viewport and scroll offset.
325
+ if (await ensureInstalled(element)) {
326
+ const page = await element.executeJavaScript(
327
+ `window.__dshBrowserAgent.state()`,
328
+ true,
329
+ )
330
+ return { ...guest, ...normalizeValue(page) }
331
+ }
332
+ return guest
333
+ }
334
+
335
+ // Everything else runs inside the page.
336
+ if (!(await ensureInstalled(element))) {
337
+ throw new Error('无法在页面里注入操作脚本(页面可能禁止脚本执行)')
338
+ }
339
+ const params = { ...(command.params ?? {}) }
340
+ const expression = `(async () => { ${source}; return await window.__dshBrowserAgent[${JSON.stringify(method)}](${JSON.stringify(params)}); })()`
341
+ return element.executeJavaScript(expression, true)
342
+ }
343
+
344
+ const tick = async () => {
345
+ if (stopped || busy) return
346
+ busy = true
347
+ try {
348
+ // Keep the guest map in step with the DOM. Every mounted conversation's
349
+ // sidebar counts, not just the visible one: a command can name a chat the
350
+ // user has just switched away from, and its guest still has to be tracked.
351
+ for (const shown of sessionViews(scope())) {
352
+ for (const element of shown.querySelectorAll(GUEST_SELECTOR)) {
353
+ if (!known.has(element)) track(element)
354
+ }
355
+ }
356
+ for (const element of [...known.keys()]) {
357
+ if (!element.isConnected) known.delete(element)
358
+ }
359
+
360
+ const command = await bridge.poll(panelId, sessionId())
361
+ if (command === null || command === undefined) return
362
+
363
+ // The conversation on screen *now*. The poll above may have been parked
364
+ // for up to 25 s, so the panel can still be registered against the chat
365
+ // the user was in when it started; a command handed over in that window
366
+ // would otherwise run against a page that is no longer visible.
367
+ const shown = sessionId()
368
+ if (command.sessionId !== undefined && shown !== undefined && command.sessionId !== shown) {
369
+ await bridge.settle(panelId, command.id, {
370
+ ok: false,
371
+ error: `这条命令属于另一个对话(${command.sessionId}),当前显示的是 ${shown};为避免操作到看不见的页面,已拒绝。请切回发起命令的对话后重试。`,
372
+ })
373
+ return
374
+ }
375
+
376
+ // The sidebar that belongs to the command's own conversation. A command
377
+ // without one comes from an older caller and belongs to whatever is shown.
378
+ const target = command.sessionId ?? shown
379
+
380
+ // No page on screen: this is the cold-start case. Open the sidebar
381
+ // browser ourselves (it is the only place the page can render), give the
382
+ // guest a moment to mount, then run the command against it. The user
383
+ // never has to open a browser by hand, and nothing leaves DSH.
384
+ let element = targetGuest(target)
385
+ if (element === undefined) {
386
+ if (!(await canAutoOpen())) {
387
+ await bridge.settle(panelId, command.id, {
388
+ ok: false,
389
+ error:
390
+ '当前对话的右侧栏还没有网页,且插件设置里关闭了「自动打开面板」;请先手动打开「浏览器」标签页后重试',
391
+ })
392
+ return
393
+ }
394
+ if (openPanel === undefined) {
395
+ await bridge.settle(panelId, command.id, {
396
+ ok: false,
397
+ error: '右侧栏还没有打开任何网页,并且当前环境无法自动打开浏览器',
398
+ })
399
+ return
400
+ }
401
+ try {
402
+ await openPanel(
403
+ command.method === 'navigate' ? command.params?.url : undefined,
404
+ // Open in the chat that asked, not the chat now on screen.
405
+ target,
406
+ )
407
+ } catch (failure) {
408
+ await bridge.settle(panelId, command.id, {
409
+ ok: false,
410
+ error: `无法自动打开侧边栏浏览器:${String(failure?.message ?? failure)}`,
411
+ })
412
+ return
413
+ }
414
+ element = await waitForGuest(8000, target)
415
+ if (element === undefined) {
416
+ await bridge.settle(panelId, command.id, {
417
+ ok: false,
418
+ error: '侧边栏浏览器已打开,但页面还没准备好,请稍后重试',
419
+ })
420
+ return
421
+ }
422
+ }
423
+
424
+ const state = known.get(element)
425
+ if (state !== undefined && state.failure !== undefined) {
426
+ // Say what actually went wrong instead of "still loading".
427
+ await bridge.settle(panelId, command.id, {
428
+ ok: false,
429
+ error: `页面加载失败:${state.failure}`,
430
+ })
431
+ return
432
+ }
433
+ if (state === undefined || !state.ready) {
434
+ await bridge.settle(panelId, command.id, { ok: false, error: '页面还在加载,请稍后重试' })
435
+ return
436
+ }
437
+
438
+ try {
439
+ const value = await dispatch(element, command, target)
440
+ await bridge.settle(panelId, command.id, { ok: true, value: normalizeValue(value) })
441
+ } catch (failure) {
442
+ await bridge.settle(panelId, command.id, {
443
+ ok: false,
444
+ error: String(failure?.message ?? failure),
445
+ })
446
+ }
447
+ } catch {
448
+ // Back off once so a persistently broken endpoint cannot spin.
449
+ tickFailed = true
450
+ } finally {
451
+ busy = false
452
+ }
453
+ }
454
+
455
+ return {
456
+ async start() {
457
+ // The host may be restarting while the page reloads; keep asking for the
458
+ // injected script instead of leaving the panel dead until a manual reload.
459
+ while (!stopped) {
460
+ try {
461
+ source = await loadSource()
462
+ break
463
+ } catch {
464
+ await new Promise((resolve) => setTimeout(resolve, 1000))
465
+ }
466
+ }
467
+ if (stopped) return
468
+ // Long poll: one request stays open until there is work. The loop only
469
+ // sleeps after a failure, so a healthy idle panel makes no requests.
470
+ const loop = async () => {
471
+ if (stopped) return
472
+ try {
473
+ await tick()
474
+ } finally {
475
+ if (stopped) return
476
+ const wait = tickFailed === true ? POLL_RETRY_MS : 0
477
+ tickFailed = false
478
+ timer = setTimeout(() => { void loop() }, wait)
479
+ }
480
+ }
481
+ void loop()
482
+ },
483
+ stop() {
484
+ stopped = true
485
+ clearTimeout(timer)
486
+ known.clear()
487
+ },
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Every sidebar currently in the document.
493
+ *
494
+ * DSH marks each conversation's sidebar wrapper with
495
+ * `data-sidebar-right-session` — the same attribute the shipped sidebar uses to
496
+ * validate its own elements. It keeps one sidebar per conversation: the selected
497
+ * one plus every background chat that still holds `keepMounted` content, and
498
+ * the browser tab is one of those. They are ordered by session id, not by what
499
+ * is on screen.
500
+ *
501
+ * Never throws: this runs on every poll tick, and an unexpected DOM shape must
502
+ * degrade to "no sidebar" rather than break the loop.
503
+ *
504
+ * @param {Document | undefined} documentRef
505
+ * @returns {Element[]}
506
+ */
507
+ function sessionViews(documentRef) {
508
+ if (documentRef === undefined || documentRef === null) return []
509
+ try {
510
+ if (typeof documentRef.querySelectorAll !== 'function') return []
511
+ return [...documentRef.querySelectorAll(SESSION_SELECTOR)]
512
+ } catch {
513
+ return []
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Which conversation one sidebar belongs to.
519
+ *
520
+ * @param {Element | undefined} view
521
+ * @returns {string | undefined}
522
+ */
523
+ function viewSessionId(view) {
524
+ const id = view?.dataset?.sidebarRightSession
525
+ return typeof id === 'string' && id !== '' ? id : undefined
526
+ }
527
+
528
+ /**
529
+ * The sidebar the human is looking at.
530
+ *
531
+ * DSH keeps the background conversations' sidebars mounted so their pages stay
532
+ * alive, and marks those `hidden`. Reading the first
533
+ * `[data-sidebar-right-session]` instead picked the alphabetically first
534
+ * session, which is why the agent could drive a page from another chat.
535
+ *
536
+ * @param {Document | undefined} documentRef
537
+ * @returns {Element | undefined}
538
+ */
539
+ function foregroundSessionView(documentRef) {
540
+ return sessionViews(documentRef).find((view) => {
541
+ if (typeof view.hasAttribute === 'function' && view.hasAttribute('hidden')) return false
542
+ return typeof view.closest !== 'function' || view.closest('[hidden]') === null
543
+ })
544
+ }
545
+
546
+ /**
547
+ * Whether a guest has a box on screen.
548
+ *
549
+ * Inactive tabs stay mounted but hidden, so their guests keep existing while the
550
+ * human cannot see them. A laid-out box is the only reliable signal.
551
+ *
552
+ * @param {Element} element
553
+ */
554
+ function isLaidOut(element) {
555
+ try {
556
+ if (typeof element.closest === 'function' && element.closest('[hidden]') !== null) return false
557
+ const rect = element.getBoundingClientRect()
558
+ return rect.width > 1 && rect.height > 1
559
+ } catch {
560
+ return false
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Ask the sidebar to show the browser tab in one specific conversation.
566
+ *
567
+ * `openTab` targets whichever Session is on screen; `openTabIn` targets the
568
+ * Session that asked. Using the latter is what keeps a browser in the chat
569
+ * that requested it when the user switches chats mid-request.
570
+ *
571
+ * @param {object} ctx cordis client context
572
+ * @param {string | undefined} sessionId conversation that asked
573
+ * @param {string} [url] address to open in the new tab
574
+ */
575
+ async function openSidebarBrowser(ctx, sessionId, url) {
576
+ const service = ctx.reflect?.get?.('sidebarRight') ?? ctx.get?.('sidebarRight')
577
+ if (service === undefined || service === null) {
578
+ throw new Error('拿不到侧边栏服务:这个 DSH 版本可能没有内置浏览器')
579
+ }
580
+ const options = url === undefined ? {} : { params: { url } }
581
+ const canTarget = typeof service.openTabIn === 'function'
582
+ if (canTarget && sessionId !== undefined) {
583
+ service.openTabIn(sessionId, 'browser', options)
584
+ return { opened: true, sessionId }
585
+ }
586
+ if (typeof service.openTab === 'function') {
587
+ service.openTab('browser', options)
588
+ return { opened: true, sessionId: undefined }
589
+ }
590
+ throw new Error('侧边栏服务没有 openTab,无法自动打开浏览器')
591
+ }
592
+
593
+ /**
594
+ * The conversation the shell is showing.
595
+ *
596
+ * Only needed while no sidebar is mounted: the tab a cold start opens has to
597
+ * land in the chat on screen, and the DOM has no marker to read yet.
598
+ *
599
+ * @param {object} ctx cordis client context
600
+ * @returns {string | undefined}
601
+ */
602
+ function currentSessionId(ctx) {
603
+ try {
604
+ const current = ctx.get?.('uiSession')?.adapter?.current
605
+ const key = current?.getSnapshot?.()?.key
606
+ return typeof key === 'string' && key !== '' ? key : undefined
607
+ } catch {
608
+ return undefined
609
+ }
610
+ }
611
+
612
+ /** Escape a value for an attribute selector (tab ids are plain, but be safe). */
613
+ function attributeValue(value) {
614
+ return String(value).replace(/["\\]/g, '\\$&')
615
+ }
616
+
617
+ /**
618
+ * Ids of the tabs in the strip that holds `tabId`, in the order the human sees.
619
+ *
620
+ * The menu is opened from a chip in the tab strip; reading that strip is the
621
+ * only way to know what "to the right" means, because the sidebar keeps its
622
+ * tab order internal. Returns [] when the tab is not in a strip (floating pane).
623
+ *
624
+ * @param {Document | undefined} documentRef
625
+ * @param {string} tabId
626
+ * @returns {string[]}
627
+ */
628
+ function tabStripIds(documentRef, tabId) {
629
+ if (documentRef === undefined || documentRef === null || tabId === undefined) return []
630
+ try {
631
+ if (typeof documentRef.querySelector !== 'function') return []
632
+ const chip = documentRef.querySelector(`[data-dockkit-tab="${attributeValue(tabId)}"]`)
633
+ const strip = chip?.closest?.('[data-dockkit-strip-tabs]')
634
+ if (strip === undefined || strip === null) return []
635
+ return [...strip.querySelectorAll('[data-dockkit-tab]')]
636
+ .map((element) => element.dataset?.dockkitTab)
637
+ .filter((id) => typeof id === 'string' && id !== '')
638
+ } catch {
639
+ return []
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Which tabs each menu action closes, for the tab the menu was opened on.
645
+ *
646
+ * "To the right" excludes the tab itself, which is the convention every editor
647
+ * follows; "all" includes it, and the sidebar refuses to close the last guide
648
+ * tab on its own.
649
+ *
650
+ * @param {string[]} ids tab ids in strip order
651
+ * @param {string} currentId
652
+ * @returns {{others: string[], all: string[], right: string[]}}
653
+ */
654
+ function closingPlan(ids, currentId) {
655
+ const index = ids.indexOf(currentId)
656
+ return {
657
+ others: ids.filter((id) => id !== currentId),
658
+ all: [...ids],
659
+ right: index === -1 ? [] : ids.slice(index + 1),
660
+ }
661
+ }
662
+
663
+ /** Close one set of tabs through the sidebar controller, then hide the menu. */
664
+ function closeTabs(ctx, ids, dismiss) {
665
+ const service = ctx.reflect?.get?.('sidebarRight') ?? ctx.get?.('sidebarRight')
666
+ if (service === undefined || service === null || typeof service.close !== 'function') return
667
+ for (const id of ids) {
668
+ try {
669
+ service.close(id)
670
+ } catch {
671
+ /* the tab may already be gone; closing the rest still makes sense */
672
+ }
673
+ }
674
+ if (typeof dismiss === 'function') dismiss()
675
+ }
676
+
677
+ /** Styles that make our entries indistinguishable from the sidebar's own. */
678
+ const TAB_MENU_STYLE = `
679
+ .dsh-live-browser-menu-group{display:contents}
680
+ .dsh-live-browser-menu-item{padding:4px 7px;color:var(--dsw-alias-label-primary);font-size:calc(var(--dsh-content-font-size-secondary, 13px) - 1px);text-align:left;background:transparent;border:none;border-radius:var(--dsw-radius-md);cursor:pointer}
681
+ .dsh-live-browser-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
682
+ .dsh-live-browser-menu-item:disabled{opacity:.45;cursor:default}
683
+ `
684
+
685
+ /** Marker on our group, so the two entry points never double up. */
686
+ const TAB_MENU_MARK = 'data-dsh-live-browser-menu'
687
+
688
+ /**
689
+ * Add the three close actions to the tab menu that just opened.
690
+ *
691
+ * The shipped sidebar exposes a React slot for extra menu entries, but that
692
+ * registration has to win a race against the sidebar declaring the slot, and
693
+ * when it loses it silently does nothing. The menu itself is a stable portal
694
+ * (`[data-dockkit-tab-menu]`), so extending it here works on every build while
695
+ * still leaving DSH unpatched.
696
+ *
697
+ * @param {object} ctx cordis client context
698
+ * @param {Element | undefined} menu the open tab menu
699
+ * @param {string | undefined} tabId the tab whose menu this is
700
+ */
701
+ function decorateTabMenu(ctx, menu, tabId) {
702
+ if (menu === undefined || menu === null || tabId === undefined) return
703
+ if (typeof menu.querySelector !== 'function') return
704
+ if (menu.querySelector(`[${TAB_MENU_MARK}]`) !== null) return
705
+
706
+ const doc = menu.ownerDocument ?? document
707
+ const plan = closingPlan(tabStripIds(doc, tabId), tabId)
708
+ const group = doc.createElement('div')
709
+ group.className = 'dsh-live-browser-menu-group'
710
+ group.setAttribute(TAB_MENU_MARK, '')
711
+
712
+ const entries = [
713
+ ['关闭其他标签页', plan.others],
714
+ ['关闭全部标签页', plan.all],
715
+ ['关闭右侧所有标签页', plan.right],
716
+ ]
717
+ for (const [label, targets] of entries) {
718
+ const button = doc.createElement('button')
719
+ button.type = 'button'
720
+ button.setAttribute('role', 'menuitem')
721
+ button.className = 'dsh-live-browser-menu-item'
722
+ button.textContent = label
723
+ button.disabled = targets.length === 0
724
+ button.addEventListener('click', (event) => {
725
+ event.stopPropagation()
726
+ if (targets.length === 0) return
727
+ closeTabs(ctx, targets, () => dismissTabMenu(menu))
728
+ })
729
+ group.appendChild(button)
730
+ }
731
+ menu.appendChild(group)
732
+ }
733
+
734
+ /** Close the menu the way an outside click does; its React state follows. */
735
+ function dismissTabMenu(menu) {
736
+ const doc = menu.ownerDocument ?? document
737
+ const view = doc.defaultView
738
+ try {
739
+ const Pointer = view?.PointerEvent ?? PointerEvent
740
+ doc.dispatchEvent(new Pointer('pointerdown', { bubbles: true }))
741
+ } catch {
742
+ try {
743
+ const Keyboard = view?.KeyboardEvent ?? KeyboardEvent
744
+ doc.dispatchEvent(new Keyboard('keydown', { key: 'Escape', bubbles: true }))
745
+ } catch {
746
+ /* no DOM events: the menu will close on the next outside click */
747
+ }
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Watch for tab context menus and decorate them.
753
+ *
754
+ * The menu is created in the same event as the right-click, so the tab id has
755
+ * to be captured there; a zero-delay timer is enough time for React to commit
756
+ * the portal.
757
+ */
758
+ function installTabMenu(ctx) {
759
+ if (typeof document === 'undefined' || document === null) return undefined
760
+ // Tests and non-DOM builds hand us a document without a head; the browser
761
+ // tools must keep working there.
762
+ if (typeof document.addEventListener !== 'function' || document.head === undefined || document.head === null) {
763
+ return undefined
764
+ }
765
+
766
+ let style
767
+ try {
768
+ style = document.createElement('style')
769
+ style.dataset.plugin = 'dsh-live-browser:tab-menu'
770
+ style.textContent = TAB_MENU_STYLE
771
+ document.head.appendChild(style)
772
+ } catch {
773
+ style = undefined
774
+ }
775
+
776
+ let pending
777
+ const onContextMenu = (event) => {
778
+ const target = event.target instanceof Element ? event.target.closest('[data-dockkit-tab]') : null
779
+ const tabId = target?.dataset?.dockkitTab
780
+ if (tabId === undefined) return
781
+ pending = tabId
782
+ setTimeout(() => {
783
+ if (pending !== tabId) return
784
+ pending = undefined
785
+ decorateTabMenu(ctx, document.querySelector('[data-dockkit-tab-menu]'), tabId)
786
+ }, 0)
787
+ }
788
+ document.addEventListener('contextmenu', onContextMenu, true)
789
+
790
+ return () => {
791
+ document.removeEventListener('contextmenu', onContextMenu, true)
792
+ style?.remove()
793
+ }
794
+ }
795
+
796
+ /** Cordis plugin entry for the client half. */
797
+ function apply(ctx) {
798
+ const bridge = createRuntime()
799
+
800
+ ctx.effect(() => installTabMenu(ctx), 'dsh-live-browser: tab menu')
801
+
802
+ // The loop is page-scoped, not component-scoped: the sidebar may unmount and
803
+ // remount tabs without the page ever reloading.
804
+ ctx.effect(() => {
805
+ const driver = createDriver({
806
+ bridge,
807
+ loadSource: () => bridge.source(),
808
+ // Auto-open: a tool call from a cold start must bring the browser up by
809
+ // itself, inside the requesting conversation.
810
+ openPanel: (url, sessionId) => openSidebarBrowser(ctx, sessionId, url),
811
+ currentSessionId: () => currentSessionId(ctx),
812
+ })
813
+ void driver.start()
814
+ return () => driver.stop()
815
+ }, 'dsh-live-browser: agent loop')
816
+ }
817
+
818
+ if (typeof window !== 'undefined' && typeof window.__ModuleLoader__ === 'object') {
819
+ window.__ModuleLoader__.load({
820
+ id: 'dsh-live-browser',
821
+ factory: () => ({ apply }),
822
+ })
823
+ } else if (typeof module !== 'undefined' && module.exports) {
824
+ module.exports = {
825
+ normalizeAddress,
826
+ normalizeValue,
827
+ GUEST_SELECTOR,
828
+ GUEST_METHODS,
829
+ tabStripIds,
830
+ closingPlan,
831
+ closeTabs,
832
+ decorateTabMenu,
833
+ }
834
+ }
835
+ })()