dsh-live-browser 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -26,7 +26,29 @@
26
26
  'use strict'
27
27
 
28
28
  /** Live guests are found by this attribute, owned by the official plugin. */
29
- const GUEST_SELECTOR = 'webview[data-sidebar-browser-frame="webview"]'
29
+ const GUEST_SELECTOR =
30
+ 'webview[data-sidebar-browser-frame="webview"], iframe[data-sidebar-browser-frame="iframe"]'
31
+
32
+ /**
33
+ * What to tell a user whose sidebar rendered the Web build's iframe.
34
+ *
35
+ * An iframe is cross-origin to the DSH page, so the parent cannot read its
36
+ * document or inject the agent script. That is a browser security boundary,
37
+ * not a missing feature in this plugin; the only full-capability path is the
38
+ * Desktop build, whose browser tab is an Electron <webview>.
39
+ */
40
+ const IFRAME_HINT =
41
+ '当前右侧栏打开的是 Web 版 iframe 浏览器,页面与 DSH 跨域,浏览器同源策略禁止插件注入脚本,点击/输入/读取/执行脚本都无法工作。' +
42
+ '请改用 DSH 桌面版的 webview 浏览器:1) 关闭浏览器里的 DSH 页面(地址形如 http://127.0.0.1:54249);' +
43
+ '2) 启动 DeepSeek Harness 桌面客户端(Windows 开始菜单里的 DeepSeek Harness,或 D:\\软件\\deepseekHARNESS\\DeepSeek Harness.exe);' +
44
+ '3) 在桌面客户端打开同一个会话,点右侧栏「浏览器」标签页。桌面版浏览器是 Electron <webview>,browser_* 工具即可正常操作。'
45
+
46
+ const guestKind = (element) =>
47
+ typeof element?.tagName === 'string' && element.tagName.toLowerCase() === 'iframe'
48
+ ? 'iframe'
49
+ : 'webview'
50
+
51
+ const isIframeGuest = (element) => guestKind(element) === 'iframe'
30
52
 
31
53
  /** One per conversation: DSH keeps a sidebar mounted for each chat it visited. */
32
54
  const SESSION_SELECTOR = '[data-sidebar-right-session]'
@@ -107,19 +129,67 @@
107
129
  *
108
130
  * @param {{ bridge: ReturnType<typeof createRuntime>, loadSource: () => Promise<string>, openPanel?: (url?: string, sessionId?: string) => Promise<object>, documentRef?: Document, currentSessionId?: () => string | undefined }} options
109
131
  */
