dsh-builtin-browser 0.4.2 → 0.4.6

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +1753 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +433 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +425 -163
  7. package/docs/architecture.md +4 -4
  8. package/docs/tool-reference.md +3 -3
  9. package/docs/user-guide.md +50 -2
  10. package/lib/browser/types.d.ts +9 -2
  11. package/lib/browser-electron/bridge-connection.d.ts +18 -21
  12. package/lib/browser-electron/bridge-connection.js +42 -1
  13. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  14. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  15. package/lib/browser-electron/entry.d.ts +20 -0
  16. package/lib/browser-electron/entry.js +110 -7
  17. package/lib/browser-electron/history-store.d.ts +11 -2
  18. package/lib/browser-electron/history-store.js +27 -5
  19. package/lib/browser-electron/host-main.js +11 -1
  20. package/lib/browser-electron/provider.d.ts +62 -2
  21. package/lib/browser-electron/provider.js +28 -12
  22. package/lib/browser-electron/remote-host.js +19 -6
  23. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  24. package/lib/browser-electron/rpc-ops.js +36 -0
  25. package/lib/browser-electron/settings-route.d.ts +6 -6
  26. package/lib/browser-electron/settings-route.js +48 -8
  27. package/lib/browser-electron/settings-store.js +57 -16
  28. package/lib/browser-electron/system-browser.d.ts +31 -9
  29. package/lib/browser-electron/system-browser.js +116 -27
  30. package/lib/index.js +0 -1
  31. package/lib/tool-browser/element-fields.d.ts +145 -0
  32. package/lib/tool-browser/element-fields.js +152 -0
  33. package/lib/tool-browser/index.js +26 -60
  34. package/package.json +6 -2
  35. package/src/browser/types.ts +9 -2
  36. package/src/browser-electron/bridge-connection.ts +37 -1
  37. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  38. package/src/browser-electron/entry.ts +113 -8
  39. package/src/browser-electron/history-store.ts +32 -7
  40. package/src/browser-electron/host-main.ts +10 -1
  41. package/src/browser-electron/provider.ts +91 -18
  42. package/src/browser-electron/remote-host.ts +19 -6
  43. package/src/browser-electron/rpc-ops.ts +41 -0
  44. package/src/browser-electron/settings-route.ts +54 -13
  45. package/src/browser-electron/settings-store.ts +56 -17
  46. package/src/browser-electron/system-browser.ts +121 -28
  47. package/src/index.ts +3 -2
  48. package/src/tool-browser/element-fields.ts +167 -0
  49. package/src/tool-browser/index.ts +31 -60
package/client.js CHANGED
@@ -19,7 +19,7 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
19
19
 
20
20
  const react = require("react")
21
21
  const h = react.createElement
22
- const { useCallback, useEffect, useState } = react
22
+ const { useCallback, useEffect, useRef, useState } = react
23
23
 
24
24
  const name = "dsh-builtin-browser"
25
25
  const inject = ["slots", "locale"]
@@ -58,7 +58,8 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
58
58
  "credentials.allowRead": "允许 Agent 读取 cookies / 导出登录状态",
59
59
  "browser.title": "浏览器载体",
60
60
  "browser.channel": "使用哪个浏览器",
61
- "browser.bundled": "内置 Electron(随插件提供,无需安装)",
61
+ "browser.sidebar": "侧栏浏览器(在 DSH 窗口右侧打开,不额外拉窗口)",
62
+ "browser.bundled": "内置 Electron(随插件提供,无需安装)",
62
63
  "browser.chrome": "本机 Chrome",
63
64
  "browser.edge": "本机 Edge",
64
65
  "browser.auto": "自动(优先 Chrome,其次 Edge)",
@@ -105,7 +106,8 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
105
106
  "credentials.allowRead": "Allow the agent to read cookies / export login state",
106
107
  "browser.title": "Browser",
107
108
  "browser.channel": "Which browser to use",
108
- "browser.bundled": "Bundled Electron (ships with the plugin, nothing to install)",
109
+ "browser.sidebar": "Sidebar browser (opens in the DSH window right pane; no extra window)",
110
+ "browser.bundled": "Bundled Electron (ships with the plugin, nothing to install)",
109
111
  "browser.chrome": "Installed Chrome",
110
112
  "browser.edge": "Installed Edge",
