@cjylzs/dsh-browser 0.0.0-stage → 0.3.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/lib/client.js ADDED
@@ -0,0 +1,2575 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-browser",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
+ //#region src/client/api.ts
11
+ /**
12
+ * Read the live browser state.
13
+ * @returns every session's browser, as the host sees it.
14
+ * @throws {Error} when the route refuses or the answer is not a report.
15
+ */
16
+ async function browserStatus() {
17
+ const response = await fetch("/dsh-browser/status", { headers: { Accept: "application/json" } });
18
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
19
+ return await response.json();
20
+ }
21
+ /**
22
+ * Ask the host for a new page in one session's browser.
23
+ *
24
+ * The Sidebar's browser entry is the caller: the pane that asks exists to hold
25
+ * the page this produces, and one ask is one page — a browser already running
26
+ * gets another blank one rather than the tab that is already there.
27
+ * `request` is the asking tab's own id, so a pane remounted by a tab switch, a
28
+ * Session change, or a client reload is the same ask and opens nothing new.
29
+ * @param sessionId - the session whose browser should open a page.
30
+ * @param request - the asking tab's id.
31
+ * @param url - address to load in the new page; a blank page when absent.
32
+ * @returns the new page's CDP target id, when the host named it.
33
+ * @throws {Error} when the route refuses or the browser cannot start.
34
+ */
35
+ async function openPageRequest(sessionId, request, url) {
36
+ const response = await fetch("/dsh-browser/pages", {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({
40
+ action: "open",
41
+ sessionId,
42
+ request,
43
+ ...url === void 0 ? {} : { url }
44
+ })
45
+ });
46
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
47
+ const answer = await response.json();
48
+ return typeof answer.targetId === "string" ? answer.targetId : void 0;
49
+ }
50
+ /**
51
+ * Ask the host to close one browser page.
52
+ *
53
+ * This is what closing a sidebar tab does to the page it mirrored: the tab's
54
+ * own close handler fires here, before the tab is removed. The host decides
55
+ * what the close means — one page among several closes alone, the last one
56
+ * stops the browser — so the client never has to count pages it cannot see.
57
+ * @param sessionId - the session whose browser holds the page.
58
+ * @param targetId - the page's CDP target id.
59
+ * @throws {Error} when the route refuses.
60
+ */
61
+ async function closePageRequest(sessionId, targetId) {
62
+ const response = await fetch("/dsh-browser/pages", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ sessionId,
67
+ targetId
68
+ })
69
+ });
70
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
71
+ }
72
+ /**
73
+ * Ask the host to stop one session's browser, and every page it holds.
74
+ *
75
+ * This is the settings page's control, and it is the one ask in the plugin that
76
+ * means "this conversation does not want a browser" rather than "close the page
77
+ * I am looking at". The host decides what that costs: it stops the browser, and
78
+ * a viewer coming back does not start another.
79
+ * @param sessionId - the session whose browser should stop.
80
+ * @throws {Error} when the route refuses.
81
+ */
82
+ async function closeBrowserRequest(sessionId) {
83
+ const response = await fetch("/dsh-browser/pages", {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({
87
+ action: "close",
88
+ sessionId,
89
+ all: true
90
+ })
91
+ });
92
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
93
+ }
94
+ /**
95
+ * Decide how the restart control reaches the host.
96
+ *
97
+ * The control exists for a browser the pane cannot use, and the pane loses its
98
+ * socket in exactly the case that matters: watching never starts a browser, so
99
+ * after a close from the settings page the host refuses the subscription and
100
+ * closes the socket. A message sent then is dropped in silence, which is how
101
+ * this control came to do nothing when it was most needed. The page ask over
102
+ * HTTP is the one path that does start a browser.
103
+ * @param socketOpen - whether the viewer socket is open right now.
104
+ * @param tabId - the asking tab's id, which the HTTP ask must carry.
105
+ * @returns the route the caller should take.
106
+ */
107
+ function restartRoute(socketOpen, tabId) {
108
+ if (socketOpen) return { via: "socket" };
109
+ if (tabId !== void 0 && tabId !== "") return { via: "http" };
110
+ return { via: "none" };
111
+ }
112
+ //#endregion
113
+ //#region src/client/clipboard.ts
114
+ /**
115
+ * Whether this keystroke is one of the clipboard shortcuts the pane owns.
116
+ *
117
+ * Alt disqualifies it: on Windows AltGr arrives as Control+Alt and still types a
118
+ * character, and the pane's own key path sends that as text. Shift does not:
119
+ * `Control+Shift+V` is a browser's paste-as-plain-text, which is exactly what the
120
+ * mirror's paste is, and nothing here claims Shift+C or Shift+X.
121
+ * @param event - the keydown to classify.
122
+ * @returns the chord to handle, or undefined when the key belongs to the mirror.
123
+ */
124
+ function clipboardChord(event) {
125
+ if (event.altKey) return void 0;
126
+ if (!event.ctrlKey && !event.metaKey) return void 0;
127
+ switch (event.key.toLowerCase()) {
128
+ case "c": return "copy";
129
+ case "x": return "cut";
130
+ case "v": return "paste";
131
+ default: return;
132
+ }
133
+ }
134
+ /**
135
+ * What a copy or a cut does with the text the mirror reported.
136
+ *
137
+ * An empty selection writes nothing: a browser's own copy with nothing selected
138
+ * leaves the clipboard as it was, and overwriting it with "" would be the pane
139
+ * clobbering a clipboard the user did not ask it to touch. A cut that did have
140
+ * something deletes it in the mirror, which is the whole difference between the
141
+ * two chords.
142
+ * @param chord - the reading chord that was pressed.
143
+ * @param text - the selection the mirror reported.
144
+ * @returns the clipboard write and, for a cut, the delete that follows it.
145
+ */
146
+ function clipboardReply(chord, text) {
147
+ if (text === "") return {
148
+ write: void 0,
149
+ after: void 0
150
+ };
151
+ return {
152
+ write: text,
153
+ ...chord === "cut" ? { after: {
154
+ type: "key",
155
+ key: "Delete"
156
+ } } : { after: void 0 }
157
+ };
158
+ }
159
+ /**
160
+ * What a paste sends the mirror, if anything.
161
+ *
162
+ * An empty clipboard sends nothing: `Input.insertText` with "" would be a
163
+ * keystroke the page never saw, and there is nothing to insert anyway.
164
+ * @param text - the clipboard text the pane read.
165
+ * @returns the text input message, or undefined when there is nothing to paste.
166
+ */
167
+ function pasteMessage(text) {
168
+ return text === "" ? void 0 : {
169
+ type: "text",
170
+ text
171
+ };
172
+ }
173
+ //#endregion
174
+ //#region src/browser/keys.ts
175
+ /** Keys worth dispatching as key events; anything else printable goes through `insertText`. */
176
+ const NAMED_KEYS = {
177
+ Enter: {
178
+ code: "Enter",
179
+ keyCode: 13,
180
+ text: "\r"
181
+ },
182
+ Tab: {
183
+ code: "Tab",
184
+ keyCode: 9
185
+ },
186
+ Backspace: {
187
+ code: "Backspace",
188
+ keyCode: 8
189
+ },
190
+ Delete: {
191
+ code: "Delete",
192
+ keyCode: 46
193
+ },
194
+ Escape: {
195
+ code: "Escape",
196
+ keyCode: 27
197
+ },
198
+ ArrowLeft: {
199
+ code: "ArrowLeft",
200
+ keyCode: 37
201
+ },
202
+ ArrowUp: {
203
+ code: "ArrowUp",
204
+ keyCode: 38
205
+ },
206
+ ArrowRight: {
207
+ code: "ArrowRight",
208
+ keyCode: 39
209
+ },
210
+ ArrowDown: {
211
+ code: "ArrowDown",
212
+ keyCode: 40
213
+ },
214
+ Home: {
215
+ code: "Home",
216
+ keyCode: 36
217
+ },
218
+ End: {
219
+ code: "End",
220
+ keyCode: 35
221
+ },
222
+ PageUp: {
223
+ code: "PageUp",
224
+ keyCode: 33
225
+ },
226
+ PageDown: {
227
+ code: "PageDown",
228
+ keyCode: 34
229
+ }
230
+ };
231
+ /**
232
+ * The modifiers a chord may name, as the bit field CDP takes.
233
+ *
234
+ * The aliases are the ones a keyboard and the platform APIs give the same four
235
+ * keys, so a chord written either way presses the same thing.
236
+ */
237
+ const MODIFIERS = {
238
+ Alt: 1,
239
+ Option: 1,
240
+ Control: 2,
241
+ Ctrl: 2,
242
+ Meta: 4,
243
+ Command: 4,
244
+ Cmd: 4,
245
+ Shift: 8
246
+ };
247
+ /** How to spell the modifiers in a message about them. */
248
+ const MODIFIER_NAMES = "Control, Meta, Alt, Shift";
249
+ /** Shift, as the bit field spells it. */
250
+ const SHIFT = 8;
251
+ /** Modifiers that stop the key from producing text, as a browser does. */
252
+ const SUPPRESSES_TEXT = 7;
253
+ /**
254
+ * One character pressed as a key.
255
+ *
256
+ * Only a letter's case follows Shift, because only a letter's case is the same
257
+ * on every keyboard: `Shift+1` is `!` on a US layout and something else on the
258
+ * next one, so a character that is not a letter is pressed as it is written and
259
+ * typing it as text is the caller's job.
260
+ * @param token - the character the chord named.
261
+ * @param modifiers - the modifiers held.
262
+ * @returns the resolved press.
263
+ */
264
+ function characterStroke(token, modifiers) {
265
+ const letter = /^[a-z]$/iu.test(token);
266
+ const digit = /^[0-9]$/u.test(token);
267
+ const upper = token.toUpperCase();
268
+ const key = letter ? (modifiers & SHIFT) === 0 ? token.toLowerCase() : upper : token;
269
+ return {
270
+ key,
271
+ code: letter ? `Key${upper}` : digit ? `Digit${token}` : token === " " ? "Space" : void 0,
272
+ keyCode: letter ? upper.charCodeAt(0) : digit ? 48 + Number(token) : token === " " ? 32 : void 0,
273
+ text: (modifiers & SUPPRESSES_TEXT) === 0 ? key : void 0,
274
+ modifiers
275
+ };
276
+ }
277
+ /**
278
+ * Resolve one chord into the press it describes.
279
+ *
280
+ * @param chord - a key name, a single character, or modifiers joined to either
281
+ * with `+`.
282
+ * @returns what to dispatch.
283
+ * @throws {Error} when the chord names no key, an unknown modifier, or a key
284
+ * that has no dispatch mapping.
285
+ */
286
+ function parseKeyStroke(chord) {
287
+ const tokens = chord.split("+");
288
+ const named = tokens.slice(0, -1);
289
+ const last = tokens.at(-1) ?? "";
290
+ let modifiers = 0;
291
+ for (const token of named) {
292
+ const bit = MODIFIERS[token];
293
+ if (bit === void 0) throw new Error(`dsh-browser: "${token}" is not a modifier; use one of ${MODIFIER_NAMES}, joined to the key with "+", such as "Control+A"`);
294
+ modifiers |= bit;
295
+ }
296
+ if (MODIFIERS[last] !== void 0 && named.length > 0) throw new Error(`dsh-browser: "${chord}" names no key after its modifiers; write the key last, such as "Control+A"`);
297
+ if (MODIFIERS[last] !== void 0) throw new Error(`dsh-browser: "${chord}" is a modifier with no key after it; write the key last, such as "Control+A"`);
298
+ if (last === "") throw new Error(`dsh-browser: "${chord}" names no key after its modifiers; write the key last, such as "Control+A"`);
299
+ const key = NAMED_KEYS[last];
300
+ if (key !== void 0) {
301
+ const text = (modifiers & SUPPRESSES_TEXT) === 0 ? key.text : void 0;
302
+ return {
303
+ key: last,
304
+ code: key.code,
305
+ keyCode: key.keyCode,
306
+ text,
307
+ modifiers
308
+ };
309
+ }
310
+ if ([...last].length === 1) return characterStroke(last, modifiers);
311
+ throw new Error(`dsh-browser: "${chord}" is not a dispatchable key; send a single character, or text through the text message`);
312
+ }
313
+ /**
314
+ * Whether the host would dispatch this chord.
315
+ *
316
+ * The pane asks before it sends, and it must ask here rather than of its own
317
+ * table: a chord the host refuses comes back on the same error frame the pane
318
+ * uses for a browser in trouble, so a pane that guesses turns an ordinary
319
+ * keypress — a lone modifier, a key this table does not carry — into a UI
320
+ * failure. One table, one answer.
321
+ * @param chord - a key name, a single character, or modifiers joined to either.
322
+ * @returns true when `parseKeyStroke` resolves it, false when it would refuse.
323
+ */
324
+ function isDispatchableKey(chord) {
325
+ try {
326
+ parseKeyStroke(chord);
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+ //#endregion
333
+ //#region src/client/keys.ts
334
+ /**
335
+ * What one keydown in the pane becomes on the wire.
336
+ *
337
+ * The decision lives here rather than in the canvas that listens, because it is
338
+ * the one piece of the pane's input path with a right answer: whether a
339
+ * keystroke is text, a chord, or nothing at all. It asks the host's own
340
+ * vocabulary (`../browser/keys.ts`) before naming a chord, so the pane cannot
341
+ * ask for something the host will refuse — a refusal comes back on the frame the
342
+ * pane reserves for a browser in trouble, which is how pressing Ctrl once came
343
+ * to look like a broken browser.
344
+ */
345
+ /**
346
+ * The modifiers a key event is holding, spelled the way a chord writes them.
347
+ * @param event - the key event.
348
+ * @returns the modifier names, in the order a chord writes them.
349
+ */
350
+ function modifiersOf(event) {
351
+ const held = [];
352
+ if (event.ctrlKey) held.push("Control");
353
+ if (event.metaKey) held.push("Meta");
354
+ if (event.altKey) held.push("Alt");
355
+ if (event.shiftKey) held.push("Shift");
356
+ return held;
357
+ }
358
+ /**
359
+ * Resolve one keydown into the message the viewer channel carries.
360
+ *
361
+ * AltGr arrives as Control+Alt on Windows and still produces a character — a
362
+ * German or Polish layout types its symbols that way — so it goes as text;
363
+ * sending it as a chord would swallow the character the user meant to type. A
364
+ * chord is a keystroke, not text: sending Ctrl+A as the character "a" is what
365
+ * this path used to do, and a page that selects everything on Ctrl+A had an "a"
366
+ * typed into it instead. And a key that is only a modifier resolves to nothing,
367
+ * because a chord writes its key last: pressing Ctrl starts a chord rather than
368
+ * being one.
369
+ *
370
+ * @param event - the keydown to resolve.
371
+ * @returns the text or chord to send, or undefined when this key is nothing the
372
+ * page can be sent.
373
+ */
374
+ function keyMessage(event) {
375
+ const altGraph = event.ctrlKey && event.altKey && event.key.length === 1;
376
+ if (event.key.length === 1 && (!(event.ctrlKey || event.metaKey || event.altKey) || altGraph)) return {
377
+ type: "text",
378
+ text: event.key
379
+ };
380
+ const chord = [...modifiersOf(event), event.key].join("+");
381
+ return isDispatchableKey(chord) ? {
382
+ type: "key",
383
+ key: chord
384
+ } : void 0;
385
+ }
386
+ //#endregion
387
+ //#region src/client/pages.ts
388
+ /**
389
+ * What the client half knows about a browser's pages, and the one rule that
390
+ * keeps the Sidebar's tabs matched to them.
391
+ *
392
+ * A page's stable identity is its CDP target id — the same string external
393
+ * DevTools sees — and a tab's address is built from it, so a tab keeps naming
394
+ * its page across navigations, active-page changes, and client reloads. The
395
+ * sync loop reads the host's report and reconciles: one tab per page the
396
+ * browser holds, no tab for a page it does not.
397
+ */
398
+ /** The address scheme a browser page's tab is recorded under. */
399
+ const PAGE_PREFIX = "dsh-resource://dsh-browser-page/";
400
+ /**
401
+ * The address a page's tab carries.
402
+ * @param targetId - the page's CDP target id.
403
+ * @returns the address to open the tab with.
404
+ */
405
+ function pageAddressOf(targetId) {
406
+ return PAGE_PREFIX + encodeURIComponent(targetId);
407
+ }
408
+ /**
409
+ * The page a tab address names.
410
+ * @param address - the tab's content id.
411
+ * @returns the page's CDP target id, or `undefined` for an address that is not a page's.
412
+ */
413
+ function targetIdOf(address) {
414
+ if (!address.startsWith("dsh-resource://dsh-browser-page/")) return void 0;
415
+ const rest = address.slice(32);
416
+ if (rest === "") return void 0;
417
+ try {
418
+ return decodeURIComponent(rest);
419
+ } catch {
420
+ return rest;
421
+ }
422
+ }
423
+ /**
424
+ * The one rule: a tab for every page the browser holds, and none for a page it
425
+ * does not.
426
+ *
427
+ * Tabs that name a page are closed the moment their page is gone. A tab that
428
+ * names no page — the guide entry's, which exists to start a browser nobody is
429
+ * watching yet — is kept until there are page tabs to hand the story to, so
430
+ * picking the entry still opens a pane while the browser is starting.
431
+ * @param desired - the page addresses the browser holds, in tab order.
432
+ * @param actual - the tabs of this kind the Sidebar already shows.
433
+ * @returns what to open and what to close.
434
+ */
435
+ function reconcile(desired, actual) {
436
+ const held = new Set(actual.map((tab) => tab.contentId));
437
+ const open = [];
438
+ for (const address of desired) if (!held.has(address)) open.push(address);
439
+ const wanted = new Set(desired);
440
+ const close = [];
441
+ for (const tab of actual) {
442
+ const isPageTab = targetIdOf(tab.contentId) !== void 0;
443
+ if (isPageTab && !wanted.has(tab.contentId)) close.push(tab.id);
444
+ if (!isPageTab && desired.length > 0) close.push(tab.id);
445
+ }
446
+ return {
447
+ open,
448
+ close
449
+ };
450
+ }
451
+ /**
452
+ * Pages a close was asked for, so the loop does not open a tab for a page that
453
+ * is on its way out.
454
+ *
455
+ * Closing a tab closes its page, and the host's report is a snapshot: for a
456
+ * moment after the ask, the page is still listed. Without this the loop would
457
+ * read that moment as "a page with no tab" and put the tab back.
458
+ */
459
+ const closing = /* @__PURE__ */ new Map();
460
+ /** How long a close ask stands, when the report never stops listing the page. */
461
+ const CLOSING_TIMEOUT_MS = 15e3;
462
+ /**
463
+ * Record that a page's close was asked for.
464
+ * @param sessionId - the session whose browser holds the page.
465
+ * @param targetId - the page's CDP target id.
466
+ * @param at - when the ask was made; defaults to now (tests hand a fixed one).
467
+ */
468
+ function markClosing(sessionId, targetId, at = Date.now()) {
469
+ closing.set(`${sessionId}\n${targetId}`, at);
470
+ }
471
+ /**
472
+ * Whether a page's close was asked for and is not yet known to have happened.
473
+ * @param sessionId - the session whose browser holds the page.
474
+ * @param targetId - the page's CDP target id.
475
+ */
476
+ function isClosing(sessionId, targetId) {
477
+ return closing.has(`${sessionId}\n${targetId}`);
478
+ }
479
+ /**
480
+ * Drop the asks that have answered.
481
+ *
482
+ * A page the report no longer lists is one the close reached; the rest expire,
483
+ * which is what brings a tab back when a close could not be carried out — the
484
+ * page is still there, so a tab for it is the honest picture.
485
+ * @param listed - every page still reported, as `sessionId` and target id pairs.
486
+ * @param now - the time to expire against.
487
+ */
488
+ function settleClosing(listed, now) {
489
+ const stillThere = new Set([...listed].map(([sessionId, targetId]) => `${sessionId}\n${targetId}`));
490
+ for (const [key, at] of [...closing]) if (!stillThere.has(key) || now - at > CLOSING_TIMEOUT_MS) closing.delete(key);
491
+ }
492
+ /** Page facts by tab address; replaced wholesale only when something changed. */
493
+ let facts = /* @__PURE__ */ new Map();
494
+ const listeners = /* @__PURE__ */ new Set();
495
+ /**
496
+ * Replace the page facts the report produced.
497
+ *
498
+ * Unchanged entries keep their object, so a reader that compares references
499
+ * (React's snapshot contract) sees no change when nothing changed — a poll
500
+ * that learned nothing must not re-render a chip.
501
+ * @param entries - one address and its facts per page the host reported.
502
+ */
503
+ function publishPageFacts(entries) {
504
+ const next = /* @__PURE__ */ new Map();
505
+ let changed = false;
506
+ for (const [address, page] of entries) {
507
+ const previous = facts.get(address);
508
+ const same = previous !== void 0 && previous.title === page.title && previous.url === page.url;
509
+ if (!same) changed = true;
510
+ next.set(address, same ? previous : page);
511
+ }
512
+ if (next.size !== facts.size) changed = true;
513
+ if (!changed) return;
514
+ facts = next;
515
+ for (const listener of [...listeners]) listener();
516
+ }
517
+ /**
518
+ * The facts of one page's tab.
519
+ * @param address - the tab's content id.
520
+ * @returns the facts, or `undefined` before the first report named that page.
521
+ */
522
+ function pageFactsOf(address) {
523
+ return facts.get(address);
524
+ }
525
+ /**
526
+ * Observe page facts.
527
+ * @param listener - called when a fact changed.
528
+ * @returns unsubscribe callback.
529
+ */
530
+ function subscribePageFacts(listener) {
531
+ listeners.add(listener);
532
+ return () => {
533
+ listeners.delete(listener);
534
+ };
535
+ }
536
+ //#endregion
537
+ //#region src/client/stream-url.ts
538
+ /**
539
+ * Origin of the Host this page belongs to.
540
+ * @returns the shell-published origin, or the document's own when it names none.
541
+ */
542
+ function hostOrigin() {
543
+ const base = globalThis.__DSH_TRANSPORT__?.streamBaseUrl;
544
+ return base === void 0 || base === "" ? location.origin : base;
545
+ }
546
+ /**
547
+ * Absolute address of one of the Host's WebSocket routes.
548
+ * @param path - Host-absolute path, e.g. `/dsh-browser/stream`.
549
+ * @param query - parameters to carry in the handshake.
550
+ * @returns the `ws:`/`wss:` address to dial.
551
+ */
552
+ function streamUrl(path, query) {
553
+ const url = new URL(path, hostOrigin());
554
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
555
+ for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
556
+ return url.href;
557
+ }
558
+ //#endregion
559
+ //#region src/client/locales.ts
560
+ /**
561
+ * Copy for the viewer, in both shipped languages.
562
+ *
563
+ * Client UI text is locale-owned, so every string the viewer renders comes from
564
+ * here rather than from the component body.
565
+ */
566
+ /** This plugin's copy namespace. */
567
+ const NS = "dshBrowser";
568
+ /** English dictionary; the key set is the contract. */
569
+ const en = {
570
+ title: "Browser",
571
+ guideTitle: "Browser",
572
+ guideDescription: "Mirror a real local browser, drive it, and attach DevTools to the same port.",
573
+ connecting: "Connecting to the mirror…",
574
+ idle: "The browser is not running yet.",
575
+ starting: "Starting the browser…",
576
+ failed: "The browser could not start.",
577
+ closed: "The browser has stopped.",
578
+ address: "Address",
579
+ secure: "Secure connection (https)",
580
+ insecure: "Not secure (plain http)",
581
+ go: "Go",
582
+ reload: "Reload",
583
+ restart: "Restart browser",
584
+ endpoint: "CDP",
585
+ waiting: "Frames appear here as the page changes.",
586
+ settingsTitle: "Browser",
587
+ settingsIntro: "The local browser this plugin mirrors into the Sidebar and drives for the agent. Every conversation gets its own browser, profile, and CDP port, except in \"Whole workspace\" mode, where the conversations of one project share a process. Changing a value here restarts all of them, and the panes reconnect on their own.",
588
+ settingsAdvanced: "Advanced",
589
+ settingsAdvancedHint: "The browser works without changing any of these.",
590
+ settingsAdvancedOverridden: "{count} changed",
591
+ settingsGroupProcess: "Browser process",
592
+ settingsGroupProcessHint: "Which binary opens, which ports it may listen on, and whether the automation markers stay hidden.",
593
+ settingsGroupMirror: "Mirror and page size",
594
+ settingsGroupMirrorHint: "Headless page size, and the JPEG quality the Sidebar shows.",
595
+ settingsGroupSnapshot: "Agent snapshot",
596
+ settingsGroupSnapshotHint: "What a snapshot prints beside each node's role and name.",
597
+ settingsMode: "Window mode",
598
+ settingsHeadless: "Headless (no window)",
599
+ settingsHeadful: "With a window",
600
+ settingsHeadlessNote: "Headless survives being covered or minimized, but sites that check for automation can tell it apart from a browser you opened yourself.",
601
+ settingsProfileDir: "Profile directory",
602
+ settingsProfileMode: "Sign-ins",
603
+ settingsProfileModeHint: "Who shares a sign-in, and whether it outlives the browser. \"Temporary\" keeps nothing; \"This conversation\" gives each conversation its own sign-ins, still there after a restart; \"Whole workspace\" gives every conversation in one project the same ones, so signing in once carries over — at the cost that a conversation can read another conversation's sign-in for the same sites.",
604
+ settingsProfileModeTemp: "Temporary (deleted on close)",
605
+ settingsProfileModeSession: "This conversation (kept)",
606
+ settingsProfileModeWorkspace: "Whole workspace (shared sign-in)",
607
+ settingsProfileDirHintSession: "Empty puts them under the temporary directory, one subdirectory per conversation. This is a parent directory: each conversation gets its own subdirectory here, so a sign-in in one is not visible in another. It must be a full path; a directory another running browser already holds fails to open.",
608
+ settingsProfileDirHintWorkspace: "Empty puts them in the project's own .dsh\\browser, so the profile follows the project. This is a parent directory: each workspace gets one subdirectory under it, shared by every conversation of that workspace. It must be a full path; naming one takes the profile out of the project.",
609
+ settingsProfileDirPlaceholderSession: "Empty = one per conversation, under the temp directory",
610
+ settingsProfileDirPlaceholderWorkspace: "Empty = .dsh\\browser inside the project",
611
+ settingsBrowser: "Browser",
612
+ settingsChannelChrome: "Google Chrome",
613
+ settingsChannelEdge: "Microsoft Edge",
614
+ settingsExecutable: "Executable path (overrides the browser choice)",
615
+ settingsWidth: "Width",
616
+ settingsHeight: "Height",
617
+ settingsQuality: "Mirror quality (JPEG 1-100)",
618
+ settingsDebugPortMin: "CDP port range — from",
619
+ settingsDebugPortMax: "CDP port range — to",
620
+ settingsDebugPortHint: "Each browser needs its own port, so this is the window those ports come from: the lowest free one goes first, and a browser that finds nothing free inside the window fails rather than listening outside it. One browser per conversation, or one per workspace in \"Whole workspace\" mode.",
621
+ settingsSnapshotAttributes: "Properties printed per node",
622
+ settingsSnapshotAttributesHint: "Comma-separated accessibility properties the snapshot prints beside a role and a name — url, level, placeholder, and so on. The default list is what changes what the agent can do; empty prints none.",
623
+ settingsSnapshotIgnore: "Selectors no snapshot prints",
624
+ settingsSnapshotIgnoreHint: "Comma-separated CSS selectors whose elements, and everything inside them, are left out of a snapshot. A page can exempt itself with data-dsh-browser-ignore; add more here, for example nav, footer.",
625
+ settingsUnavailable: "Settings are unavailable in this deployment; the composed configuration is in force.",
626
+ settingsLoading: "Loading settings…",
627
+ settingsReset: "Reset",
628
+ settingsApplied: "Launch settings restart the browser; snapshot settings apply to the next snapshot.",
629
+ settingsStealth: "Automation markers",
630
+ settingsStealthOn: "Hidden (recommended)",
631
+ settingsStealthOff: "Left visible",
632
+ settingsStealthNote: "Playwright-started browsers report navigator.webdriver and, headless, spell their user agent HeadlessChrome. Sites that check for automation block those: with the markers left visible Google served its \"unusual traffic\" page three times out of three, and none with them hidden.",
633
+ settingsStatusReading: "Reading the browser state…",
634
+ settingsStatusRunning: "{count} session browser(s) running",
635
+ settingsStatusStarting: "{count} session browser(s) starting",
636
+ settingsStatusNone: "No session browser is running — one starts when a conversation opens it.",
637
+ settingsStatusUnknown: "The browser state could not be read: {error}",
638
+ settingsSessionsCount: "Sessions with a browser ({count})",
639
+ settingsSessionUnnamed: "Unnamed session",
640
+ settingsSessionClose: "Close browser",
641
+ settingsSessionClosing: "Closing…",
642
+ settingsSessionCloseFailed: "Could not close: {error}",
643
+ settingsInstanceStateRunning: "running",
644
+ settingsInstanceStateStarting: "starting",
645
+ settingsInstanceStateStopped: "stopped",
646
+ settingsInstanceStateFailed: "failed",
647
+ settingsInstancePages: "{count} page(s)",
648
+ settingsInstanceShared: "shares a browser with {count} conversations",
649
+ settingsRefresh: "Refresh",
650
+ settingsSaved: "Saved. The browser restarts when a launch setting changed.",
651
+ settingsSaveFailed: "Could not save: {error}"
652
+ };
653
+ /** Chinese dictionary. */
654
+ const zh = {
655
+ title: "浏览器",
656
+ guideTitle: "浏览器",
657
+ guideDescription: "镜像本机真实浏览器,可操控,也可用同一端口附加 DevTools。",
658
+ connecting: "正在连接镜像…",
659
+ idle: "浏览器尚未启动。",
660
+ starting: "正在启动浏览器…",
661
+ failed: "浏览器启动失败。",
662
+ closed: "浏览器已停止。",
663
+ address: "地址",
664
+ secure: "安全连接(https)",
665
+ insecure: "不安全(明文 http)",
666
+ go: "前往",
667
+ reload: "刷新",
668
+ restart: "重启浏览器",
669
+ endpoint: "CDP",
670
+ waiting: "页面发生变化时,画面会出现在这里。",
671
+ settingsTitle: "浏览器",
672
+ settingsIntro: "本插件镜像到侧栏、并由 agent 驱动的本机浏览器。每个对话一个浏览器、自己的 profile 和 CDP 端口;「整个工作区」模式下同一项目的对话共用一个进程。在此改动会重启全部浏览器,侧栏里的画面会自行重连。",
673
+ settingsAdvanced: "高级设置",
674
+ settingsAdvancedHint: "不改这里也能正常使用。",
675
+ settingsAdvancedOverridden: "{count} 项已自定义",
676
+ settingsGroupProcess: "浏览器进程",
677
+ settingsGroupProcessHint: "用哪个可执行文件、在哪个端口范围里监听、是否继续隐藏自动化标记。",
678
+ settingsGroupMirror: "镜像与页面尺寸",
679
+ settingsGroupMirrorHint: "无头下的页面尺寸,以及侧栏镜像的 JPEG 画质。",
680
+ settingsGroupSnapshot: "Agent 快照",
681
+ settingsGroupSnapshotHint: "快照在每个节点的 role 与 name 之外还打印什么。",
682
+ settingsMode: "窗口模式",
683
+ settingsHeadless: "无头(无窗口)",
684
+ settingsHeadful: "带窗口",
685
+ settingsHeadlessNote: "无头在窗口被遮挡或最小化时不受影响,但会做自动化检测的站点能把它和你自己打开的浏览器区分开。",
686
+ settingsProfileDir: "Profile 目录",
687
+ settingsProfileMode: "登录态",
688
+ settingsProfileModeHint: "谁共享登录态,以及关掉之后还留不留。「临时」什么都不保留;「本对话」让每个对话各有各的登录态,重启后还在;「整个工作区」让同一项目的所有对话共用一份,于是一处登录处处可用——代价是同一工作区里的对话能读到彼此的登录态。",
689
+ settingsProfileModeTemp: "临时(关闭即删)",
690
+ settingsProfileModeSession: "本对话(关闭后保留)",
691
+ settingsProfileModeWorkspace: "整个工作区(共享登录态)",
692
+ settingsProfileDirHintSession: "留空则放在临时目录下、每个对话一个子目录。这里填的是父目录:每个对话在它下面各有各的子目录,因此一个对话里的登录态不会出现在另一个对话里。必须是完整路径;若该目录已被另一个运行中的浏览器占用,启动会失败。",
693
+ settingsProfileDirHintWorkspace: "留空则放在项目自己的 .dsh\\browser 下,profile 跟着项目走。这里填的是父目录:每个工作区在它下面有一个子目录,同一工作区的全部对话共用它。必须是完整路径;填写后不再落在项目里。",
694
+ settingsProfileDirPlaceholderSession: "留空 = 临时目录下的每对话一份",
695
+ settingsProfileDirPlaceholderWorkspace: "留空 = 项目内的 .dsh\\browser",
696
+ settingsBrowser: "浏览器",
697
+ settingsChannelChrome: "Google Chrome",
698
+ settingsChannelEdge: "Microsoft Edge",
699
+ settingsExecutable: "可执行文件路径(覆盖浏览器选择)",
700
+ settingsWidth: "宽度",
701
+ settingsHeight: "高度",
702
+ settingsQuality: "镜像画质(JPEG 1-100)",
703
+ settingsDebugPortMin: "CDP 端口范围(起)",
704
+ settingsDebugPortMax: "CDP 端口范围(止)",
705
+ settingsDebugPortHint: "每台浏览器各占一个端口,所以这里给的是一个范围:从最小的空闲端口开始分配;范围里没有空端口时直接报错,而不会跑到范围外去监听。浏览器按对话划分,「整个工作区」模式下则按工作区。",
706
+ settingsSnapshotAttributes: "每个节点打印的属性",
707
+ settingsSnapshotAttributesHint: "快照在 role 与 name 之外打印的无障碍属性,用逗号分隔——url、level、placeholder 等。默认这一组决定了 agent 能做什么;留空则一个都不打印。",
708
+ settingsSnapshotIgnore: "快照不打印的选择器",
709
+ settingsSnapshotIgnoreHint: "这些 CSS 选择器匹配到的元素及其内部内容不会进入快照,用逗号分隔。页面自己可以用 data-dsh-browser-ignore 豁免;需要更多就在这里补,例如 nav, footer。",
710
+ settingsUnavailable: "当前部署没有设置服务,使用组合配置。",
711
+ settingsLoading: "正在载入设置…",
712
+ settingsReset: "重置",
713
+ settingsApplied: "启动项会重启浏览器;快照相关设置从下一次快照起生效。",
714
+ settingsStealth: "自动化标记",
715
+ settingsStealthOn: "隐藏(推荐)",
716
+ settingsStealthOff: "保留",
717
+ settingsStealthNote: "Playwright 启动的浏览器会报告 navigator.webdriver,无头时 UA 还写成 HeadlessChrome。做自动化检测的站点会因此拦截:保留标记时 Google 连续三次搜索全部被拦,隐藏后三次全部通过。",
718
+ settingsStatusReading: "正在读取浏览器状态…",
719
+ settingsStatusRunning: "{count} 个会话浏览器在运行",
720
+ settingsStatusStarting: "{count} 个会话浏览器正在启动",
721
+ settingsStatusNone: "当前没有会话浏览器在运行——某个对话用到时才会启动。",
722
+ settingsStatusUnknown: "读不到浏览器状态:{error}",
723
+ settingsSessionsCount: "有浏览器的会话({count})",
724
+ settingsSessionUnnamed: "未命名会话",
725
+ settingsSessionClose: "关闭浏览器",
726
+ settingsSessionClosing: "正在关闭…",
727
+ settingsSessionCloseFailed: "关闭失败:{error}",
728
+ settingsInstanceStateRunning: "运行中",
729
+ settingsInstanceStateStarting: "启动中",
730
+ settingsInstanceStateStopped: "已停止",
731
+ settingsInstanceStateFailed: "启动失败",
732
+ settingsInstancePages: "{count} 个页面",
733
+ settingsInstanceShared: "与 {count} 个对话共用这个浏览器",
734
+ settingsRefresh: "刷新",
735
+ settingsSaved: "已保存。改的是启动项时会重启浏览器。",
736
+ settingsSaveFailed: "保存失败:{error}"
737
+ };
738
+ //#endregion
739
+ //#region src/client/view.tsx
740
+ /**
741
+ * The mirror's viewer: a canvas fed by the plugin's WebSocket, and the few
742
+ * controls that act on the browser behind it.
743
+ *
744
+ * Input leaves as fractions of the frame rather than pixels, so the host never
745
+ * needs to know the viewer's size and resizing the Sidebar cannot shift a
746
+ * click. Which message a keystroke becomes — text, a chord, or nothing — is
747
+ * decided in `keys.ts`, against the same key table the host dispatches from.
748
+ *
749
+ * Styles are inline: this plugin builds its client bundle outside the
750
+ * repository's stylesheet pipeline, so a CSS import would have no owner.
751
+ */
752
+ /** Absolute path the host serves the mirror on. */
753
+ const STREAM_PATH = "/dsh-browser/stream";
754
+ /** Milliseconds between forwarded pointer moves, so a drag does not flood the socket. */
755
+ const MOVE_INTERVAL_MS = 33;
756
+ /** Translate one key, falling back to the bundled dictionary at this dynamic boundary. */
757
+ function copyOf(t) {
758
+ return (key) => t === void 0 ? en[key] : t(key);
759
+ }
760
+ /**
761
+ * Where a pointer event landed, as a fraction of the frame.
762
+ *
763
+ * The canvas fits the frame into its box with `object-fit: contain`, so the
764
+ * drawn image is smaller than the box on one axis and centred in it. Measuring
765
+ * against the box would offset every click wherever the two aspect ratios
766
+ * differ, which is exactly the case for a wide page in a narrow Sidebar.
767
+ * @param element - the canvas the event arrived on.
768
+ * @param clientX - pointer x in viewport coordinates.
769
+ * @param clientY - pointer y in viewport coordinates.
770
+ * @returns the position in `0..1` of the frame, clamped to the frame.
771
+ */
772
+ function fractionOf(element, clientX, clientY) {
773
+ const rect = element.getBoundingClientRect();
774
+ if (rect.width === 0 || rect.height === 0 || element.width === 0 || element.height === 0) return {
775
+ x: 0,
776
+ y: 0
777
+ };
778
+ const scale = Math.min(rect.width / element.width, rect.height / element.height);
779
+ const drawnWidth = element.width * scale;
780
+ const drawnHeight = element.height * scale;
781
+ const x = (clientX - rect.left - (rect.width - drawnWidth) / 2) / drawnWidth;
782
+ const y = (clientY - rect.top - (rect.height - drawnHeight) / 2) / drawnHeight;
783
+ return {
784
+ x: Math.min(1, Math.max(0, x)),
785
+ y: Math.min(1, Math.max(0, y))
786
+ };
787
+ }
788
+ /**
789
+ * Name a DOM button number the way the host's input messages spell it.
790
+ * @param button - the event's button number.
791
+ * @returns the matching button name.
792
+ */
793
+ function buttonOf(button) {
794
+ if (button === 1) return "middle";
795
+ if (button === 2) return "right";
796
+ return "left";
797
+ }
798
+ /**
799
+ * Put text on the system clipboard from the page the user is looking at.
800
+ *
801
+ * The async API is the first choice; a browser that refuses it outside its own
802
+ * idea of a gesture still honours the older hidden-textarea route, which is the
803
+ * fallback VS Code's web clipboard keeps as well. Focus is restored either way:
804
+ * the temporary field is a document-wide side effect, and the user's next
805
+ * keystroke belongs wherever it was going.
806
+ * @param text - the text to place on the clipboard.
807
+ */
808
+ async function writeClipboard(text) {
809
+ try {
810
+ await navigator.clipboard.writeText(text);
811
+ return;
812
+ } catch {}
813
+ const previous = document.activeElement;
814
+ const field = document.createElement("textarea");
815
+ field.setAttribute("aria-hidden", "true");
816
+ field.style.cssText = "position:absolute;left:-9999px;top:0;width:1px;height:1px";
817
+ field.value = text;
818
+ document.body.appendChild(field);
819
+ field.select();
820
+ try {
821
+ document.execCommand("copy");
822
+ } finally {
823
+ field.remove();
824
+ if (previous instanceof HTMLElement) previous.focus();
825
+ }
826
+ }
827
+ /** Height of the address bar, which its pill radius is derived from. */
828
+ const OMNIBOX_HEIGHT = 30;
829
+ /**
830
+ * The design tokens the address bar borrows, so it matches the app in either
831
+ * theme. `--dsw-alias-*` are the theme's own aliases: a layer for the field, a
832
+ * border for its edge, and label colours for the text and the icons.
833
+ */
834
+ const style = {
835
+ root: {
836
+ boxSizing: "border-box",
837
+ display: "flex",
838
+ flexDirection: "column",
839
+ height: "100%",
840
+ minHeight: 0,
841
+ gap: "6px",
842
+ padding: "6px"
843
+ },
844
+ omnibox: {
845
+ boxSizing: "border-box",
846
+ display: "flex",
847
+ alignItems: "center",
848
+ gap: "2px",
849
+ flex: "0 0 auto",
850
+ height: `${String(OMNIBOX_HEIGHT)}px`,
851
+ padding: "0 3px 0 9px",
852
+ borderRadius: `${String(OMNIBOX_HEIGHT / 2)}px`,
853
+ background: "var(--dsw-alias-bg-layer-3)",
854
+ border: "0.5px solid var(--dsw-alias-border-l4)"
855
+ },
856
+ omniboxFocused: { borderColor: "var(--dsw-alias-state-business-primary)" },
857
+ scheme: {
858
+ display: "flex",
859
+ alignItems: "center",
860
+ flex: "0 0 auto",
861
+ color: "var(--dsw-alias-label-tertiary)"
862
+ },
863
+ schemeInsecure: { color: "var(--dsw-alias-state-warn-label)" },
864
+ address: {
865
+ flex: "1 1 auto",
866
+ minWidth: 0,
867
+ height: "100%",
868
+ padding: "0 4px",
869
+ border: "none",
870
+ outline: "none",
871
+ background: "transparent",
872
+ color: "var(--dsw-alias-label-primary)",
873
+ font: "inherit",
874
+ fontSize: "12px"
875
+ },
876
+ iconButton: {
877
+ display: "flex",
878
+ alignItems: "center",
879
+ justifyContent: "center",
880
+ flex: "0 0 auto",
881
+ width: `${String(24)}px`,
882
+ height: `${String(24)}px`,
883
+ padding: 0,
884
+ border: "none",
885
+ borderRadius: "50%",
886
+ cursor: "pointer",
887
+ background: "transparent",
888
+ color: "var(--dsw-alias-label-secondary)"
889
+ },
890
+ iconButtonHovered: {
891
+ background: "var(--dsw-alias-interactive-bg-hover)",
892
+ color: "var(--dsw-alias-label-primary)"
893
+ },
894
+ stage: {
895
+ position: "relative",
896
+ flex: "1 1 auto",
897
+ minHeight: 0,
898
+ overflow: "hidden",
899
+ background: "#101418"
900
+ },
901
+ canvas: {
902
+ display: "block",
903
+ width: "100%",
904
+ height: "100%",
905
+ objectFit: "contain",
906
+ outline: "none"
907
+ },
908
+ pasteSink: {
909
+ position: "absolute",
910
+ left: "-9999px",
911
+ top: 0,
912
+ width: "1px",
913
+ height: "1px",
914
+ padding: 0,
915
+ border: "none",
916
+ opacity: 0,
917
+ resize: "none"
918
+ },
919
+ note: {
920
+ position: "absolute",
921
+ inset: 0,
922
+ display: "flex",
923
+ flexDirection: "column",
924
+ gap: "8px",
925
+ alignItems: "center",
926
+ justifyContent: "center",
927
+ textAlign: "center",
928
+ padding: "12px",
929
+ fontSize: "12px",
930
+ color: "#93a1b0",
931
+ pointerEvents: "none"
932
+ },
933
+ noteCard: {
934
+ display: "flex",
935
+ flexDirection: "column",
936
+ gap: "8px",
937
+ alignItems: "center",
938
+ maxWidth: "90%",
939
+ padding: "10px 14px",
940
+ borderRadius: "8px",
941
+ background: "rgba(16, 20, 24, 0.88)",
942
+ color: "#d7dee6",
943
+ lineHeight: 1.5
944
+ },
945
+ noteButton: {
946
+ padding: "4px 8px",
947
+ fontSize: "12px",
948
+ cursor: "pointer",
949
+ pointerEvents: "auto",
950
+ background: "var(--dsw-alias-button-primary-fill)",
951
+ color: "var(--dsw-alias-label-primary-foreground)",
952
+ border: "none",
953
+ borderRadius: "var(--dsw-radius-sm)"
954
+ },
955
+ status: {
956
+ flex: "0 0 auto",
957
+ display: "flex",
958
+ gap: "6px",
959
+ alignItems: "center",
960
+ fontSize: "11px",
961
+ color: "var(--dsw-alias-label-tertiary)"
962
+ },
963
+ statusText: {
964
+ flex: "1 1 auto",
965
+ minWidth: 0,
966
+ overflow: "hidden",
967
+ textOverflow: "ellipsis",
968
+ whiteSpace: "nowrap"
969
+ },
970
+ statusButton: {
971
+ flex: "0 0 auto",
972
+ padding: "2px 8px",
973
+ fontSize: "11px",
974
+ cursor: "pointer",
975
+ borderRadius: "var(--dsw-radius-sm)",
976
+ border: "0.5px solid var(--dsw-alias-border-l2)",
977
+ background: "transparent",
978
+ color: "var(--dsw-alias-label-secondary)"
979
+ }
980
+ };
981
+ /** The lock a secure address shows, drawn at the size the bar's icons share. */
982
+ const LOCK_ICON = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
983
+ x: "3.5",
984
+ y: "7",
985
+ width: "9",
986
+ height: "6.5",
987
+ rx: "2"
988
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5.75 7V5.25a2.25 2.25 0 0 1 4.5 0V7" })] });
989
+ /** The circular arrow a page that needs no navigation shows. */
990
+ const RELOAD_ICON = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12.75 8a4.75 4.75 0 1 1-1.4-3.36" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12.9 2.6v3.1h-3.1" })] });
991
+ /** The arrow an edited address shows, which submits it. */
992
+ const GO_ICON = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 8h9.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9 4.5 12.5 8 9 11.5" })] });
993
+ /**
994
+ * One icon control inside the address bar.
995
+ *
996
+ * Hover is tracked in state rather than by a stylesheet: this plugin's client
997
+ * bundle has no stylesheet of its own, so every appearance here is an inline
998
+ * style, and a pseudo-class is not one.
999
+ * @param props - the icon, its accessible name, and what it does.
1000
+ * @returns the button.
1001
+ */
1002
+ function IconButton(props) {
1003
+ const [hovered, setHovered] = (0, react.useState)(false);
1004
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1005
+ type: props.submit === true ? "submit" : "button",
1006
+ "aria-label": props.label,
1007
+ title: props.label,
1008
+ onClick: props.onClick,
1009
+ onMouseEnter: () => {
1010
+ setHovered(true);
1011
+ },
1012
+ onMouseLeave: () => {
1013
+ setHovered(false);
1014
+ },
1015
+ style: {
1016
+ ...style.iconButton,
1017
+ ...hovered ? style.iconButtonHovered : {}
1018
+ },
1019
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1020
+ width: "14",
1021
+ height: "14",
1022
+ viewBox: "0 0 16 16",
1023
+ fill: "none",
1024
+ stroke: "currentColor",
1025
+ strokeWidth: "1.4",
1026
+ strokeLinecap: "round",
1027
+ strokeLinejoin: "round",
1028
+ "aria-hidden": "true",
1029
+ children: props.icon
1030
+ })
1031
+ });
1032
+ }
1033
+ /**
1034
+ * The mirror's body: canvas, address bar, and the browser's own status.
1035
+ * @param props - composed slot props.
1036
+ * @returns the viewer.
1037
+ */
1038
+ function BrowserBody({ sessionId, t, useTabInfo }) {
1039
+ const copy = copyOf(t);
1040
+ const tab = useTabInfo?.();
1041
+ const pageId = tab === void 0 ? void 0 : targetIdOf(tab.tab.contentId);
1042
+ /**
1043
+ * The tab's own id, which is what a pane with no page asks under: the ask is
1044
+ * the tab's, so the same tab asking again — remounted by a tab switch, a
1045
+ * Session change, or a client reload — opens nothing new.
1046
+ */
1047
+ const tabId = tab === void 0 ? void 0 : tab.tab.id;
1048
+ const canvasRef = (0, react.useRef)(null);
1049
+ const socketRef = (0, react.useRef)(void 0);
1050
+ const lastMoveRef = (0, react.useRef)(0);
1051
+ const pasteSinkRef = (0, react.useRef)(null);
1052
+ /**
1053
+ * What to do with the selection the mirror is about to report.
1054
+ *
1055
+ * The copy is armed here, on the keystroke, and spent when the answer arrives:
1056
+ * the answer is a round trip away, and the chord is what says whether there is
1057
+ * anything to write at all.
1058
+ */
1059
+ const clipboardRef = (0, react.useRef)(void 0);
1060
+ const [address, setAddress] = (0, react.useState)("");
1061
+ const [status, setStatus] = (0, react.useState)(void 0);
1062
+ const [connected, setConnected] = (0, react.useState)(false);
1063
+ const [painted, setPainted] = (0, react.useState)(false);
1064
+ const [failure, setFailure] = (0, react.useState)(void 0);
1065
+ const [focused, setFocused] = (0, react.useState)(false);
1066
+ /**
1067
+ * Draw one received frame.
1068
+ * @param buffer - the JPEG bytes of the frame.
1069
+ */
1070
+ const drawFrame = (0, react.useCallback)(async (buffer) => {
1071
+ const canvas = canvasRef.current;
1072
+ if (canvas === null) return;
1073
+ const bitmap = await createImageBitmap(new Blob([buffer], { type: "image/jpeg" }));
1074
+ if (canvas.width !== bitmap.width) canvas.width = bitmap.width;
1075
+ if (canvas.height !== bitmap.height) canvas.height = bitmap.height;
1076
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
1077
+ bitmap.close();
1078
+ setPainted(true);
1079
+ }, []);
1080
+ (0, react.useEffect)(() => {
1081
+ const query = { session: sessionId };
1082
+ if (pageId !== void 0) query.page = pageId;
1083
+ let cancelled = false;
1084
+ let socket;
1085
+ (pageId !== void 0 || tabId === void 0 ? Promise.resolve() : openPageRequest(sessionId, tabId).then(() => void 0, () => void 0)).then(() => {
1086
+ if (cancelled) return;
1087
+ const opened = new WebSocket(streamUrl(STREAM_PATH, query));
1088
+ socket = opened;
1089
+ socketRef.current = opened;
1090
+ opened.binaryType = "arraybuffer";
1091
+ opened.onopen = () => {
1092
+ setConnected(true);
1093
+ };
1094
+ opened.onclose = () => {
1095
+ setConnected(false);
1096
+ };
1097
+ opened.onerror = () => {
1098
+ setConnected(false);
1099
+ };
1100
+ opened.onmessage = (event) => {
1101
+ if (typeof event.data !== "string") {
1102
+ drawFrame(event.data);
1103
+ return;
1104
+ }
1105
+ const parsed = JSON.parse(event.data);
1106
+ if (parsed.type === "status" && parsed.status !== void 0) {
1107
+ setStatus(parsed.status);
1108
+ setFailure(void 0);
1109
+ return;
1110
+ }
1111
+ if (parsed.type === "error") setFailure(String(parsed.message));
1112
+ if (parsed.type === "clipboard") {
1113
+ const settle = clipboardRef.current;
1114
+ clipboardRef.current = void 0;
1115
+ settle?.(String(parsed.text ?? ""));
1116
+ }
1117
+ };
1118
+ });
1119
+ return () => {
1120
+ cancelled = true;
1121
+ socketRef.current = void 0;
1122
+ socket?.close();
1123
+ };
1124
+ }, [
1125
+ drawFrame,
1126
+ sessionId,
1127
+ pageId,
1128
+ tabId
1129
+ ]);
1130
+ const browserUrl = status?.url;
1131
+ (0, react.useEffect)(() => {
1132
+ if (browserUrl !== void 0 && browserUrl !== "" && browserUrl !== "about:blank") setAddress(browserUrl);
1133
+ }, [browserUrl]);
1134
+ /**
1135
+ * Send one viewer message.
1136
+ * @param payload - the message to encode.
1137
+ */
1138
+ const send = (0, react.useCallback)((payload) => {
1139
+ const socket = socketRef.current;
1140
+ if (socket !== void 0 && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload));
1141
+ }, []);
1142
+ /**
1143
+ * Send one input message.
1144
+ * @param message - the host's input message.
1145
+ */
1146
+ const sendInput = (0, react.useCallback)((message) => {
1147
+ send({
1148
+ type: "input",
1149
+ message
1150
+ });
1151
+ }, [send]);
1152
+ /**
1153
+ * Ask the host for a browser this pane can use again.
1154
+ *
1155
+ * The socket carries the ask when it is up, because that is the connection the
1156
+ * host already holds for this viewer. It is not up in the one case the button
1157
+ * exists for: watching never starts a browser, so a session whose browser was
1158
+ * closed from the settings page has no socket at all — the host refused the
1159
+ * subscription and closed it. Sending there would be dropped in silence by
1160
+ * {@link send}, which is what made this button do nothing exactly when it was
1161
+ * needed. The HTTP ask is the path that does start a browser, so it is the
1162
+ * fallback: same page ask the entry makes, under the same tab id, which keeps
1163
+ * a second click from opening a second page.
1164
+ */
1165
+ const restart = (0, react.useCallback)(() => {
1166
+ const socket = socketRef.current;
1167
+ const route = restartRoute(socket !== void 0 && socket.readyState === WebSocket.OPEN, tabId);
1168
+ if (route.via === "socket") {
1169
+ send({ type: "restart" });
1170
+ return;
1171
+ }
1172
+ if (route.via === "http") openPageRequest(sessionId, tabId).catch(() => {});
1173
+ }, [
1174
+ send,
1175
+ sessionId,
1176
+ tabId
1177
+ ]);
1178
+ /**
1179
+ * Arm the clipboard for the chord just pressed.
1180
+ *
1181
+ * A copy with nothing selected must leave the clipboard alone, and only the
1182
+ * mirror knows what is selected, so the write waits for its answer:
1183
+ * {@link clipboardReply} is the decision, and this is only where it lands.
1184
+ * @param chord - the reading chord that was pressed.
1185
+ */
1186
+ const armClipboard = (0, react.useCallback)((chord) => {
1187
+ clipboardRef.current = (text) => {
1188
+ const reply = clipboardReply(chord, text);
1189
+ if (reply.write !== void 0) writeClipboard(reply.write);
1190
+ if (reply.after !== void 0) sendInput(reply.after);
1191
+ };
1192
+ }, [sendInput]);
1193
+ /**
1194
+ * Navigate to what the address bar holds.
1195
+ *
1196
+ * Both Enter and the arrow button land here, because an implicit form
1197
+ * submission is not the only way this pane is asked to navigate and the two
1198
+ * must not drift apart.
1199
+ */
1200
+ const submit = (0, react.useCallback)(() => {
1201
+ const target = address.trim();
1202
+ if (target !== "") send({
1203
+ type: "navigate",
1204
+ url: target
1205
+ });
1206
+ }, [address, send]);
1207
+ const state = status?.state;
1208
+ const shownUrl = status?.url ?? "";
1209
+ /** Whether the address bar holds something the browser is not on. */
1210
+ const dirty = address.trim() !== "" && address !== shownUrl;
1211
+ const secure = shownUrl.startsWith("https://");
1212
+ const note = failure !== void 0 ? failure : !connected ? copy("connecting") : painted && state !== "failed" && state !== "closed" ? void 0 : state === "failed" ? `${copy("failed")} ${status?.error ?? ""}`.trim() : state === "starting" ? copy("starting") : state === "ready" ? copy("waiting") : state === "closed" ? `${copy("closed")} ${status?.error ?? ""}`.trim() : copy("idle");
1213
+ const recoverable = failure !== void 0 || state === "failed" || state === "closed";
1214
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1215
+ style: style.root,
1216
+ children: [
1217
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1218
+ style: {
1219
+ ...style.omnibox,
1220
+ ...focused ? style.omniboxFocused : {}
1221
+ },
1222
+ onSubmit: (event) => {
1223
+ event.preventDefault();
1224
+ submit();
1225
+ },
1226
+ children: [
1227
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1228
+ style: {
1229
+ ...style.scheme,
1230
+ ...secure ? {} : style.schemeInsecure
1231
+ },
1232
+ title: secure ? copy("secure") : copy("insecure"),
1233
+ children: secure ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1234
+ width: "14",
1235
+ height: "14",
1236
+ viewBox: "0 0 16 16",
1237
+ fill: "none",
1238
+ stroke: "currentColor",
1239
+ strokeWidth: "1.3",
1240
+ strokeLinecap: "round",
1241
+ strokeLinejoin: "round",
1242
+ "aria-hidden": "true",
1243
+ children: LOCK_ICON
1244
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconGlobeOutlineMedium, { size: 14 })
1245
+ }),
1246
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1247
+ style: style.address,
1248
+ value: address,
1249
+ placeholder: copy("address"),
1250
+ spellCheck: false,
1251
+ autoComplete: "off",
1252
+ "aria-label": copy("address"),
1253
+ onFocus: (event) => {
1254
+ setFocused(true);
1255
+ event.currentTarget.select();
1256
+ },
1257
+ onBlur: () => {
1258
+ setFocused(false);
1259
+ },
1260
+ onChange: (event) => {
1261
+ setAddress(event.target.value);
1262
+ },
1263
+ onKeyDown: (event) => {
1264
+ if (event.key === "Enter") {
1265
+ event.preventDefault();
1266
+ submit();
1267
+ }
1268
+ }
1269
+ }),
1270
+ dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
1271
+ label: copy("go"),
1272
+ icon: GO_ICON,
1273
+ onClick: submit,
1274
+ submit: true
1275
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
1276
+ label: copy("reload"),
1277
+ icon: RELOAD_ICON,
1278
+ onClick: () => {
1279
+ send({ type: "reload" });
1280
+ }
1281
+ })
1282
+ ]
1283
+ }),
1284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1285
+ style: style.stage,
1286
+ children: [
1287
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
1288
+ ref: canvasRef,
1289
+ tabIndex: 0,
1290
+ style: style.canvas,
1291
+ onContextMenu: (event) => {
1292
+ event.preventDefault();
1293
+ },
1294
+ onMouseDown: (event) => {
1295
+ const at = fractionOf(event.currentTarget, event.clientX, event.clientY);
1296
+ sendInput({
1297
+ type: "mouse",
1298
+ action: "down",
1299
+ x: at.x,
1300
+ y: at.y,
1301
+ button: buttonOf(event.button),
1302
+ clickCount: 1
1303
+ });
1304
+ },
1305
+ onMouseUp: (event) => {
1306
+ const at = fractionOf(event.currentTarget, event.clientX, event.clientY);
1307
+ sendInput({
1308
+ type: "mouse",
1309
+ action: "up",
1310
+ x: at.x,
1311
+ y: at.y,
1312
+ button: buttonOf(event.button),
1313
+ clickCount: 1
1314
+ });
1315
+ },
1316
+ onMouseMove: (event) => {
1317
+ const now = Date.now();
1318
+ if (now - lastMoveRef.current < MOVE_INTERVAL_MS) return;
1319
+ lastMoveRef.current = now;
1320
+ const at = fractionOf(event.currentTarget, event.clientX, event.clientY);
1321
+ sendInput({
1322
+ type: "mouse",
1323
+ action: "move",
1324
+ x: at.x,
1325
+ y: at.y
1326
+ });
1327
+ },
1328
+ onWheel: (event) => {
1329
+ const at = fractionOf(event.currentTarget, event.clientX, event.clientY);
1330
+ sendInput({
1331
+ type: "wheel",
1332
+ x: at.x,
1333
+ y: at.y,
1334
+ deltaX: event.deltaX,
1335
+ deltaY: event.deltaY
1336
+ });
1337
+ },
1338
+ onKeyDown: (event) => {
1339
+ const chord = clipboardChord(event);
1340
+ if (chord === "paste") {
1341
+ pasteSinkRef.current?.focus();
1342
+ return;
1343
+ }
1344
+ if (chord !== void 0) {
1345
+ event.preventDefault();
1346
+ armClipboard(chord);
1347
+ send({ type: "selection" });
1348
+ return;
1349
+ }
1350
+ const message = keyMessage(event);
1351
+ if (message === void 0) return;
1352
+ sendInput(message);
1353
+ event.preventDefault();
1354
+ }
1355
+ }),
1356
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1357
+ ref: pasteSinkRef,
1358
+ "aria-hidden": "true",
1359
+ tabIndex: -1,
1360
+ value: "",
1361
+ style: style.pasteSink,
1362
+ onChange: () => {},
1363
+ onPaste: (event) => {
1364
+ const text = event.clipboardData.getData("text");
1365
+ event.preventDefault();
1366
+ canvasRef.current?.focus();
1367
+ const message = pasteMessage(text);
1368
+ if (message !== void 0) sendInput(message);
1369
+ }
1370
+ }),
1371
+ note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1372
+ style: style.note,
1373
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1374
+ style: style.noteCard,
1375
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: note }), recoverable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1376
+ type: "button",
1377
+ style: style.noteButton,
1378
+ onClick: () => {
1379
+ restart();
1380
+ },
1381
+ children: copy("restart")
1382
+ }) : null]
1383
+ })
1384
+ })
1385
+ ]
1386
+ }),
1387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1388
+ style: style.status,
1389
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1390
+ style: style.statusText,
1391
+ children: status?.debugPort === void 0 ? "" : `${copy("endpoint")} 127.0.0.1:${status.debugPort}`
1392
+ })
1393
+ })
1394
+ ]
1395
+ });
1396
+ }
1397
+ //#endregion
1398
+ //#region src/client/chip.ts
1399
+ /**
1400
+ * How the browser's glyph is placed inside a chip title.
1401
+ *
1402
+ * The chip's title row (`[data-dockkit-tab-title]`) is already
1403
+ * `display: flex; gap: 5px; align-items: center`, and it reaches registrants
1404
+ * through a `display: contents` slot wrapper — so the harness's own chips hand
1405
+ * it a bare `<svg>`: a flex item, blockified, centred on the row's line, one
1406
+ * 5px gap from the label.
1407
+ *
1408
+ * This bundle has no stylesheet of its own, so the wrapper below is where that
1409
+ * has to be said instead. It exists for `flex: 0 0 auto` (without it a long
1410
+ * label shrinks the globe) and must add nothing else. Two measured ways of
1411
+ * getting it wrong, both seen on 2026-09-24 with the pane open in the GUI:
1412
+ *
1413
+ * - `display: inline-block` wrapping the icon leaves the *svg* inline, sitting
1414
+ * on the wrapper's first baseline while the row centres the wrapper — an
1415
+ * 18.2px line box for a 14px glyph, 2.1px above the label's centre.
1416
+ * - `margin-right` here doubles the row's gap: 10px from the label against the
1417
+ * sibling chips' 5px.
1418
+ *
1419
+ * `verticalAlign` is not part of it either: its wrapper is a block-level flex
1420
+ * item, where the property has no effect (which is why `-3px` never moved the
1421
+ * glyph that looked high).
1422
+ */
1423
+ const CHIP_GLYPH = {
1424
+ display: "flex",
1425
+ alignItems: "center",
1426
+ flex: "0 0 auto"
1427
+ };
1428
+ //#endregion
1429
+ //#region src/client/title.tsx
1430
+ /**
1431
+ * The browser's chip title: the globe before the tab's own label.
1432
+ *
1433
+ * Registered under `sidebar.right.pane.tab.title`; without a registrant the chip
1434
+ * falls back to the registry's captured title text, which is how the browser
1435
+ * used to be the only tab in the strip with no glyph of its own.
1436
+ *
1437
+ * The label is the page's own, live: a tab names a page, and what makes the
1438
+ * tabs tell each other apart is the page renaming itself, not the moment the
1439
+ * tab was opened. The facts come from the loop that follows the host's report
1440
+ * (`pages.ts`); until they name this tab's page the captured text stands in.
1441
+ */
1442
+ /**
1443
+ * The title as the chip and a floating panel's header show it.
1444
+ * @param props - the tab information hook.
1445
+ * @returns the globe followed by the tab's title text.
1446
+ */
1447
+ function BrowserTitle({ useTabInfo }) {
1448
+ const { tab } = useTabInfo();
1449
+ const facts = (0, react.useSyncExternalStore)(subscribePageFacts, () => pageFactsOf(tab.contentId));
1450
+ const label = facts !== void 0 && facts.title !== "" ? facts.title : tab.title;
1451
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1452
+ style: CHIP_GLYPH,
1453
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconGlobeOutlineRegular, { size: 16 })
1454
+ }), label] });
1455
+ }
1456
+ //#endregion
1457
+ //#region src/client/definition.ts
1458
+ /** The tab kind this plugin owns. */
1459
+ const BROWSER_KIND = "cdpBrowser";
1460
+ /** This implementation's identity: the key its body and title register under. */
1461
+ const BROWSER_ID = "dsh-browser";
1462
+ /** The address glob a page tab matches: the page scheme, anything after it. */
1463
+ const PAGE_PATTERN = `${PAGE_PREFIX}**`;
1464
+ /**
1465
+ * The mirror's registry definition.
1466
+ *
1467
+ * The guide entry carries the glyph the caller hands in: without one the guide
1468
+ * draws its cube placeholder, which says nothing about what picking the entry
1469
+ * opens. The chip draws the same glyph through this module's own title
1470
+ * registrant, so the two places the user meets the browser agree on what it
1471
+ * looks like.
1472
+ * @param title - the tab chip's text in the current language.
1473
+ * @param guideTitle - the guide entry's title in the current language.
1474
+ * @param guideDescription - the guide entry's one-line description.
1475
+ * @param icon - the browser's glyph, supplied by the caller so this module stays
1476
+ * loadable outside the bundle (see `glyph.ts`).
1477
+ * @returns the definition to register.
1478
+ */
1479
+ function browserDefinition(title, guideTitle, guideDescription, icon) {
1480
+ return {
1481
+ id: BROWSER_ID,
1482
+ kind: BROWSER_KIND,
1483
+ multiple: true,
1484
+ priority: "extension",
1485
+ title,
1486
+ patterns: [PAGE_PATTERN],
1487
+ canOpen: (address) => address.startsWith(PAGE_PREFIX),
1488
+ guide: [{
1489
+ id: BROWSER_ID,
1490
+ order: 40,
1491
+ title: guideTitle,
1492
+ description: guideDescription,
1493
+ icon
1494
+ }]
1495
+ };
1496
+ }
1497
+ //#endregion
1498
+ //#region src/client/reveal.ts
1499
+ /** How long the loop waits between looks at the host's browser state. */
1500
+ const POLL_MS = 1500;
1501
+ /**
1502
+ * Read the host's report once and bring every named session's tabs in line
1503
+ * with its pages.
1504
+ * @param ctx - client context carrying the Sidebar's navigation face.
1505
+ * @param report - what the host last answered.
1506
+ */
1507
+ function followOneReport(ctx, report) {
1508
+ const facts = [];
1509
+ /** The pages each session holds, by session id. */
1510
+ const wanted = /* @__PURE__ */ new Map();
1511
+ for (const instance of report.instances) {
1512
+ const addresses = [];
1513
+ for (const tab of instance.tabs ?? []) {
1514
+ if (tab.targetId === void 0) continue;
1515
+ const address = pageAddressOf(tab.targetId);
1516
+ addresses.push(address);
1517
+ facts.push([address, {
1518
+ title: tab.title ?? "",
1519
+ url: tab.url
1520
+ }]);
1521
+ }
1522
+ wanted.set(instance.sessionId, addresses);
1523
+ }
1524
+ publishPageFacts(facts);
1525
+ const listed = [];
1526
+ for (const instance of report.instances) for (const tab of instance.tabs ?? []) if (tab.targetId !== void 0) listed.push([instance.sessionId, tab.targetId]);
1527
+ settleClosing(listed, Date.now());
1528
+ for (const [sessionId, addresses] of wanted) {
1529
+ const plan = reconcile(addresses, ctx.sidebarRight.openTabs.getSnapshot().filter((tab) => tab.kind === "cdpBrowser" && tab.sessionId === sessionId).map((tab) => ({
1530
+ id: tab.tabId,
1531
+ contentId: tab.contentId
1532
+ })));
1533
+ for (const tabId of plan.close) ctx.sidebarRight.closeIn(sessionId, tabId);
1534
+ for (const address of plan.open) {
1535
+ const targetId = targetIdOf(address);
1536
+ if (targetId === void 0 || isClosing(sessionId, targetId)) continue;
1537
+ ctx.sidebarRight.openResourceIn(sessionId, address);
1538
+ }
1539
+ }
1540
+ }
1541
+ /**
1542
+ * Follow the host's browser state for as long as the plugin is loaded, and keep
1543
+ * every session's browser tabs matched to its pages.
1544
+ * @param ctx - client context carrying the Sidebar's navigation face.
1545
+ */
1546
+ function followBrowserPages(ctx) {
1547
+ ctx.effect(() => {
1548
+ let timer;
1549
+ let looking = false;
1550
+ let stopped = false;
1551
+ /** Read the host's state once, and reconcile every session it named. */
1552
+ const look = async () => {
1553
+ if (looking || stopped) return;
1554
+ looking = true;
1555
+ try {
1556
+ const report = await browserStatus();
1557
+ if (stopped) return;
1558
+ followOneReport(ctx, report);
1559
+ } catch {} finally {
1560
+ looking = false;
1561
+ }
1562
+ };
1563
+ const follow = () => {
1564
+ if (timer !== void 0) clearInterval(timer);
1565
+ timer = setInterval(() => {
1566
+ look();
1567
+ }, POLL_MS);
1568
+ look();
1569
+ };
1570
+ follow();
1571
+ return () => {
1572
+ stopped = true;
1573
+ if (timer !== void 0) clearInterval(timer);
1574
+ };
1575
+ }, "dsh-browser: keep sidebar tabs matched to the browser pages");
1576
+ }
1577
+ //#endregion
1578
+ //#region src/client/close.ts
1579
+ /**
1580
+ * Register the close handler for the browser's tabs.
1581
+ *
1582
+ * The registration is scoped to the plugin's fiber: `registerCloseHandler`
1583
+ * returns the disposer that takes the entry out of the Sidebar's map, and a
1584
+ * resident controller's map outlives a plugin that is applied again in the same
1585
+ * page. Dropping that disposer makes the second apply throw "close handler
1586
+ * already registered", which leaves the client half dead for that page until a
1587
+ * full reload — every other registration in `index.ts` is effect-scoped for
1588
+ * exactly this reason.
1589
+ * @param ctx - client context carrying the Sidebar's registry.
1590
+ */
1591
+ function followPageCloses(ctx) {
1592
+ ctx.effect(() => ctx.sidebarRight.registerCloseHandler(BROWSER_KIND, (sessionId, tab) => {
1593
+ const targetId = targetIdOf(tab.contentId);
1594
+ if (targetId === void 0) return;
1595
+ markClosing(sessionId, targetId);
1596
+ closePageRequest(sessionId, targetId).catch(() => {});
1597
+ }), "dsh-browser: close handler");
1598
+ }
1599
+ //#endregion
1600
+ //#region src/client/settings-layout.ts
1601
+ /**
1602
+ * The blocks inside the fold, in page order.
1603
+ *
1604
+ * This list is the page's structure, not a hint: a group missing from it would
1605
+ * have its fields placed but never rendered, which the tests reject. A group is
1606
+ * folded as a whole — splitting one would leave a heading with nothing under it
1607
+ * in the visible tier.
1608
+ */
1609
+ const ADVANCED_GROUPS = [
1610
+ "process",
1611
+ "mirror",
1612
+ "snapshot"
1613
+ ];
1614
+ /**
1615
+ * Every editable field, in the order the page renders it.
1616
+ *
1617
+ * The set is exactly the volatile half of the configuration schema — a field the
1618
+ * loader lets the settings page write has to appear here, and
1619
+ * `test/settings-layout.test.ts` fails when the two lists drift apart.
1620
+ */
1621
+ const FIELD_PLACEMENTS = [
1622
+ {
1623
+ field: "headless",
1624
+ group: "everyday"
1625
+ },
1626
+ {
1627
+ field: "channel",
1628
+ group: "everyday"
1629
+ },
1630
+ {
1631
+ field: "profileMode",
1632
+ group: "everyday"
1633
+ },
1634
+ {
1635
+ field: "userDataDir",
1636
+ group: "everyday"
1637
+ },
1638
+ {
1639
+ field: "stealth",
1640
+ group: "process"
1641
+ },
1642
+ {
1643
+ field: "executablePath",
1644
+ group: "process"
1645
+ },
1646
+ {
1647
+ field: "debugPortMin",
1648
+ group: "process"
1649
+ },
1650
+ {
1651
+ field: "debugPortMax",
1652
+ group: "process"
1653
+ },
1654
+ {
1655
+ field: "viewportWidth",
1656
+ group: "mirror"
1657
+ },
1658
+ {
1659
+ field: "viewportHeight",
1660
+ group: "mirror"
1661
+ },
1662
+ {
1663
+ field: "quality",
1664
+ group: "mirror"
1665
+ },
1666
+ {
1667
+ field: "snapshotAttributes",
1668
+ group: "snapshot"
1669
+ },
1670
+ {
1671
+ field: "snapshotIgnore",
1672
+ group: "snapshot"
1673
+ }
1674
+ ];
1675
+ /**
1676
+ * Copy for every placed field.
1677
+ *
1678
+ * A separate table from {@link FIELD_PLACEMENTS} because the order the page
1679
+ * renders in and the words it uses are different concerns that happen to share a
1680
+ * key; keeping the record total means a new field cannot appear unlabelled.
1681
+ */
1682
+ const FIELD_COPY = {
1683
+ headless: {
1684
+ label: "settingsMode",
1685
+ hint: "settingsHeadlessNote"
1686
+ },
1687
+ channel: { label: "settingsBrowser" },
1688
+ profileMode: {
1689
+ label: "settingsProfileMode",
1690
+ hint: "settingsProfileModeHint"
1691
+ },
1692
+ userDataDir: { label: "settingsProfileDir" },
1693
+ stealth: {
1694
+ label: "settingsStealth",
1695
+ hint: "settingsStealthNote"
1696
+ },
1697
+ executablePath: { label: "settingsExecutable" },
1698
+ debugPortMin: {
1699
+ label: "settingsDebugPortMin",
1700
+ hint: "settingsDebugPortHint"
1701
+ },
1702
+ debugPortMax: { label: "settingsDebugPortMax" },
1703
+ viewportWidth: { label: "settingsWidth" },
1704
+ viewportHeight: { label: "settingsHeight" },
1705
+ quality: { label: "settingsQuality" },
1706
+ snapshotAttributes: {
1707
+ label: "settingsSnapshotAttributes",
1708
+ hint: "settingsSnapshotAttributesHint"
1709
+ },
1710
+ snapshotIgnore: {
1711
+ label: "settingsSnapshotIgnore",
1712
+ hint: "settingsSnapshotIgnoreHint"
1713
+ }
1714
+ };
1715
+ /** The heading and one-line explanation of a block inside the fold. */
1716
+ const GROUP_COPY = {
1717
+ process: {
1718
+ title: "settingsGroupProcess",
1719
+ hint: "settingsGroupProcessHint"
1720
+ },
1721
+ mirror: {
1722
+ title: "settingsGroupMirror",
1723
+ hint: "settingsGroupMirrorHint"
1724
+ },
1725
+ snapshot: {
1726
+ title: "settingsGroupSnapshot",
1727
+ hint: "settingsGroupSnapshotHint"
1728
+ }
1729
+ };
1730
+ /** Modes whose profiles outlive the browser process. */
1731
+ const KEEPING_MODES = ["session", "workspace"];
1732
+ /**
1733
+ * Whether the directory row applies to a mode.
1734
+ *
1735
+ * `temp` keeps nothing, so it has no parent directory to point anywhere: asking
1736
+ * for one would offer a setting that cannot take effect.
1737
+ * @param mode - the profile mode in force.
1738
+ * @returns whether the page shows the directory row.
1739
+ */
1740
+ function profileDirVisible(mode) {
1741
+ return KEEPING_MODES.includes(mode);
1742
+ }
1743
+ /**
1744
+ * The hint the directory row carries for a mode.
1745
+ *
1746
+ * The field means something different in each keeping mode — one directory per
1747
+ * conversation versus a parent for one directory per project — and a single
1748
+ * sentence cannot say both without being wrong in one of them.
1749
+ * @param mode - the profile mode in force.
1750
+ * @returns the copy key for that mode's explanation.
1751
+ */
1752
+ function profileDirHint(mode) {
1753
+ return mode === "workspace" ? "settingsProfileDirHintWorkspace" : "settingsProfileDirHintSession";
1754
+ }
1755
+ /**
1756
+ * The placeholder the directory field shows for a mode.
1757
+ *
1758
+ * It is the only place the row can say what an empty value will do, and that
1759
+ * answer differs per mode: a default directory, or the project's own.
1760
+ * @param mode - the profile mode in force.
1761
+ * @returns the copy key for that mode's placeholder.
1762
+ */
1763
+ function profileDirPlaceholder(mode) {
1764
+ return mode === "workspace" ? "settingsProfileDirPlaceholderWorkspace" : "settingsProfileDirPlaceholderSession";
1765
+ }
1766
+ /**
1767
+ * The placements of one block, in page order.
1768
+ * @param group - the block to render.
1769
+ * @returns its fields, in the order they are declared.
1770
+ */
1771
+ function placementsIn(group) {
1772
+ return FIELD_PLACEMENTS.filter((placement) => placement.group === group);
1773
+ }
1774
+ /**
1775
+ * The fields behind the fold, in page order.
1776
+ * @returns every folded placement.
1777
+ */
1778
+ function foldedPlacements() {
1779
+ return ADVANCED_GROUPS.flatMap((group) => placementsIn(group));
1780
+ }
1781
+ /**
1782
+ * How many of these fields the user layer holds.
1783
+ *
1784
+ * The fold hides its fields' controls, and with them their reset buttons, so a
1785
+ * change made inside it would otherwise be invisible from the collapsed page.
1786
+ * @param user - the user's own settings layer, as the form reports it.
1787
+ * @param placements - the fields to look at.
1788
+ * @returns the number of them the user has overridden.
1789
+ */
1790
+ function overriddenCount(user, placements) {
1791
+ return placements.filter((placement) => Object.hasOwn(user, placement.field)).length;
1792
+ }
1793
+ /**
1794
+ * The line the fold shows while closed.
1795
+ * @param t - copy for this namespace.
1796
+ * @param overridden - how many hidden fields the user has changed.
1797
+ * @returns the summary, mentioning them when there are any.
1798
+ */
1799
+ function advancedSummary(t, overridden) {
1800
+ const title = t("settingsAdvanced");
1801
+ if (overridden === 0) return title;
1802
+ return `${title} · ${t("settingsAdvancedOverridden").replace("{count}", String(overridden))}`;
1803
+ }
1804
+ /**
1805
+ * What the banner says about the browsers right now.
1806
+ *
1807
+ * The state is the host's, not the form's: it is the only way a user can tell a
1808
+ * saved value from a value a browser is actually running with.
1809
+ * @param report - the last read, or `undefined` before the first answer.
1810
+ * @param t - copy for this namespace.
1811
+ * @returns the banner's summary line.
1812
+ */
1813
+ function statusLine(report, t) {
1814
+ if (report === void 0) return t("settingsStatusReading");
1815
+ const running = report.instances.filter((instance) => instance.state === "ready").length;
1816
+ const starting = report.instances.filter((instance) => instance.state === "starting").length;
1817
+ if (running > 0) return t("settingsStatusRunning").replace("{count}", String(running));
1818
+ if (starting > 0) return t("settingsStatusStarting").replace("{count}", String(starting));
1819
+ return t("settingsStatusNone");
1820
+ }
1821
+ /**
1822
+ * One session's browser as a single line: which session, what state, and the
1823
+ * facts only a running one has.
1824
+ * @param instance - one entry of the report.
1825
+ * @param t - copy for this namespace.
1826
+ * @param shared - how many conversations use the same process as this one; 1
1827
+ * means it is this conversation's own, so nothing is said about sharing.
1828
+ * @returns the line to show for it.
1829
+ */
1830
+ function instanceLine(instance, t, shared = 1) {
1831
+ const state = instance.state === "ready" ? t("settingsInstanceStateRunning") : instance.state === "starting" ? t("settingsInstanceStateStarting") : instance.state === "failed" ? `${t("settingsInstanceStateFailed")} — ${instance.error ?? ""}`.trim() : t("settingsInstanceStateStopped");
1832
+ const parts = [instanceLabel(instance, t), state];
1833
+ if (instance.debugPort !== void 0) parts.push(`CDP 127.0.0.1:${instance.debugPort}`);
1834
+ if (instance.mode !== void 0) parts.push(instance.mode);
1835
+ const pages = instance.ownedPages ?? instance.tabs?.length ?? 0;
1836
+ if (pages > 0) parts.push(t("settingsInstancePages").replace("{count}", String(pages)));
1837
+ if (shared > 1) parts.push(t("settingsInstanceShared").replace("{count}", String(shared)));
1838
+ return parts.join(" · ");
1839
+ }
1840
+ /**
1841
+ * How many conversations share each browser process in a report.
1842
+ *
1843
+ * Keyed by the host's `browserId`, which is the pool key: two rows with the same
1844
+ * one are two conversations on one process. A report whose rows carry no
1845
+ * `browserId` — an older host — counts each session as its own, which is what
1846
+ * those rows mean.
1847
+ * @param instances - every row of the report.
1848
+ * @returns one count per session id.
1849
+ */
1850
+ function sharedCounts(instances) {
1851
+ const byBrowser = /* @__PURE__ */ new Map();
1852
+ for (const instance of instances) {
1853
+ const key = instance.browserId ?? instance.sessionId;
1854
+ const held = byBrowser.get(key);
1855
+ if (held === void 0) byBrowser.set(key, [instance.sessionId]);
1856
+ else held.push(instance.sessionId);
1857
+ }
1858
+ const counts = /* @__PURE__ */ new Map();
1859
+ for (const sessions of byBrowser.values()) for (const sessionId of sessions) counts.set(sessionId, sessions.length);
1860
+ return counts;
1861
+ }
1862
+ /**
1863
+ * What one session's browser is called on the page.
1864
+ *
1865
+ * The title when the host could read one, and "unnamed session" when it could
1866
+ * not. The raw id is deliberately not the fallback: every id starts with
1867
+ * `session-`, so shortening one prints the same eight hexadecimal characters
1868
+ * for every row and tells the user nothing about which conversation it is. The
1869
+ * id belongs in the row's tooltip, where it costs nothing and settles a tie
1870
+ * between two conversations with the same name.
1871
+ * @param instance - one entry of the report.
1872
+ * @param t - copy for this namespace.
1873
+ * @returns the label to show for its row.
1874
+ */
1875
+ function instanceLabel(instance, t) {
1876
+ const title = instance.title?.trim() ?? "";
1877
+ return title === "" ? t("settingsSessionUnnamed") : title;
1878
+ }
1879
+ //#endregion
1880
+ //#region src/client/settings.tsx
1881
+ /**
1882
+ * The 浏览器 settings section.
1883
+ *
1884
+ * Values arrive from the plugin's settings namespace — the composition entry
1885
+ * with the user's overrides resolved over it — and every control writes one
1886
+ * field back through the bound scope, so the page never holds a draft that can
1887
+ * disagree with what the browser was launched from.
1888
+ *
1889
+ * The profile settings are two questions, not one asked twice: the mode decides
1890
+ * who shares a sign-in and whether anything is kept, and the directory only
1891
+ * decides where. A blank directory is a legitimate answer in every mode — it
1892
+ * means "this mode's own default" — which is why the field is never a switch.
1893
+ *
1894
+ * One row can be absent: the directory row is not rendered in `temp` mode,
1895
+ * because that mode keeps nothing and therefore has no directory to name.
1896
+ * `settings-layout.ts` owns which modes that is, so the rule is testable.
1897
+ *
1898
+ * Which fields exist, where they sit, and what they are called all come from
1899
+ * `settings-layout.ts`: this file only turns each placement into a control. That
1900
+ * split is what keeps a new configuration field from slipping onto the page
1901
+ * unlabelled or missing entirely, and it is what the tests can reach.
1902
+ *
1903
+ * Styles are inline: this plugin builds its client bundle outside the
1904
+ * repository's stylesheet pipeline, so a CSS import would have no owner.
1905
+ */
1906
+ /**
1907
+ * Every value here is a `--dsw-*` token from ui-theme: the page is read in both
1908
+ * palettes, and a name the theme does not define falls back to nothing at all
1909
+ * rather than to a colour that happens to suit one of them. The materials are the
1910
+ * shared settings ones, so this page sits beside the harness's own cards: a
1911
+ * control is `bg-layer-3` inside a `border-l4` hairline at `radius-md`, a label
1912
+ * is 13px primary, a hint is 12px tertiary, and a card is `radius-xl` over the
1913
+ * settings-card fill and stroke.
1914
+ */
1915
+ const styles = {
1916
+ page: {
1917
+ display: "flex",
1918
+ flexDirection: "column",
1919
+ gap: "14px",
1920
+ maxWidth: "720px"
1921
+ },
1922
+ intro: {
1923
+ margin: 0,
1924
+ fontSize: "12px",
1925
+ lineHeight: 1.6,
1926
+ color: "var(--dsw-alias-label-tertiary)"
1927
+ },
1928
+ group: {
1929
+ display: "flex",
1930
+ flexDirection: "column",
1931
+ gap: "4px",
1932
+ borderTop: "0.5px solid var(--dsw-alias-border-l2)",
1933
+ paddingTop: "6px"
1934
+ },
1935
+ row: {
1936
+ display: "flex",
1937
+ gap: "12px",
1938
+ alignItems: "flex-start",
1939
+ justifyContent: "space-between",
1940
+ padding: "10px 0"
1941
+ },
1942
+ label: {
1943
+ display: "flex",
1944
+ flexDirection: "column",
1945
+ gap: "2px",
1946
+ flex: "1 1 auto",
1947
+ minWidth: 0
1948
+ },
1949
+ name: {
1950
+ fontSize: "13px",
1951
+ fontWeight: 500,
1952
+ lineHeight: 1.5,
1953
+ color: "var(--dsw-alias-label-primary)"
1954
+ },
1955
+ hint: {
1956
+ fontSize: "12px",
1957
+ lineHeight: 1.5,
1958
+ color: "var(--dsw-alias-label-tertiary)"
1959
+ },
1960
+ control: {
1961
+ display: "flex",
1962
+ gap: "8px",
1963
+ alignItems: "center",
1964
+ flex: "0 0 auto"
1965
+ },
1966
+ input: {
1967
+ boxSizing: "border-box",
1968
+ padding: "6px 12px",
1969
+ width: "240px",
1970
+ font: "inherit",
1971
+ fontSize: "13px",
1972
+ lineHeight: 1.5,
1973
+ border: "0.5px solid var(--dsw-alias-border-l4)",
1974
+ borderRadius: "var(--dsw-radius-md)",
1975
+ background: "var(--dsw-alias-bg-layer-3)",
1976
+ color: "var(--dsw-alias-label-primary)"
1977
+ },
1978
+ number: {
1979
+ boxSizing: "border-box",
1980
+ padding: "6px 12px",
1981
+ width: "84px",
1982
+ font: "inherit",
1983
+ fontSize: "13px",
1984
+ lineHeight: 1.5,
1985
+ border: "0.5px solid var(--dsw-alias-border-l4)",
1986
+ borderRadius: "var(--dsw-radius-md)",
1987
+ background: "var(--dsw-alias-bg-layer-3)",
1988
+ color: "var(--dsw-alias-label-primary)"
1989
+ },
1990
+ select: {
1991
+ boxSizing: "border-box",
1992
+ padding: "6px 12px",
1993
+ font: "inherit",
1994
+ fontSize: "13px",
1995
+ lineHeight: 1.5,
1996
+ border: "0.5px solid var(--dsw-alias-border-l4)",
1997
+ borderRadius: "var(--dsw-radius-md)",
1998
+ background: "var(--dsw-alias-bg-layer-3)",
1999
+ color: "var(--dsw-alias-label-primary)"
2000
+ },
2001
+ reset: {
2002
+ border: "none",
2003
+ background: "none",
2004
+ padding: 0,
2005
+ font: "inherit",
2006
+ fontSize: "12px",
2007
+ lineHeight: 1.5,
2008
+ color: "var(--dsw-alias-label-secondary)",
2009
+ cursor: "pointer"
2010
+ },
2011
+ note: {
2012
+ margin: 0,
2013
+ fontSize: "12px",
2014
+ lineHeight: 1.5,
2015
+ color: "var(--dsw-alias-label-tertiary)"
2016
+ },
2017
+ status: {
2018
+ display: "flex",
2019
+ gap: "10px",
2020
+ alignItems: "center",
2021
+ justifyContent: "space-between",
2022
+ padding: "10px 12px",
2023
+ fontSize: "12px",
2024
+ lineHeight: 1.5,
2025
+ borderRadius: "var(--dsw-radius-xl)",
2026
+ border: "0.5px solid var(--dsw-alias-settings-card-stroke)",
2027
+ background: "var(--dsw-alias-settings-card-fill)",
2028
+ color: "var(--dsw-alias-label-secondary)"
2029
+ },
2030
+ statusText: {
2031
+ display: "flex",
2032
+ flexDirection: "column",
2033
+ gap: "2px",
2034
+ minWidth: 0
2035
+ },
2036
+ statusDetail: {
2037
+ fontSize: "12px",
2038
+ lineHeight: 1.5,
2039
+ color: "var(--dsw-alias-label-tertiary)",
2040
+ wordBreak: "break-word",
2041
+ display: "block"
2042
+ },
2043
+ statusRow: {
2044
+ display: "flex",
2045
+ gap: "8px",
2046
+ alignItems: "baseline",
2047
+ justifyContent: "space-between"
2048
+ },
2049
+ statusSummary: {
2050
+ fontSize: "12px",
2051
+ lineHeight: 1.5,
2052
+ color: "var(--dsw-alias-label-tertiary)",
2053
+ cursor: "pointer"
2054
+ },
2055
+ statusFailed: { color: "var(--dsw-alias-state-error-primary)" },
2056
+ fold: {
2057
+ display: "flex",
2058
+ flexDirection: "column",
2059
+ gap: "10px",
2060
+ borderTop: "0.5px solid var(--dsw-alias-border-l2)",
2061
+ paddingTop: "12px"
2062
+ },
2063
+ foldSummary: {
2064
+ cursor: "pointer",
2065
+ fontSize: "13px",
2066
+ fontWeight: 500,
2067
+ lineHeight: 1.5,
2068
+ color: "var(--dsw-alias-label-primary)"
2069
+ },
2070
+ block: {
2071
+ display: "flex",
2072
+ flexDirection: "column",
2073
+ gap: "10px"
2074
+ },
2075
+ blockDivided: {
2076
+ display: "flex",
2077
+ flexDirection: "column",
2078
+ gap: "10px",
2079
+ borderTop: "0.5px solid var(--dsw-alias-border-l2)",
2080
+ paddingTop: "12px"
2081
+ },
2082
+ blockTitle: {
2083
+ margin: 0,
2084
+ fontSize: "13px",
2085
+ fontWeight: 500,
2086
+ lineHeight: 1.5,
2087
+ color: "var(--dsw-alias-label-primary)"
2088
+ },
2089
+ blockHint: {
2090
+ margin: 0,
2091
+ fontSize: "12px",
2092
+ lineHeight: 1.5,
2093
+ color: "var(--dsw-alias-label-tertiary)"
2094
+ },
2095
+ save: {
2096
+ margin: 0,
2097
+ fontSize: "12px",
2098
+ lineHeight: 1.5,
2099
+ color: "var(--dsw-alias-label-tertiary)",
2100
+ minHeight: "18px"
2101
+ },
2102
+ saveFailed: {
2103
+ margin: 0,
2104
+ fontSize: "12px",
2105
+ lineHeight: 1.5,
2106
+ color: "var(--dsw-alias-state-error-primary)",
2107
+ minHeight: "18px"
2108
+ }
2109
+ };
2110
+ /**
2111
+ * A control is missing.
2112
+ *
2113
+ * This only compiles while the caller's switch covers every field of
2114
+ * {@link BrowserSettingsView}, so adding a configuration field fails the build
2115
+ * here until it gets a control — the runtime throw is a backstop that never
2116
+ * runs in a build that passed.
2117
+ * @param field - the field nothing renders.
2118
+ * @returns never.
2119
+ */
2120
+ function unreachableField(field) {
2121
+ throw new Error(`no control for ${String(field)}`);
2122
+ }
2123
+ /** One labelled control, with a reset affordance once the user layer holds the field. */
2124
+ function Row(props) {
2125
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2126
+ style: styles.row,
2127
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2128
+ style: styles.label,
2129
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2130
+ style: styles.name,
2131
+ children: props.label
2132
+ }), props.hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2133
+ style: styles.hint,
2134
+ children: props.hint
2135
+ })]
2136
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2137
+ style: styles.control,
2138
+ children: [props.children, props.overridden ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2139
+ type: "button",
2140
+ style: styles.reset,
2141
+ onClick: props.onReset,
2142
+ children: props.resetLabel
2143
+ }) : null]
2144
+ })]
2145
+ });
2146
+ }
2147
+ /** One block inside the fold: what it covers, then its rows. */
2148
+ function Block(props) {
2149
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2150
+ "aria-label": props.title,
2151
+ style: props.divided ? styles.blockDivided : styles.block,
2152
+ children: [
2153
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2154
+ style: styles.blockTitle,
2155
+ children: props.title
2156
+ }),
2157
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2158
+ style: styles.blockHint,
2159
+ children: props.hint
2160
+ }),
2161
+ props.children
2162
+ ]
2163
+ });
2164
+ }
2165
+ /**
2166
+ * The 浏览器 settings page.
2167
+ * @param props - section owner props plus the bound namespace scope.
2168
+ * @returns the section element.
2169
+ */
2170
+ function BrowserSettingsSection(props) {
2171
+ const { form, t } = props;
2172
+ const subscribe = (0, react.useCallback)((listener) => form.subscribe(listener), [form]);
2173
+ const snapshot = (0, react.useSyncExternalStore)(subscribe, () => form.getSnapshot());
2174
+ const [status, setStatus] = (0, react.useState)(void 0);
2175
+ const [statusError, setStatusError] = (0, react.useState)(void 0);
2176
+ const [saveError, setSaveError] = (0, react.useState)(void 0);
2177
+ const [saved, setSaved] = (0, react.useState)(false);
2178
+ /** The session whose browser is being stopped right now, if one is. */
2179
+ const [closing, setClosing] = (0, react.useState)(void 0);
2180
+ const [closeError, setCloseError] = (0, react.useState)(void 0);
2181
+ const timers = (0, react.useRef)([]);
2182
+ /** Read the host's live state; the page cannot see the browser any other way. */
2183
+ const refresh = (0, react.useCallback)(() => {
2184
+ browserStatus().then((next) => {
2185
+ setStatus(next);
2186
+ setStatusError(void 0);
2187
+ }).catch((error) => {
2188
+ setStatusError(error instanceof Error ? error.message : String(error));
2189
+ });
2190
+ }, []);
2191
+ (0, react.useEffect)(() => {
2192
+ refresh();
2193
+ const pending = timers.current;
2194
+ return () => {
2195
+ for (const id of pending) window.clearTimeout(id);
2196
+ pending.length = 0;
2197
+ };
2198
+ }, [refresh]);
2199
+ /**
2200
+ * Stop one session's browser and re-read the report it should leave.
2201
+ *
2202
+ * The button belongs to a session rather than to the process: what it asks is
2203
+ * "this conversation does not want a browser", and the host decides whether
2204
+ * that stops a process. The row goes away when the ask answers, because the
2205
+ * report lists the browsers that exist and not the ones that did.
2206
+ * @param sessionId - the session whose browser should stop.
2207
+ */
2208
+ const closeBrowser = (0, react.useCallback)((sessionId) => {
2209
+ setClosing(sessionId);
2210
+ setCloseError(void 0);
2211
+ closeBrowserRequest(sessionId).then(() => {
2212
+ refresh();
2213
+ }).catch((error) => {
2214
+ setCloseError(error instanceof Error ? error.message : String(error));
2215
+ }).finally(() => {
2216
+ setClosing(void 0);
2217
+ });
2218
+ }, [refresh]);
2219
+ /**
2220
+ * Report one settings write, then re-read the browser it may have restarted.
2221
+ * A launch field closes the running browser and the next frame request starts
2222
+ * a new one, so the later reads are what catch the restarted instance rather
2223
+ * than the one that is on its way out. Resetting a field is a write like any
2224
+ * other and goes through here too — otherwise the banner keeps reporting the
2225
+ * browser the user just replaced.
2226
+ * @param operation - the form write to track.
2227
+ */
2228
+ const commit = (operation) => {
2229
+ setSaved(false);
2230
+ setSaveError(void 0);
2231
+ operation.then(() => {
2232
+ setSaved(true);
2233
+ for (const delay of [
2234
+ 1500,
2235
+ 4e3,
2236
+ 8e3
2237
+ ]) timers.current.push(window.setTimeout(refresh, delay));
2238
+ }).catch((error) => {
2239
+ setSaveError(error instanceof Error ? error.message : String(error));
2240
+ });
2241
+ };
2242
+ /**
2243
+ * Write one field and report both the write and the browser it may restart.
2244
+ * @param field - the configuration field.
2245
+ * @param next - its new value.
2246
+ */
2247
+ const write = (field, next) => {
2248
+ commit(form.set(field, next));
2249
+ };
2250
+ if (snapshot.status === "loading") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2251
+ style: styles.intro,
2252
+ children: t("settingsLoading")
2253
+ });
2254
+ const value = snapshot.value;
2255
+ if (value === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2256
+ style: styles.intro,
2257
+ children: t("settingsUnavailable")
2258
+ });
2259
+ const user = typeof snapshot.user === "object" && snapshot.user !== null ? snapshot.user : {};
2260
+ /** A field the user layer holds, so clearing it restores the composed value. */
2261
+ const owns = (field) => Object.hasOwn(user, field);
2262
+ /** A field is overridden once the user layer holds it. */
2263
+ const row = (label, field, control, hint) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2264
+ label,
2265
+ hint,
2266
+ overridden: owns(field),
2267
+ resetLabel: t("settingsReset"),
2268
+ onReset: () => {
2269
+ commit(form.unset(field));
2270
+ },
2271
+ children: control
2272
+ });
2273
+ /** The control one placed field renders. */
2274
+ const control = (field) => {
2275
+ switch (field) {
2276
+ case "headless": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2277
+ style: styles.select,
2278
+ value: value.headless ? "headless" : "headful",
2279
+ onChange: (event) => {
2280
+ write("headless", event.target.value === "headless");
2281
+ },
2282
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2283
+ value: "headless",
2284
+ children: t("settingsHeadless")
2285
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2286
+ value: "headful",
2287
+ children: t("settingsHeadful")
2288
+ })]
2289
+ });
2290
+ case "channel": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2291
+ style: styles.select,
2292
+ value: value.channel,
2293
+ onChange: (event) => {
2294
+ write("channel", event.target.value);
2295
+ },
2296
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2297
+ value: "chrome",
2298
+ children: t("settingsChannelChrome")
2299
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2300
+ value: "msedge",
2301
+ children: t("settingsChannelEdge")
2302
+ })]
2303
+ });
2304
+ case "profileMode": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2305
+ style: styles.select,
2306
+ value: value.profileMode,
2307
+ onChange: (event) => {
2308
+ const next = event.target.value;
2309
+ write("profileMode", next);
2310
+ },
2311
+ children: [
2312
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2313
+ value: "temp",
2314
+ children: t("settingsProfileModeTemp")
2315
+ }),
2316
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2317
+ value: "session",
2318
+ children: t("settingsProfileModeSession")
2319
+ }),
2320
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2321
+ value: "workspace",
2322
+ children: t("settingsProfileModeWorkspace")
2323
+ })
2324
+ ]
2325
+ });
2326
+ case "userDataDir": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2327
+ style: styles.input,
2328
+ value: value.userDataDir,
2329
+ spellCheck: false,
2330
+ placeholder: t(profileDirPlaceholder(value.profileMode)),
2331
+ onChange: (event) => {
2332
+ write("userDataDir", event.target.value);
2333
+ }
2334
+ });
2335
+ case "stealth": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2336
+ style: styles.select,
2337
+ value: value.stealth ? "on" : "off",
2338
+ onChange: (event) => {
2339
+ write("stealth", event.target.value === "on");
2340
+ },
2341
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2342
+ value: "on",
2343
+ children: t("settingsStealthOn")
2344
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2345
+ value: "off",
2346
+ children: t("settingsStealthOff")
2347
+ })]
2348
+ });
2349
+ case "executablePath": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2350
+ style: styles.input,
2351
+ value: value.executablePath,
2352
+ spellCheck: false,
2353
+ onChange: (event) => {
2354
+ write("executablePath", event.target.value);
2355
+ }
2356
+ });
2357
+ case "debugPortMin": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2358
+ style: styles.number,
2359
+ type: "number",
2360
+ min: 1,
2361
+ max: 65535,
2362
+ value: value.debugPortMin,
2363
+ onChange: (event) => {
2364
+ write("debugPortMin", Number(event.target.value));
2365
+ }
2366
+ });
2367
+ case "debugPortMax": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2368
+ style: styles.number,
2369
+ type: "number",
2370
+ min: 1,
2371
+ max: 65535,
2372
+ value: value.debugPortMax,
2373
+ onChange: (event) => {
2374
+ write("debugPortMax", Number(event.target.value));
2375
+ }
2376
+ });
2377
+ case "viewportWidth": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2378
+ style: styles.number,
2379
+ type: "number",
2380
+ min: 320,
2381
+ max: 3840,
2382
+ value: value.viewportWidth,
2383
+ onChange: (event) => {
2384
+ write("viewportWidth", Number(event.target.value));
2385
+ }
2386
+ });
2387
+ case "viewportHeight": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2388
+ style: styles.number,
2389
+ type: "number",
2390
+ min: 240,
2391
+ max: 2160,
2392
+ value: value.viewportHeight,
2393
+ onChange: (event) => {
2394
+ write("viewportHeight", Number(event.target.value));
2395
+ }
2396
+ });
2397
+ case "quality": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2398
+ style: styles.number,
2399
+ type: "number",
2400
+ min: 1,
2401
+ max: 100,
2402
+ value: value.quality,
2403
+ onChange: (event) => {
2404
+ write("quality", Number(event.target.value));
2405
+ }
2406
+ });
2407
+ case "snapshotAttributes": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2408
+ style: styles.input,
2409
+ value: value.snapshotAttributes,
2410
+ spellCheck: false,
2411
+ onChange: (event) => {
2412
+ write("snapshotAttributes", event.target.value);
2413
+ }
2414
+ });
2415
+ case "snapshotIgnore": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2416
+ style: styles.input,
2417
+ value: value.snapshotIgnore,
2418
+ spellCheck: false,
2419
+ onChange: (event) => {
2420
+ write("snapshotIgnore", event.target.value);
2421
+ }
2422
+ });
2423
+ }
2424
+ return unreachableField(field);
2425
+ };
2426
+ /** One placed field as a labelled row. */
2427
+ const placedRow = (placement) => {
2428
+ const copy = FIELD_COPY[placement.field];
2429
+ const hint = placement.field === "userDataDir" ? profileDirHint(value.profileMode) : copy.hint;
2430
+ return row(t(copy.label), placement.field, control(placement.field), hint === void 0 ? void 0 : t(hint));
2431
+ };
2432
+ const hiddenChanges = overriddenCount(user, foldedPlacements());
2433
+ const shared = sharedCounts(status?.instances ?? []);
2434
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2435
+ style: styles.page,
2436
+ children: [
2437
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2438
+ style: styles.intro,
2439
+ children: t("settingsIntro")
2440
+ }),
2441
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2442
+ style: styles.status,
2443
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2444
+ style: styles.statusText,
2445
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2446
+ style: statusError !== void 0 ? styles.statusFailed : void 0,
2447
+ children: statusError !== void 0 ? t("settingsStatusUnknown").replace("{error}", statusError) : statusLine(status, t)
2448
+ }), (status?.instances.length ?? 0) === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", { children: [
2449
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", {
2450
+ style: styles.statusSummary,
2451
+ children: t("settingsSessionsCount").replace("{count}", String(status?.instances.length ?? 0))
2452
+ }),
2453
+ status?.instances.map((instance) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2454
+ style: styles.statusRow,
2455
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2456
+ style: styles.statusDetail,
2457
+ title: instance.sessionId,
2458
+ children: instanceLine(instance, t, shared.get(instance.sessionId) ?? 1)
2459
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2460
+ type: "button",
2461
+ style: styles.reset,
2462
+ disabled: closing === instance.sessionId,
2463
+ title: t("settingsSessionClose"),
2464
+ onClick: () => {
2465
+ closeBrowser(instance.sessionId);
2466
+ },
2467
+ children: closing === instance.sessionId ? t("settingsSessionClosing") : t("settingsSessionClose")
2468
+ })]
2469
+ }, instance.sessionId)),
2470
+ closeError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2471
+ style: styles.statusFailed,
2472
+ children: t("settingsSessionCloseFailed").replace("{error}", closeError)
2473
+ })
2474
+ ] })]
2475
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2476
+ type: "button",
2477
+ style: styles.reset,
2478
+ onClick: refresh,
2479
+ children: t("settingsRefresh")
2480
+ })]
2481
+ }),
2482
+ saveError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2483
+ style: styles.saveFailed,
2484
+ children: t("settingsSaveFailed").replace("{error}", saveError)
2485
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2486
+ style: styles.save,
2487
+ children: saved ? t("settingsSaved") : ""
2488
+ }),
2489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2490
+ style: styles.group,
2491
+ children: placementsIn("everyday").map((placement) => placement.field === "userDataDir" && !profileDirVisible(value.profileMode) ? null : placedRow(placement))
2492
+ }),
2493
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
2494
+ style: styles.fold,
2495
+ children: [
2496
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", {
2497
+ style: styles.foldSummary,
2498
+ children: advancedSummary(t, hiddenChanges)
2499
+ }),
2500
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2501
+ style: styles.note,
2502
+ children: t("settingsAdvancedHint")
2503
+ }),
2504
+ ADVANCED_GROUPS.map((group, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Block, {
2505
+ title: t(GROUP_COPY[group].title),
2506
+ hint: t(GROUP_COPY[group].hint),
2507
+ divided: index > 0,
2508
+ children: placementsIn(group).map((placement) => placedRow(placement))
2509
+ }, group))
2510
+ ]
2511
+ }),
2512
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2513
+ style: styles.note,
2514
+ children: t("settingsApplied")
2515
+ })
2516
+ ]
2517
+ });
2518
+ }
2519
+ //#endregion
2520
+ //#region src/client/index.ts
2521
+ /** Required browser services: the tab registry, its navigation face, the slot registry, and copy. */
2522
+ const inject = [
2523
+ "slots",
2524
+ "locale",
2525
+ "sidebarRightTabs",
2526
+ "sidebarRight"
2527
+ ];
2528
+ /** The settings namespace the host half registers, and this page edits. */
2529
+ const SETTINGS_NAMESPACE = "dsh-browser";
2530
+ /**
2531
+ * Client plugin body: register the dictionaries, the tab type, its body, and
2532
+ * the settings page.
2533
+ * @param ctx - client root context carrying the registries and copy.
2534
+ */
2535
+ function apply(ctx) {
2536
+ const t = ctx.locale.bind(NS);
2537
+ ctx.effect(() => ctx.locale.register(NS, {
2538
+ zh,
2539
+ en
2540
+ }), "dsh-browser: dictionaries");
2541
+ ctx.effect(() => ctx.sidebarRightTabs.register(browserDefinition(() => t("title"), () => t("guideTitle"), () => t("guideDescription"), _deepseek_ai_dsh_client_ui_primitives.IconGlobeOutlineRegular)), "dsh-browser: cdpBrowser type");
2542
+ ctx.effect(() => ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({
2543
+ name: "sidebar.right.pane.tab",
2544
+ key: BROWSER_ID,
2545
+ locale: NS,
2546
+ inject: (sessionId) => ({ sessionId })
2547
+ }, BrowserBody)), "dsh-browser: viewer body");
2548
+ ctx.effect(() => ctx.slots.inject("sidebar.right.pane.tab.title", () => ctx.slots.register({
2549
+ name: "sidebar.right.pane.tab.title",
2550
+ key: BROWSER_ID
2551
+ }, BrowserTitle)), "dsh-browser: viewer title");
2552
+ followBrowserPages(ctx);
2553
+ followPageCloses(ctx);
2554
+ ctx.inject(["configForms"], (settingsCtx) => {
2555
+ const form = settingsCtx.configForms.get(SETTINGS_NAMESPACE);
2556
+ settingsCtx.effect(() => settingsCtx.configForms.whileServed([SETTINGS_NAMESPACE], () => settingsCtx.slots.inject("settings.section", () => settingsCtx.slots.register({
2557
+ name: "settings.section",
2558
+ id: BROWSER_ID,
2559
+ order: 46,
2560
+ label: () => t("settingsTitle"),
2561
+ inject: () => ({
2562
+ form,
2563
+ t
2564
+ })
2565
+ }, BrowserSettingsSection))), "dsh-browser: settings section");
2566
+ });
2567
+ }
2568
+ //#endregion
2569
+ exports.apply = apply;
2570
+ exports.inject = inject;
2571
+ return module.exports;
2572
+ }
2573
+ });
2574
+
2575
+ //# sourceMappingURL=client.js.map