110
- function createDriver({ bridge, loadSource, openPanel, documentRef, currentSessionId }) {
111
- const panelId = `panel-${Math.random().toString(36).slice(2, 10)}`
132
+ function createDriver({ bridge, loadSource, openPanel, documentRef, currentSessionId, listSessions }) {
133
+ /** Stable prefix; each conversation gets its own panel id below. */
134
+ const basePanelId = `panel-${Math.random().toString(36).slice(2, 10)}`
112
135
  /** @type {Map<Element, {ready: boolean, installed: boolean, url: string, failure?: string}>} */
113
136
  const known = new Map()
137
+ /**
138
+ * One long-poll per conversation.
139
+ *
140
+ * A single poller used to follow whatever chat was on screen, so switching
141
+ * chats silently re-bound the only panel and commands queued for the other
142
+ * chat were rejected. DSH keeps each chat's sidebar mounted; giving every
143
+ * mounted sidebar its own panel keeps background conversations working.
144
+ *
145
+ * @type {Map<string, {panelId: string, timer: any, lastSeen: number, stopped: boolean}>}
146
+ */
147
+ const workers = new Map()
114
148
  let source = ''
115
149
  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
150
+ let discoveryTimer
120
151
 
121
152
  const scope = () => documentRef ?? (typeof document === 'undefined' ? undefined : document)
122
153
 
154
+ /** One small, dismissible notice for the Web build's iframe browser. */
155
+ let iframeNotice
156
+ let iframeNoticeDismissed = false
157
+ const hideIframeNotice = () => {
158
+ if (iframeNotice !== undefined && iframeNotice.isConnected) iframeNotice.remove()
159
+ iframeNotice = undefined
160
+ }
161
+ const dismissIframeNotice = () => {
162
+ iframeNoticeDismissed = true
163
+ hideIframeNotice()
164
+ }
165
+ const showIframeNotice = () => {
166
+ if (iframeNoticeDismissed) return
167
+ if (iframeNotice !== undefined && iframeNotice.isConnected) return
168
+ const doc = scope()
169
+ if (doc === undefined || doc.body === undefined || doc.body === null) return
170
+ const notice = doc.createElement('div')
171
+ notice.dataset.dshLiveBrowserIframeNotice = ''
172
+ notice.setAttribute('role', 'status')
173
+ notice.style.cssText =
174
+ 'position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:440px;' +
175
+ 'padding:12px 14px;border:1px solid #ffd591;border-radius:8px;background:#fff7e6;' +
176
+ 'color:#613400;box-shadow:0 6px 20px rgba(0,0,0,.16);font:13px/1.55 system-ui,sans-serif;'
177
+ const text = doc.createElement('div')
178
+ text.textContent =
179
+ '当前右侧栏是 iframe 浏览器,插件无法执行点击/输入/读取。请改用 DSH 桌面版 webview:' +
180
+ '关闭浏览器里的 DSH 页面 → 启动 DeepSeek Harness 桌面客户端 → 打开同一会话 → 点右侧栏「浏览器」。'
181
+ const close = doc.createElement('button')
182
+ close.type = 'button'
183
+ close.textContent = '知道了'
184
+ close.style.cssText =
185
+ 'margin-top:8px;padding:3px 10px;border:1px solid #ffd591;border-radius:5px;' +
186
+ 'background:#fff;color:#613400;font:12px system-ui,sans-serif;cursor:pointer;'
187
+ close.addEventListener('click', dismissIframeNotice)
188
+ notice.append(text, close)
189
+ doc.body.appendChild(notice)
190
+ iframeNotice = notice
191
+ }
192
+
123
193
  /**
124
194
  * The sidebar the human is looking at.
125
195
  *
@@ -137,10 +207,17 @@
137
207
  * on. Without a mounted sidebar the shell's current conversation is the only
138
208
  * source left, and it is exactly where a cold start has to open the tab.
139
209
  */
140
- const sessionId = () => {
210
+ const currentSession = () => {
211
+ // The shell's own selection is authoritative for the visible tab. It is
212
+ // only used to decide which sidebar is on screen (and to make sure the
213
+ // visible chat has a worker before its first browser tab exists); every
214
+ // worker below polls for a fixed session id, so background chats keep
215
+ // working while the user is looking elsewhere.
216
+ const current = currentSessionId?.()
217
+ if (current !== undefined) return current
141
218
  const shown = view()
142
219
  if (shown !== undefined) return viewSessionId(shown)
143
- return currentSessionId?.()
220
+ return undefined
144
221
  }
145
222
 
146
223
  /**
@@ -149,14 +226,33 @@
149
226
  * A command names its conversation; the guest has to come from that same
150
227
  * chat's sidebar even when the user has switched to another one meanwhile.
151
228
  */
229
+ const viewsFor = (target) => {
230
+ const all = sessionViews(scope())
231
+ if (target === undefined) {
232
+ const shown = view()
233
+ return shown === undefined ? [] : [shown]
234
+ }
235
+ return all.filter((candidate) => viewSessionId(candidate) === target)
236
+ }
237
+
152
238
  const viewFor = (target) => {
153
- if (target === undefined) return view()
154
- return sessionViews(scope()).find((candidate) => viewSessionId(candidate) === target)
239
+ const candidates = viewsFor(target)
240
+ // The same conversation can expose more than one
241
+ // [data-sidebar-right-session] node (the session wrapper and the panel
242
+ // itself, which may be portaled). Prefer the one that actually holds the
243
+ // guest, otherwise a wrapper without a browser would shadow the panel.
244
+ return (
245
+ candidates.find((candidate) => candidate.querySelector(GUEST_SELECTOR) !== null) ??
246
+ candidates[0]
247
+ )
155
248
  }
156
249
 
157
250
  const findGuests = (target) => {
158
- const shown = viewFor(target)
159
- return shown === undefined ? [] : [...shown.querySelectorAll(GUEST_SELECTOR)]
251
+ const guests = []
252
+ for (const candidate of viewsFor(target)) {
253
+ guests.push(...candidate.querySelectorAll(GUEST_SELECTOR))
254
+ }
255
+ return [...new Set(guests)]
160
256
  }
161
257
 
162
258
  /**
@@ -184,8 +280,13 @@
184
280
  * that is not on screen.
185
281
  */
186
282
  const targetGuest = (target) => {
187
- const guests = findGuests(target)
188
- if (guests.length === 0) return undefined
283
+ const all = findGuests(target)
284
+ if (all.length === 0) return undefined
285
+ // A desktop build may have both kinds mounted while switching profiles.
286
+ // Prefer a webview: it is the one this plugin can actually drive.
287
+ const guests = all.some((element) => !isIframeGuest(element))
288
+ ? all.filter((element) => !isIframeGuest(element))
289
+ : all
189
290
  const doc = scope()
190
291
  const focused = guests.find((element) => element === doc?.activeElement)
191
292
  if (focused !== undefined) return focused
@@ -207,7 +308,11 @@
207
308
  for (const candidate of findGuests(target)) {
208
309
  if (!known.has(candidate)) track(candidate)
209
310
  }
210
- const ready = findGuests(target).find((candidate) => known.get(candidate)?.ready === true)
311
+ const candidates = findGuests(target)
312
+ const ready =
313
+ candidates.find(
314
+ (candidate) => !isIframeGuest(candidate) && known.get(candidate)?.ready === true,
315
+ ) ?? candidates.find((candidate) => known.get(candidate)?.ready === true)
211
316
  if (ready !== undefined) return ready
212
317
  await new Promise((resolve) => setTimeout(resolve, 100))
213
318
  }
@@ -218,6 +323,24 @@
218
323
  const track = (element) => {
219
324
  const state = { ready: false, installed: false, url: '', failure: undefined }
220
325
  known.set(element, state)
326
+ if (isIframeGuest(element)) {
327
+ // An iframe has none of the <webview> methods. Its href is readable from
328
+ // the parent even when its document is not; that is enough to report
329
+ // "this tab is an iframe" and hand the caller the Desktop hint.
330
+ const refreshFrame = () => {
331
+ try {
332
+ state.url = typeof element.src === 'string' ? element.src : ''
333
+ } catch {
334
+ state.url = ''
335
+ }
336
+ state.ready = true
337
+ state.installed = false
338
+ state.failure = undefined
339
+ }
340
+ element.addEventListener('load', refreshFrame)
341
+ refreshFrame()
342
+ return
343
+ }
221
344
  const refresh = () => {
222
345
  try {
223
346
  state.url = element.getURL()
@@ -258,6 +381,10 @@
258
381
  * re-evaluating the whole script on every SPA route change.
259
382
  */
260
383
  const ensureInstalled = async (element) => {
384
+ // There is no cross-origin scripting surface for an iframe. Returning
385
+ // false lets `state`/`tabs` answer with the iframe description instead of
386
+ // pretending the page is merely loading.
387
+ if (isIframeGuest(element)) return false
261
388
  const state = known.get(element)
262
389
  if (state === undefined) return false
263
390
  if (state.installed) return true
@@ -277,6 +404,28 @@
277
404
  }
278
405
 
279
406
  const describeGuest = (element) => {
407
+ if (isIframeGuest(element)) {
408
+ let sameOrigin = false
409
+ try {
410
+ sameOrigin = element.contentDocument !== null
411
+ } catch {
412
+ sameOrigin = false
413
+ }
414
+ return {
415
+ kind: 'iframe',
416
+ mode: 'iframe',
417
+ actionRequired: 'switch-to-desktop-webview',
418
+ id: typeof element.src === 'string' ? element.src : '',
419
+ url: typeof element.src === 'string' ? element.src : '',
420
+ title: element.getAttribute('title') ?? '',
421
+ canGoBack: false,
422
+ canGoForward: false,
423
+ loading: false,
424
+ sameOrigin,
425
+ automation: false,
426
+ hint: IFRAME_HINT,
427
+ }
428
+ }
280
429
  const state = known.get(element)
281
430
  let url = state?.url ?? ''
282
431
  let title = ''
@@ -292,7 +441,7 @@
292
441
  } catch {
293
442
  /* the element is between documents */
294
443
  }
295
- return { id: url, url, title, canGoBack, canGoForward, loading }
444
+ return { kind: 'webview', id: url, url, title, canGoBack, canGoForward, loading }
296
445
  }
297
446
 
298
447
  /** Perform one command on the chosen guest. */
@@ -305,6 +454,16 @@
305
454
  return bridge.tabCommand(method, command.params ?? {})
306
455
  }
307
456
 
457
+ // A Web-build iframe has no scriptable guest. It can still answer what it
458
+ // is (so browser_status can explain the situation), but every command that
459
+ // needs the page must stop with the Desktop/webview hint instead of
460
+ // reporting a generic "still loading".
461
+ if (isIframeGuest(element)) {
462
+ if (method === 'state') return describeGuest(element)
463
+ if (method === 'tabs') return { tabs: findGuests(target).map(describeGuest) }
464
+ throw new Error(IFRAME_HINT)
465
+ }
466
+
308
467
  // Browser-level commands drive the element, like the toolbar does.
309
468
  if (method in GUEST_METHODS) {
310
469
  element[GUEST_METHODS[method]]()
@@ -341,115 +500,237 @@
341
500
  return element.executeJavaScript(expression, true)
342
501
  }
343
502
 
344
- const tick = async () => {
345
- if (stopped || busy) return
346
- busy = true
503
+ /** Refresh the iframe notice for the sidebar currently on screen. */
504
+ const updateIframeNotice = (shownSession) => {
505
+ const guests = shownSession === undefined ? [] : findGuests(shownSession)
506
+ const hasWebview = guests.some((element) => !isIframeGuest(element))
507
+ const iframe = hasWebview ? undefined : guests.find((element) => isIframeGuest(element))
508
+ if (iframe === undefined) {
509
+ iframeNoticeDismissed = false
510
+ hideIframeNotice()
511
+ } else {
512
+ showIframeNotice()
513
+ }
514
+ }
515
+
516
+ const stopWorker = (sessionId) => {
517
+ const worker = workers.get(sessionId)
518
+ if (worker === undefined) return
519
+ worker.stopped = true
520
+ if (worker.timer !== undefined) clearTimeout(worker.timer)
521
+ workers.delete(sessionId)
522
+ }
523
+
524
+ /**
525
+ * One request loop for one conversation.
526
+ *
527
+ * The panel is bound to `sessionId` for its whole life. It never reads the
528
+ * chat currently on screen, so switching chats cannot steal or reject a
529
+ * command that belongs to this conversation.
530
+ */
531
+ const tickFor = async (sessionId, worker) => {
532
+ for (const element of findGuests(sessionId)) {
533
+ if (!known.has(element)) track(element)
534
+ }
535
+ for (const element of [...known.keys()]) {
536
+ if (!element.isConnected) known.delete(element)
537
+ }
538
+
539
+ let command
347
540
  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
- }
541
+ command = await bridge.poll(worker.panelId, sessionId)
542
+ } catch {
543
+ // A failed long poll (host restarting, page hidden) backs off once.
544
+ return true
545
+ }
546
+ if (command === null || command === undefined) return false
359
547
 
360
- const command = await bridge.poll(panelId, sessionId())
361
- if (command === null || command === undefined) return
548
+ if (command.sessionId !== undefined && command.sessionId !== sessionId) {
549
+ await bridge.settle(worker.panelId, command.id, {
550
+ ok: false,
551
+ error: `这条命令属于对话 ${command.sessionId},当前面板绑定的是 ${sessionId};已拒绝,避免操作到错误页面。`,
552
+ })
553
+ return false
554
+ }
555
+ const target = sessionId
362
556
 
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, {
557
+ // Explicit opener: create the browser tab in this conversation, even
558
+ // while a different conversation is on screen.
559
+ if (command.method === 'openPanel') {
560
+ if (!(await canAutoOpen())) {
561
+ await bridge.settle(worker.panelId, command.id, {
370
562
  ok: false,
371
- error: `这条命令属于另一个对话(${command.sessionId}),当前显示的是 ${shown};为避免操作到看不见的页面,已拒绝。请切回发起命令的对话后重试。`,
563
+ error:
564
+ '当前对话的右侧栏还没有网页,且插件设置里关闭了「自动打开面板」;请先手动打开「浏览器」标签页后重试',
372
565
  })
373
- return
566
+ return false
374
567
  }
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
- }
568
+ if (openPanel === undefined) {
569
+ await bridge.settle(worker.panelId, command.id, {
570
+ ok: false,
571
+ error: '右侧栏还没有打开任何网页,并且当前环境无法自动打开浏览器',
572
+ })
573
+ return false
574
+ }
575
+ try {
576
+ await openPanel(command.params?.url, target)
577
+ } catch (failure) {
578
+ await bridge.settle(worker.panelId, command.id, {
579
+ ok: false,
580
+ error: `无法自动打开侧边栏浏览器:${String(failure?.message ?? failure)}`,
581
+ })
582
+ return false
422
583
  }
584
+ const opened = await waitForGuest(8000, target)
585
+ await bridge.settle(worker.panelId, command.id, {
586
+ ok: true,
587
+ value: { opened: true, hasPage: opened !== undefined },
588
+ })
589
+ return false
590
+ }
423
591
 
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, {
592
+ let element = targetGuest(target)
593
+ if (element === undefined) {
594
+ // `state`/`tabs` are status probes; they must not create tabs.
595
+ if (command.method === 'state') {
596
+ await bridge.settle(worker.panelId, command.id, { ok: true, value: null })
597
+ return false
598
+ }
599
+ if (command.method === 'tabs') {
600
+ await bridge.settle(worker.panelId, command.id, { ok: true, value: { tabs: [] } })
601
+ return false
602
+ }
603
+ if (!(await canAutoOpen())) {
604
+ await bridge.settle(worker.panelId, command.id, {
428
605
  ok: false,
429
- error: `页面加载失败:${state.failure}`,
606
+ error:
607
+ '当前对话的右侧栏还没有网页,且插件设置里关闭了「自动打开面板」;请先手动打开「浏览器」标签页后重试',
430
608
  })
431
- return
609
+ return false
432
610
  }
433
- if (state === undefined || !state.ready) {
434
- await bridge.settle(panelId, command.id, { ok: false, error: '页面还在加载,请稍后重试' })
435
- return
611
+ if (openPanel === undefined) {
612
+ await bridge.settle(worker.panelId, command.id, {
613
+ ok: false,
614
+ error: '右侧栏还没有打开任何网页,并且当前环境无法自动打开浏览器',
615
+ })
616
+ return false
436
617
  }
437
-
438
618
  try {
439
- const value = await dispatch(element, command, target)
440
- await bridge.settle(panelId, command.id, { ok: true, value: normalizeValue(value) })
619
+ await openPanel(command.method === 'navigate' ? command.params?.url : undefined, target)
441
620
  } catch (failure) {
442
- await bridge.settle(panelId, command.id, {
621
+ await bridge.settle(worker.panelId, command.id, {
622
+ ok: false,
623
+ error: `无法自动打开侧边栏浏览器:${String(failure?.message ?? failure)}`,
624
+ })
625
+ return false
626
+ }
627
+ element = await waitForGuest(8000, target)
628
+ if (element === undefined) {
629
+ await bridge.settle(worker.panelId, command.id, {
443
630
  ok: false,
444
- error: String(failure?.message ?? failure),
631
+ error: '侧边栏浏览器已打开,但页面还没准备好,请稍后重试',
445
632
  })
633
+ return false
634
+ }
635
+ }
636
+
637
+ const state = known.get(element)
638
+ if (state !== undefined && state.failure !== undefined) {
639
+ // Say what actually went wrong instead of "still loading".
640
+ await bridge.settle(worker.panelId, command.id, {
641
+ ok: false,
642
+ error: `页面加载失败:${state.failure}`,
643
+ })
644
+ return false
645
+ }
646
+ if (state === undefined || !state.ready) {
647
+ await bridge.settle(worker.panelId, command.id, {
648
+ ok: false,
649
+ error: '页面还在加载,请稍后重试',
650
+ })
651
+ return false
652
+ }
653
+
654
+ try {
655
+ const value = await dispatch(element, command, target)
656
+ await bridge.settle(worker.panelId, command.id, {
657
+ ok: true,
658
+ value: normalizeValue(value),
659
+ })
660
+ } catch (failure) {
661
+ await bridge.settle(worker.panelId, command.id, {
662
+ ok: false,
663
+ error: String(failure?.message ?? failure),
664
+ })
665
+ }
666
+ return false
667
+ }
668
+
669
+ const startWorker = (sessionId) => {
670
+ const worker = {
671
+ panelId: `${basePanelId}-${sessionId}`,
672
+ timer: undefined,
673
+ lastSeen: Date.now(),
674
+ stopped: false,
675
+ }
676
+ const loop = async () => {
677
+ if (stopped || worker.stopped) return
678
+ let retry = false
679
+ try {
680
+ retry = await tickFor(sessionId, worker)
681
+ } catch {
682
+ retry = true
683
+ }
684
+ if (stopped || worker.stopped) return
685
+ worker.timer = setTimeout(() => {
686
+ void loop()
687
+ }, retry ? POLL_RETRY_MS : 0)
688
+ }
689
+ void loop()
690
+ return worker
691
+ }
692
+
693
+ /**
694
+ * Keep one worker per mounted conversation.
695
+ *
696
+ * A conversation whose sidebar is hidden is still mounted by DSH; its
697
+ * worker keeps polling, which is what lets a command finish there while the
698
+ * user is reading another chat.
699
+ */
700
+ const syncWorkers = () => {
701
+ if (stopped) return
702
+ const visible = currentSession()
703
+ const ids = new Set()
704
+ if (visible !== undefined) ids.add(visible)
705
+ for (const view of sessionViews(scope())) {
706
+ const id = viewSessionId(view)
707
+ if (id !== undefined) ids.add(id)
708
+ }
709
+ // A conversation with no browser tab has no sidebar to discover. Include
710
+ // every live session so a cold-start navigate can create its tab even
711
+ // while another conversation is on screen.
712
+ try {
713
+ for (const session of listSessions?.() ?? []) {
714
+ const id = typeof session === 'string' ? session : session?.id
715
+ if (typeof id === 'string' && id !== '') ids.add(id)
446
716
  }
447
717
  } catch {
448
- // Back off once so a persistently broken endpoint cannot spin.
449
- tickFailed = true
450
- } finally {
451
- busy = false
718
+ // Optional service; the DOM/current session still cover the common case.
719
+ }
720
+ for (const id of ids) {
721
+ let worker = workers.get(id)
722
+ if (worker === undefined) {
723
+ worker = startWorker(id)
724
+ workers.set(id, worker)
725
+ }
726
+ worker.lastSeen = Date.now()
727
+ }
728
+ // A closed chat leaves the DOM immediately but its worker may still hold
729
+ // a queued command for a few seconds; keep it through that grace period.
730
+ for (const [id, worker] of [...workers]) {
731
+ if (!ids.has(id) && Date.now() - worker.lastSeen > 30000) stopWorker(id)
452
732
  }
733
+ updateIframeNotice(visible)
453
734
  }
454
735
 
455
736
  return {
@@ -465,25 +746,16 @@
465
746
  }
466
747
  }
467
748
  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()
749
+ syncWorkers()
750
+ discoveryTimer = setInterval(syncWorkers, 500)
482
751
  },
483
752
  stop() {
484
753
  stopped = true
485
- clearTimeout(timer)
754
+ if (discoveryTimer !== undefined) clearInterval(discoveryTimer)
755
+ for (const id of [...workers.keys()]) stopWorker(id)
486
756
  known.clear()
757
+ iframeNoticeDismissed = false
758
+ hideIframeNotice()
487
759
  },
488
760
  }
489
761
  }
@@ -537,10 +809,20 @@
537
809
  * @returns {Element | undefined}
538
810
  */
539
811
  function foregroundSessionView(documentRef) {
540
- return sessionViews(documentRef).find((view) => {
812
+ const views = sessionViews(documentRef).filter((view) => {
541
813
  if (typeof view.hasAttribute === 'function' && view.hasAttribute('hidden')) return false
542
- return typeof view.closest !== 'function' || view.closest('[hidden]') === null
814
+ if (typeof view.closest === 'function' && view.closest('[hidden]') !== null) return false
815
+ // Background sessions may be hidden with CSS rather than the `hidden`
816
+ // attribute, and a portaled panel can keep a non-zero box there. A view
817
+ // that is not laid out is not the one the human is looking at.
818
+ return isLaidOut(view)
543
819
  })
820
+ const active = documentRef?.activeElement
821
+ const focused =
822
+ active === undefined || active === null
823
+ ? undefined
824
+ : views.find((view) => typeof view.contains === 'function' && view.contains(active))
825
+ return focused ?? views[0]
544
826
  }
545
827
 
546
828
  /**
@@ -809,6 +1091,13 @@
809
1091
  // itself, inside the requesting conversation.
810
1092
  openPanel: (url, sessionId) => openSidebarBrowser(ctx, sessionId, url),
811
1093
  currentSessionId: () => currentSessionId(ctx),
1094
+ listSessions: () => {
1095
+ try {
1096
+ return ctx.get?.('sessions')?.list?.() ?? []
1097
+ } catch {
1098
+ return []
1099
+ }
1100
+ },
812
1101
  })
813
1102
  void driver.start()
814
1103
  return () => driver.stop()