111
113
  "browser.auto": "Automatic (Chrome first, then Edge)",
@@ -137,26 +139,219 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
137
139
  return data
138
140
  }
139
141
 
142
+ /**
143
+ * The panel's own stylesheet.
144
+ *
145
+ * Inline styles cannot express `:hover`, and the rows need one — an interactive row with no
146
+ * feedback reads as decoration. The colours come from DSH's own design tokens
147
+ * (`--dsw-alias-*`, the same ones the host's settings rows use) so this follows the active
148
+ * theme instead of guessing at it; each has a literal fallback because a token may be absent
149
+ * under a third-party theme, and an unstyled row is worse than a slightly-off one.
150
+ */
151
+ const CSS = `
152
+ .dsh-bb-row {
153
+ border-radius: 8px;
154
+ padding: 7px 9px;
155
+ color: var(--dsw-alias-label-primary, inherit);
156
+ transition: background-color .12s ease;
157
+ }
158
+ /* No resting background: a permanent grey block behind every row reads as a set of buttons
159
+ rather than a list of settings. The highlight belongs to the pointer, not to the row. */
160
+ .dsh-bb-row:hover {
161
+ background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.15));
162
+ }
163
+ .dsh-bb-row:active {
164
+ background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.22));
165
+ }
166
+ .dsh-bb-row:focus-within {
167
+ border-color: var(--dsw-alias-border-l2, rgba(128,128,128,.4));
168
+ }
169
+ /* The dropdown is drawn here rather than by the OS.
170
+ A native <select> paints its popup with system colours on Windows: the highlight overrides
171
+ whatever the option sets (white text on a white bar), and the list carries a border no CSS
172
+ can remove. Only the collapsed control is styleable, so a panel that matches its theme
173
+ everywhere else ends up with one control that does not. Everything below is ours: the
174
+ surface, the highlight, and the absence of that line. */
175
+ .dsh-bb-dd {
176
+ position: relative;
177
+ display: inline-block;
178
+ margin-top: 4px;
179
+ }
180
+ .dsh-bb-dd-btn {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 8px;
184
+ max-width: 100%;
185
+ padding: 4px 8px 4px 9px;
186
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4));
187
+ border-radius: 6px;
188
+ background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12));
189
+ color: var(--dsw-alias-label-primary, inherit);
190
+ font: inherit;
191
+ text-align: left;
192
+ cursor: pointer;
193
+ }
194
+ .dsh-bb-dd-btn:hover:not(:disabled) {
195
+ background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18));
196
+ }
197
+ .dsh-bb-dd-btn:disabled { opacity: .55; cursor: default; }
198
+ .dsh-bb-dd-caret { opacity: .6; font-size: 10px; line-height: 1; }
199
+ .dsh-bb-dd-list {
200
+ position: absolute;
201
+ z-index: 20;
202
+ top: calc(100% + 4px);
203
+ left: 0;
204
+ min-width: 100%;
205
+ max-width: 340px;
206
+ padding: 4px;
207
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4));
208
+ border-radius: 8px;
209
+ background: var(--dsw-alias-bg-layer-2, #2f2f2f);
210
+ box-shadow: 0 6px 20px rgba(0,0,0,.28);
211
+ }
212
+ .dsh-bb-dd-opt {
213
+ display: block;
214
+ width: 100%;
215
+ padding: 6px 9px;
216
+ border: none;
217
+ border-radius: 6px;
218
+ background: transparent;
219
+ color: var(--dsw-alias-label-primary, inherit);
220
+ font: inherit;
221
+ text-align: left;
222
+ cursor: pointer;
223
+ }
224
+ /* The same ellipse the rows use, so the panel reads as one surface — and it is ours to draw,
225
+ which is the whole reason this control exists. */
226
+ .dsh-bb-dd-opt:hover,
227
+ .dsh-bb-dd-opt[data-active="true"] {
228
+ background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18));
229
+ }
230
+ .dsh-bb-dd-opt[aria-selected="true"] { font-weight: 600; }
231
+ `
232
+
233
+ /** Install the stylesheet once, however many times the panel is mounted. */
234
+ function installStyles() {
235
+ if (typeof document === "undefined") return
236
+ if (document.getElementById("dsh-builtin-browser-styles") !== null) return
237
+ const tag = document.createElement("style")
238
+ tag.id = "dsh-builtin-browser-styles"
239
+ tag.textContent = CSS
240
+ document.head.appendChild(tag)
241
+ }
242
+
140
243
  const styles = {
141
244
  wrap: { display: "flex", flexDirection: "column", gap: "14px", padding: "4px 2px", fontSize: "13px" },
142
245
  lead: { opacity: 0.75, lineHeight: 1.6, margin: 0 },
143
246
  group: { border: "1px solid rgba(128,128,128,.28)", borderRadius: "8px", padding: "10px 12px" },
144
247
  groupTitle: { margin: "0 0 8px", fontSize: "13px", fontWeight: 600 },
145
- row: { display: "flex", gap: "10px", alignItems: "flex-start", padding: "5px 0" },
248
+ row: { display: "flex", gap: "10px", alignItems: "flex-start" },
146
249
  box: { marginTop: "2px" },
147
250
  label: { display: "flex", flexDirection: "column", gap: "2px", cursor: "pointer" },
148
251
  hint: { opacity: 0.68, lineHeight: 1.5 },
149
- select: { marginTop: "4px", padding: "4px 6px", borderRadius: "6px", background: "transparent", color: "inherit", border: "1px solid rgba(128,128,128,.4)" },
150
252
  status: { opacity: 0.7, minHeight: "18px" },
151
253
  error: { color: "#e5534b" },
152
254
  pathLine: { opacity: 0.6, wordBreak: "break-all", lineHeight: 1.5 },
153
255
  button: { padding: "3px 10px", borderRadius: "6px", border: "1px solid rgba(128,128,128,.4)", background: "transparent", color: "inherit", cursor: "pointer" },
154
256
  }
155
257
 
258
+ /**
259
+ * A dropdown drawn by this panel rather than by the operating system.
260
+ *
261
+ * Replaces a native `<select>`, whose popup Windows paints with system colours: the hover
262
+ * highlight overrode the option's background (white text on a white bar) and the list carried
263
+ * a border no CSS could remove. Both are unfixable from outside the control, and this panel's
264
+ * two dropdowns were the only places it did not match its own theme.
265
+ *
266
+ * Keyboard and assistive-technology behaviour is reimplemented rather than inherited, which is
267
+ * the cost of owning the rendering: Up/Down move, Enter/Space commit, Escape closes and
268
+ * returns focus, Home/End jump, clicking outside closes.
269
+ */
270
+ function Dropdown(props) {
271
+ const [open, setOpen] = useState(false)
272
+ // Index the keyboard has moved to; -1 means "the selected value, unless the user moved".
273
+ const [active, setActive] = useState(-1)
274
+ const rootRef = useRef(null)
275
+ const buttonRef = useRef(null)
276
+
277
+ const selected = props.options.findIndex(option => option.value === props.value)
278
+ const current = props.options[selected] ?? props.options[0]
279
+
280
+ useEffect(() => {
281
+ if (!open) return undefined
282
+ const away = event => {
283
+ if (rootRef.current !== null && !rootRef.current.contains(event.target)) setOpen(false)
284
+ }
285
+ document.addEventListener("mousedown", away)
286
+ return () => document.removeEventListener("mousedown", away)
287
+ }, [open])
288
+
289
+ const commit = index => {
290
+ const option = props.options[index]
291
+ if (option === undefined) return
292
+ setOpen(false)
293
+ setActive(-1)
294
+ buttonRef.current?.focus()
295
+ if (option.value !== props.value) props.onChange(option.value)
296
+ }
297
+
298
+ const onKeyDown = event => {
299
+ if (event.key === "Escape") {
300
+ if (open) { event.preventDefault(); setOpen(false); buttonRef.current?.focus() }
301
+ return
302
+ }
303
+ if (!open && (event.key === "Enter" || event.key === " " || event.key === "ArrowDown")) {
304
+ event.preventDefault()
305
+ setOpen(true)
306
+ setActive(selected >= 0 ? selected : 0)
307
+ return
308
+ }
309
+ if (!open) return
310
+ const last = props.options.length - 1
311
+ if (event.key === "ArrowDown") { event.preventDefault(); setActive(a => Math.min(last, (a < 0 ? selected : a) + 1)) }
312
+ else if (event.key === "ArrowUp") { event.preventDefault(); setActive(a => Math.max(0, (a < 0 ? selected : a) - 1)) }
313
+ else if (event.key === "Home") { event.preventDefault(); setActive(0) }
314
+ else if (event.key === "End") { event.preventDefault(); setActive(last) }
315
+ else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); commit(active < 0 ? selected : active) }
316
+ }
317
+
318
+ return h("div", { key: "s", className: "dsh-bb-dd", ref: rootRef }, [
319
+ h("button", {
320
+ key: "b",
321
+ type: "button",
322
+ ref: buttonRef,
323
+ className: "dsh-bb-dd-btn",
324
+ disabled: props.disabled,
325
+ "aria-haspopup": "listbox",
326
+ "aria-expanded": open ? "true" : "false",
327
+ onClick: () => { setOpen(o => !o); setActive(-1) },
328
+ onKeyDown,
329
+ }, [
330
+ h("span", { key: "v" }, current === undefined ? "" : current.label),
331
+ h("span", { key: "c", className: "dsh-bb-dd-caret", "aria-hidden": "true" }, "\\u25be"),
332
+ ]),
333
+ open ? h("div", {
334
+ key: "l",
335
+ className: "dsh-bb-dd-list",
336
+ role: "listbox",
337
+ "aria-label": props.label,
338
+ }, props.options.map((option, index) => h("button", {
339
+ key: option.value,
340
+ type: "button",
341
+ className: "dsh-bb-dd-opt",
342
+ role: "option",
343
+ "aria-selected": option.value === props.value ? "true" : "false",
344
+ "data-active": index === active ? "true" : "false",
345
+ onMouseEnter: () => setActive(index),
346
+ onClick: () => commit(index),
347
+ }, option.label))) : null,
348
+ ])
349
+ }
350
+
156
351
  /** One boolean switch bound to a settings path. */
157
352
  function Toggle(props) {
158
353
  const t = translate
159
- return h("div", { style: styles.row }, [
354
+ return h("div", { style: styles.row, className: "dsh-bb-row" }, [
160
355
  h("input", {
161
356
  key: "i",
162
357
  type: "checkbox",
@@ -277,40 +472,43 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
277
472
  ]),
278
473
 
279
474
  section("vision.title", [
280
- h("div", { key: "row", style: styles.row }, [
475
+ h("div", { key: "row", style: styles.row, className: "dsh-bb-row" }, [
281
476
  h("label", { key: "l", style: styles.label }, [
282
477
  h("span", { key: "t" }, t("vision.strategy")),
283
- h("select", {
478
+ h(Dropdown, {
284
479
  key: "s",
285
- style: styles.select,
480
+ label: t("vision.strategy"),
286
481
  value: settings.vision.strategy,
287
482
  disabled: busy,
288
- onChange: event => patch({ vision: { strategy: event.target.value } }),
289
- }, [
290
- h("option", { key: "auto", value: "auto" }, t("vision.auto")),
291
- h("option", { key: "nonVisual", value: "nonVisual" }, t("vision.nonVisual")),
292
- ]),
483
+ onChange: value => patch({ vision: { strategy: value } }),
484
+ options: [
485
+ { value: "auto", label: t("vision.auto") },
486
+ { value: "nonVisual", label: t("vision.nonVisual") },
487
+ ],
488
+ }),
293
489
  h("span", { key: "h", style: styles.hint }, t("vision.hint")),
294
490
  ]),
295
491
  ]),
296
492
  ]),
297
493
 
298
494
  section("browser.title", [
299
- h("div", { key: "row", style: styles.row }, [
495
+ h("div", { key: "row", style: styles.row, className: "dsh-bb-row" }, [
300
496
  h("label", { key: "l", style: styles.label }, [
301
497
  h("span", { key: "t" }, t("browser.channel")),
302
- h("select", {
498
+ h(Dropdown, {
303
499
  key: "s",
304
- style: styles.select,
500
+ label: t("browser.channel"),
305
501
  value: settings.browser.channel,
306
502
  disabled: busy,
307
- onChange: event => patch({ browser: { channel: event.target.value } }),
308
- }, [
309
- h("option", { key: "bundled", value: "bundled" }, t("browser.bundled")),
310
- h("option", { key: "auto", value: "auto" }, t("browser.auto")),
311
- h("option", { key: "chrome", value: "chrome" }, t("browser.chrome")),
312
- h("option", { key: "edge", value: "edge" }, t("browser.edge")),
313
- ]),
503
+ onChange: value => patch({ browser: { channel: value } }),
504
+ options: [
505
+ { value: "sidebar", label: t("browser.sidebar") },
506
+ { value: "bundled", label: t("browser.bundled") },
507
+ { value: "auto", label: t("browser.auto") },
508
+ { value: "chrome", label: t("browser.chrome") },
509
+ { value: "edge", label: t("browser.edge") },
510
+ ],
511
+ }),
314
512
  h("span", { key: "h", style: styles.hint }, t("browser.hint")),
315
513
  ]),
316
514
  ]),
@@ -365,7 +563,218 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
365
563
  ])
366
564
  }
367
565
 
566
+ // ===========================================================================
567
+ // Conversation panel service: the one place a sidebar panel is opened.
568
+ //
569
+ // The shell mounts one right sidebar per conversation, and DSH's own sidebar
570
+ // browser keeps that conversation's tabs and its webview guest inside it.
571
+ // `ctx.sidebarRight.openTab` acts on the conversation the shell is SHOWING
572
+ // (`mounted`), so every path that simulated a shortcut or clicked a card put
573
+ // the page into whichever conversation the human happened to be reading —
574
+ // that is the cross-conversation bleed, and it is a property of the path, not
575
+ // an accident of it.
576
+ //
577
+ // `openTabIn(sessionId, …)` is the same navigation aimed at a NAMED
578
+ // conversation, and it works for one that is not on screen. This service is
579
+ // the only caller; the plugin's host half reaches it by evaluating
580
+ // `globalThis.__dshBuiltinBrowser.openPanel(...)` inside the shell window, so
581
+ // no input event, no key dispatch and no element click is involved.
582
+ //
583
+ // Guest identity is read from the panel's own `<webview>` and only inside the
584
+ // subtree found for THAT conversation — never from the first match in the
585
+ // document, which belongs to somebody else's panel.
586
+ // ===========================================================================
587
+
588
+ /** The conversation a sidebar element belongs to, read off its React fiber. */
589
+ function sessionIdOfElement(element) {
590
+ const key = Object.keys(element).find(
591
+ name => name.startsWith("__reactFiber$") || name.startsWith("__reactInternalInstance$"),
592
+ )
593
+ if (key === undefined) return null
594
+ let fiber = element[key]
595
+ let depth = 0
596
+ while (fiber !== null && fiber !== undefined && depth < 60) {
597
+ const props = fiber.memoizedProps
598
+ if (props !== null && typeof props === "object" && typeof props.sessionId === "string") return props.sessionId
599
+ fiber = fiber.return
600
+ depth += 1
601
+ }
602
+ return null
603
+ }
604
+
605
+ /**
606
+ * This conversation's own sidebar strip, or null when the shell has not mounted it.
607
+ *
608
+ * Its presence is the precondition `openTabIn` needs: it is minted together with the
609
+ * conversation's surface store, and a call for a conversation whose store was never
610
+ * minted silently does nothing. Detecting it here turns that silence into a reason
611
+ * the caller can report, instead of a page that never appears.
612
+ */
613
+ function tabStripFor(sessionId) {
614
+ for (const strip of Array.from(document.querySelectorAll("[class*=_tabStrip]"))) {
615
+ if (sessionIdOfElement(strip) === sessionId) return strip
616
+ }
617
+ return null
618
+ }
619
+
620
+ /**
621
+ * Every live `<webview>` guest inside this conversation's panel subtree.
622
+ *
623
+ * The walk up stops the moment an ancestor carries ANOTHER conversation's sidebar, and that stop
624
+ * condition is the whole point of this function. The shell mounts every conversation's panel in
625
+ * the same document — hidden ones included — so one step too far lands on a subtree that holds
626
+ * all of them, and the first guest found there belongs to whichever conversation the shell
627
+ * happens to render first. Measured: a GitHub login page opened in a different conversation was
628
+ * reported as this one's guest, which is the cross-conversation bleed this service exists to
629
+ * prevent. A sibling strip is the exact, class-name-independent signature of having stepped
630
+ * outside; a conversation may legitimately own more than one strip of its own, so the test is
631
+ * "is there one that is NOT mine", never a count.
632
+ */
633
+ function guestsInPanel(sessionId) {
634
+ const strip = tabStripFor(sessionId)
635
+ if (strip === null) return []
636
+ // Start at the strip itself: it is by construction this conversation's own, so the foreign test
637
+ // below cannot trip on it, and a shell that renders the guest as a sibling of the strip is
638
+ // still reached.
639
+ let node = strip
640
+ while (node !== null && node !== undefined && node !== document.body) {
641
+ const foreign = Array.from(node.querySelectorAll("[class*=_tabStrip]"))
642
+ .some(el => sessionIdOfElement(el) !== sessionId)
643
+ if (foreign) break
644
+ const found = Array.from(node.querySelectorAll("webview")).filter(view => {
645
+ try { return typeof view.getWebContentsId === "function" && view.getWebContentsId() > 0 }
646
+ catch (error) { return false }
647
+ })
648
+ if (found.length > 0) return found
649
+ node = node.parentElement
650
+ }
651
+ return []
652
+ }
653
+
654
+ /** Read this conversation's current guest, preferring the one actually drawn. */
655
+ function guestStateFor(sessionId) {
656
+ const strip = tabStripFor(sessionId)
657
+ if (strip === null) return { panel: false, guestId: null, url: "" }
658
+ const guests = guestsInPanel(sessionId)
659
+ if (guests.length === 0) return { panel: true, guestId: null, url: "" }
660
+ // A sidebar keeps every tab mounted (`keepMounted`), so a background tab's guest is
661
+ // in the tree too. Prefer the one with a drawn box; fall back to the newest.
662
+ const drawn = guests.find(view => {
663
+ const rect = view.getBoundingClientRect()
664
+ return rect.width > 0 && rect.height > 0
665
+ })
666
+ const chosen = drawn === undefined ? guests[guests.length - 1] : drawn
667
+ let url = ""
668
+ try { url = String(chosen.getURL ? chosen.getURL() : "") } catch (error) { url = "" }
669
+ try { return { panel: true, guestId: chosen.getWebContentsId(), url } }
670
+ catch (error) { return { panel: true, guestId: null, url: "" } }
671
+ }
672
+
673
+ /**
674
+ * Publish the service every host-side panel request goes through.
675
+ *
676
+ * The binding is separate from the publication so the API exists from the first
677
+ * apply — a request arriving before `sidebarRight` is ready gets a named reason
678
+ * rather than a missing global, which is indistinguishable from a bridge that
679
+ * never loaded.
680
+ */
681
+ function installPanelService(ctx) {
682
+ let sidebarRight = null
683
+ // `inject` is how a client plugin waits for a service to appear. A host that does not offer it
684
+ // — an older composition, or a harness that mounted this bundle for its settings panel alone —
685
+ // must still get the panel, so the wait is skipped and the published API names the missing
686
+ // service when it is called. Failing the whole apply here would take the settings panel down
687
+ // with it, which is exactly the shape of regression this file has been bitten by before.
688
+ if (typeof ctx.inject === "function") {
689
+ ctx.inject(["sidebarRight"], (scope) => {
690
+ sidebarRight = scope.sidebarRight
691
+ scope.effect(() => () => {
692
+ if (sidebarRight === scope.sidebarRight) sidebarRight = null
693
+ }, "dsh-builtin-browser: conversation panel binding")
694
+ })
695
+ }
696
+ const api = {
697
+ /** @returns what happened: an existing guest, a newly opened tab, or a named refusal. */
698
+ openPanel(sessionId, url) {
699
+ const wanted = typeof sessionId === "string" ? sessionId : ""
700
+ if (!/^session-/.test(wanted)) {
701
+ return { ok: false, reason: "not a conversation id: " + JSON.stringify(sessionId), panel: false, guestId: null }
702
+ }
703
+ if (sidebarRight === null || typeof sidebarRight.openTabIn !== "function") {
704
+ return { ok: false, reason: "the shell's sidebar service is not available", panel: false, guestId: null }
705
+ }
706
+ const state = guestStateFor(wanted)
707
+ if (state.guestId !== null) {
708
+ return { ok: true, created: false, panel: true, guestId: state.guestId, url: state.url }
709
+ }
710
+ // A mounted panel is NOT required to place a tab.
711
+ //
712
+ // `openTabIn` acts on the conversation's own surface store, and the sidebar-right service
713
+ // keeps one for every conversation the shell has shown — "reaches any session's store by
714
+ // id … on screen or not". What a DISPLAYED conversation adds is the DOM to read the guest
715
+ // back out of, which is a separate question. So the tab is placed either way, and `tabsIn`
716
+ // (a public method) is what says whether it actually landed: `openTabIn` SILENTLY ignores a
717
+ // conversation whose store was never minted, and a silent no-op must never be reported as
718
+ // success.
719
+ let before = 0
720
+ try { before = sidebarRight.tabsIn(wanted).length } catch (error) { before = 0 }
721
+ try {
722
+ sidebarRight.openTabIn(wanted, "browser", url ? { params: { url: String(url) } } : {})
723
+ } catch (error) {
724
+ return { ok: false, reason: String((error && error.message) || error), panel: state.panel, guestId: null }
725
+ }
726
+ let placed = 0
727
+ try { placed = sidebarRight.tabsIn(wanted).length } catch (error) { placed = before }
728
+ // `openTabIn` DEDUPLICATES, so "the count grew" is the wrong test for "is this
729
+ // conversation's surface mounted".
730
+ //
731
+ // Placing a page kind the conversation already holds leaves the count unchanged — the shell
732
+ // reveals the tab it has. Testing for growth therefore reported a conversation that already
733
+ // held the page as UNMOUNTED, and refused to drive a page that was right there: that is the
734
+ // "it will not reopen what it already opened" behaviour, and it bites hardest on the second
735
+ // open of an address, which is exactly when a human expects a fast answer.
736
+ //
737
+ // A count that started at zero and stayed there is the real signature of a surface the
738
+ // shell has never minted, because that is the only case where `openTabIn` is a silent no-op.
739
+ if (placed <= before && before === 0) {
740
+ // Name what was FOUND, not just what was missing: "no panel" has two very different
741
+ // causes — the shell has mounted no sidebar for this conversation (nothing this side can
742
+ // do), or it writes its session id somewhere this lookup does not read (fixable) — and a
743
+ // refusal that says only "not mounted" cannot tell them apart.
744
+ const strips = Array.from(document.querySelectorAll("[class*=_tabStrip]"))
745
+ return {
746
+ ok: false,
747
+ reason: "this conversation's sidebar is not mounted, so a panel cannot be opened in it",
748
+ panel: state.panel,
749
+ guestId: null,
750
+ found: {
751
+ wanted: wanted,
752
+ strips: strips.length,
753
+ owners: strips.slice(0, 8).map(strip => sessionIdOfElement(strip)),
754
+ reactKeys: strips.length === 0 ? [] : Object.keys(strips[0]).filter(key => key.startsWith("__react")).slice(0, 4),
755
+ },
756
+ }
757
+ }
758
+ return { ok: true, created: placed > before, panel: state.panel, guestId: null, tabs: placed }
759
+ },
760
+ /** Read-only: this conversation's current guest, if it has one. */
761
+ panelGuest(sessionId) {
762
+ return guestStateFor(typeof sessionId === "string" ? sessionId : "")
763
+ },
764
+ }
765
+ ctx.effect(() => {
766
+ globalThis.__dshBuiltinBrowser = api
767
+ return () => {
768
+ if (globalThis.__dshBuiltinBrowser === api) delete globalThis.__dshBuiltinBrowser
769
+ }
770
+ }, "dsh-builtin-browser: conversation panel service")
771
+ }
772
+
368
773
  function apply(ctx) {
774
+ // Before anything renders: the rows need a hover state, and inline styles cannot express
775
+ // one. Idempotent, so a re-apply does not stack copies.
776
+ installStyles()
777
+ installPanelService(ctx)
369
778
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-builtin-browser: dictionaries")
370
779
  const t = ctx.locale.bind(NS)
371
780
  translate = t
@@ -106,11 +106,31 @@ ${MARKER} pages.
106
106
  // Installed by apps/desktop/bridge/install.mjs — re-run it after a desktop upgrade.
107
107
  // A bridge failure is never fatal to the shell.
108
108
  app.whenReady().then(async () => {
109
+ const bridgeUrl = new URL("./plugin-browser-bridge.js", import.meta.url);
110
+ const loadBridge = async (why) => {
111
+ try {
112
+ // A plain import is cached for the process lifetime, so an edited bridge
113
+ // would need a shell restart. The stamp makes every load read the current
114
+ // bytes; start() itself stops the previous instance, so this reloads in place.
115
+ const bridge = await import(bridgeUrl.href + "?v=" + Date.now());
116
+ bridge.start();
117
+ console.log("[dsh-browser-bridge] started" + (why ? " (" + why + ")" : ""));
118
+ } catch (error) {
119
+ console.error("[dsh-browser-bridge] start failed:", error);
120
+ }
121
+ };
122
+ await loadBridge();
123
+ // Watch the module so an edit reloads it in place instead of forcing a shell
124
+ // restart. Debounced; a failure here is never fatal to the shell.
109
125
  try {
110
- const bridge = await import("./plugin-browser-bridge.js");
111
- bridge.start();
126
+ const { watch } = await import("node:fs");
127
+ let timer = null;
128
+ watch(bridgeUrl, { persistent: false }, () => {
129
+ if (timer) clearTimeout(timer);
130
+ timer = setTimeout(() => void loadBridge("file changed"), 300);
131
+ });
112
132
  } catch (error) {
113
- console.error("[dsh-browser-bridge] start failed:", error);
133
+ console.error("[dsh-browser-bridge] watch failed:", error);
114
134
  }
115
135
  });
116
136
  `
@@ -273,9 +293,49 @@ if (revert) {
273
293
  reverted = true
274
294
  }
275
295
  if (existsSync(backupPath)) {
276
- copyFileSync(backupPath, mainPath)
277
- console.log(`restored ${mainPath} from ${backupPath}`)
278
- reverted = true
296
+ // Refuse when the backup no longer matches the tree it would overwrite.
297
+ //
298
+ // The backup is taken once and then kept, so after a desktop upgrade it holds the PREVIOUS
299
+ // build's main.js. Restoring it would silently roll the app back to that build's code — and
300
+ // it would also discard any patch another tool applied in the meantime, which is not a
301
+ // hypothetical here: `dsh-purge` patches this same file, so a blind revert erases its work
302
+ // while reporting success. A matching current file is the only case where the restore is
303
+ // unambiguously right; anything else needs a human to decide.
304
+ let backedUp
305
+ let current
306
+ try {
307
+ backedUp = readFileSync(backupPath)
308
+ current = readFileSync(mainPath)
309
+ } catch {
310
+ backedUp = undefined
311
+ current = undefined
312
+ }
313
+ const identical = backedUp !== undefined && current !== undefined && backedUp.equals(current)
314
+ const stillHasOurMarker = current !== undefined && current.includes(MARKER)
315
+ // A backup taken BEFORE the file was patched by anything else is not a clean restore
316
+ // point: reverting to it discards those patches. The marker test alone does not catch
317
+ // this, because the current file legitimately carries our marker — that is exactly what
318
+ // this script put there — while the backup predates a patch by another tool.
319
+ // `dsh-purge` patches this same file, so on this machine that is not hypothetical.
320
+ const backupHasOurMarker = backedUp !== undefined && backedUp.includes(MARKER)
321
+ if (!identical && stillHasOurMarker && !backupHasOurMarker) {
322
+ console.error(`WARNING: ${backupPath} predates the bridge patch, so it is not a clean`)
323
+ console.error(` restore point for ${mainPath}. Reverting would also discard anything`)
324
+ console.error(' patched in between (dsh-purge patches this same file). Refreshing the')
325
+ console.error(' backup from the current file instead, so a later --revert is safe.')
326
+ writeFileSync(backupPath, current ?? Buffer.alloc(0))
327
+ reverted = true
328
+ } else if (!identical && !stillHasOurMarker) {
329
+ console.error(`WARNING: ${mainPath} has changed since the backup was taken, and it does`)
330
+ console.error(' not contain the bridge import either — something else has edited it')
331
+ console.error(' (a desktop upgrade, or another patch tool). Restoring the backup would')
332
+ console.error(` discard that. The backup is at ${backupPath}; restore it by hand if that`)
333
+ console.error(' is what you want. Skipping this file.')
334
+ } else {
335
+ copyFileSync(backupPath, mainPath)
336
+ console.log(`restored ${mainPath} from ${backupPath}`)
337
+ reverted = true
338
+ }
279
339
  }
280
340
  if (existsSync(cliShimBackup)) {
281
341
  copyFileSync(cliShimBackup, cliShimPath)