Improve browser session context lifecycle
Keep browser sessions context-qualified so tabs from different chats can coexist without closing on context switches. Create a real chat context when Browser launches from dashboard/no selected context, preserving agent handoff for that session. Move chat context detail out of visible tab labels and into hover tooltips using only real chat names, with regression coverage for the updated lifecycle.
Alessandro committed
Apr 28, 2026 at 14:40 UTC
b7ba8eff7f883c4100bbf1ef726b4981ff8dc365
8 files changed
+566
-108
plugins/_browser/api/ws_browser.py
+30
-4
@@ -8,7 +8,7 @@ from typing import Any, ClassVar
8
from agent import AgentContext
9
from helpers.ws import WsHandler
10
from helpers.ws_manager import WsResult
11
-from plugins._browser.helpers.runtime import get_runtime
11
+from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
12
13
14
FRAME_IDLE_POLL_SECONDS = 0.05
@@ -38,6 +38,8 @@ class WsBrowser(WsHandler):
38
return await self._subscribe(data, sid)
39
if event == "browser_viewer_unsubscribe":
40
return self._unsubscribe(data, sid)
41
+ if event == "browser_viewer_sessions":
42
+ return await self._sessions(data)
43
if event == "browser_viewer_command":
44
return await self._command(data, sid)
45
if event == "browser_viewer_input":
@@ -90,8 +92,10 @@ class WsBrowser(WsHandler):
92
93
return {
94
"context_id": context_id,
95
+ "active_browser_context_id": context_id,
96
"active_browser_id": active_id,
94
- "browsers": browsers,
97
+ "browsers": await self._all_browser_tabs(),
98
+ "all_browsers": True,
99
"viewer_id": viewer_id,
100
}
101
@@ -104,6 +108,13 @@ class WsBrowser(WsHandler):
108
task.cancel()
109
return {"context_id": context_id, "unsubscribed": True}
110
111
+ async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]:
112
+ return {
113
+ "context_id": self._context_id(data),
114
+ "browsers": await self._all_browser_tabs(),
115
+ "all_browsers": True,
116
+ }
117
+
118
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
119
context_id = self._context_id(data)
120
if not context_id:
@@ -136,17 +147,20 @@ class WsBrowser(WsHandler):
147
listing = await runtime.call("list")
148
last_interacted_browser_id = listing.get("last_interacted_browser_id")
149
snapshot = await self._snapshot_for_result(runtime, result)
150
+ all_browsers = await self._all_browser_tabs()
151
await self.emit_to(
152
sid,
153
"browser_viewer_state",
154
{
155
"context_id": context_id,
156
+ "active_browser_context_id": context_id,
157
"viewer_id": viewer_id,
158
"command": command,
159
"browser_id": browser_id,
160
"result": result,
161
"snapshot": snapshot,
149
- "browsers": listing.get("browsers") or [],
162
+ "browsers": all_browsers,
163
+ "all_browsers": True,
164
"last_interacted_browser_id": last_interacted_browser_id,
165
},
166
correlation_id=data.get("correlationId"),
@@ -154,7 +168,9 @@ class WsBrowser(WsHandler):
168
return {
169
"result": result,
170
"snapshot": snapshot,
157
- "browsers": listing.get("browsers") or [],
171
+ "browsers": all_browsers,
172
+ "all_browsers": True,
173
+ "active_browser_context_id": context_id,
174
"last_interacted_browser_id": last_interacted_browser_id,
175
"command": command,
176
"browser_id": browser_id,
@@ -255,6 +271,16 @@ class WsBrowser(WsHandler):
271
return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
272
return None
273
274
+ async def _all_browser_tabs(self) -> list[dict[str, Any]]:
275
+ browsers: list[dict[str, Any]] = []
276
+ for session in await list_runtime_sessions():
277
+ context_id = str(session.get("context_id") or "")
278
+ for browser in session.get("browsers") or []:
279
+ entry = dict(browser or {})
280
+ entry.setdefault("context_id", context_id)
281
+ browsers.append(entry)
282
+ return browsers
283
+
284
async def _stream_frames(
285
self,
286
sid: str,
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+20
-2
@@ -69,6 +69,16 @@ function browserIdFromResult(result = {}, kvps = {}) {
69
);
70
}
71
72
+function browserContextIdFromResult(result = {}, kvps = {}) {
73
+ return (
74
+ result.context_id
75
+ || result.state?.context_id
76
+ || kvps.context_id
77
+ || kvps.contextId
78
+ || null
79
+ );
80
+}
81
+
82
function isFreshToolMessage(timestamp) {
83
const value = Number(timestamp);
84
if (!Number.isFinite(value) || value <= 0) return true;
@@ -94,7 +104,11 @@ function autoOpenBrowserCanvas(args, result) {
104
sessionStorage.setItem(persistedKey, "1");
105
requestAnimationFrame(async () => {
106
if (!(await browserAllowsToolAutofocus())) return;
97
- void openBrowserCanvas({ browserId, source: "tool" });
107
+ void openBrowserCanvas({
108
+ browserId,
109
+ contextId: browserContextIdFromResult(result, kvps),
110
+ source: "tool",
111
+ });
112
});
113
}
114
@@ -119,7 +133,11 @@ function drawBrowserTool({
133
const browserButton = createActionButton(
134
"visibility",
135
"Browser",
122
- () => openBrowserCanvas({ browserId: browserIdFromResult(browserResult, kvps), source: "tool" }),
136
+ () => openBrowserCanvas({
137
+ browserId: browserIdFromResult(browserResult, kvps),
138
+ contextId: browserContextIdFromResult(browserResult, kvps),
139
+ source: "tool",
140
+ }),
141
);
142
browserButton.setAttribute("title", "Open Browser");
143
browserButton.setAttribute("aria-label", "Open Browser");
plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
+1
@@ -93,6 +93,7 @@ export default async function registerBrowserSurface(canvas) {
93
await browser.onOpen(panel, {
94
mode: "canvas",
95
browserId: payload.browserId || payload.browser_id || null,
96
+ contextId: payload.contextId || payload.context_id || null,
97
});
98
}
99
},
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
+12
-1
@@ -16,6 +16,7 @@ export default async function autoOpenBrowserResults(context) {
16
if (!shouldAutoOpen(args, payload, result)) continue;
17
18
const browserId = getBrowserId(payload, result);
19
+ const contextId = getBrowserContextId(payload, result);
20
const key = [
21
args?.id || "",
22
browserId || "",
@@ -26,7 +27,7 @@ export default async function autoOpenBrowserResults(context) {
27
28
requestAnimationFrame(async () => {
29
if (!(await browserAllowsToolAutofocus())) return;
29
- void openBrowserCanvas({ browserId, source: "tool-result" });
30
+ void openBrowserCanvas({ browserId, contextId, source: "tool-result" });
31
});
32
}
33
}
@@ -105,6 +106,16 @@ function getBrowserId(payload = {}, result = {}) {
106
);
107
}
108
109
+function getBrowserContextId(payload = {}, result = {}) {
110
+ return (
111
+ result.context_id
112
+ || result.state?.context_id
113
+ || payload.context_id
114
+ || payload.contextId
115
+ || null
116
+ );
117
+}
118
+
119
function isFresh(timestamp, fallbackTimestamp) {
120
const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
121
if (!messageMs) return true;
plugins/_browser/helpers/runtime.py
+22
@@ -916,6 +916,7 @@ class _BrowserRuntimeCore:
916
history_length = 0
917
return {
918
"id": browser_page.id,
919
+ "context_id": self.context_id,
920
"currentUrl": page.url,
921
"title": title,
922
"canGoBack": bool(history_length and int(history_length) > 1),
@@ -1061,4 +1062,25 @@ def known_context_ids() -> list[str]:
1062
return sorted(_runtimes)
1063
1064
1065
+async def list_runtime_sessions() -> list[dict[str, Any]]:
1066
+ with _runtime_lock:
1067
+ runtimes = list(_runtimes.items())
1068
+
1069
+ sessions: list[dict[str, Any]] = []
1070
+ for context_id, runtime in runtimes:
1071
+ try:
1072
+ listing = await runtime.call("list")
1073
+ except Exception as exc:
1074
+ PrintStyle.warning(f"Browser runtime list failed for context {context_id}: {exc}")
1075
+ continue
1076
+ sessions.append(
1077
+ {
1078
+ "context_id": context_id,
1079
+ "browsers": listing.get("browsers") or [],
1080
+ "last_interacted_browser_id": listing.get("last_interacted_browser_id"),
1081
+ }
1082
+ )
1083
+ return sessions
1084
+
1085
+
1086
atexit.register(close_all_runtimes_sync)
plugins/_browser/webui/browser-panel.html
+6
-5
@@ -11,25 +11,26 @@
11
<div x-data>
12
<template x-if="$store.browserPage">
13
<div class="browser-panel" x-create="$store.browserPage.onOpen($el, xAttrs($el) || {})" x-destroy="$store.browserPage.cleanup()"
14
+ x-effect="$store.browserPage.handleSelectedContextChange($store.chats?.selected)"
15
@keydown.window="$store.browserPage.handleKeydown($event)">
16
<div class="browser-meta">
17
<div class="browser-meta-top">
18
<div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
18
- <template x-for="browser in $store.browserPage.browsers" :key="browser.id">
19
+ <template x-for="browser in $store.browserPage.browsers" :key="$store.browserPage.browserTabKey(browser)">
20
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
21
<button type="button" class="browser-tab" role="tab"
22
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
22
- :title="$store.browserPage.browserTabLabel(browser)"
23
- @click="$store.browserPage.selectBrowser(browser.id)">
23
+ :title="$store.browserPage.browserTabTooltip(browser)"
24
+ @click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
25
<span class="material-symbols-outlined browser-tab-icon" aria-hidden="true">language</span>
26
<span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
27
</button>
28
<button type="button" class="browser-tab-close"
29
:title="'Close ' + $store.browserPage.browserTabLabel(browser)"
30
:aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
30
- :disabled="$store.browserPage.isClosingBrowser(browser.id)"
31
+ :disabled="$store.browserPage.isClosingBrowser(browser.id, browser.context_id)"
32
@pointerdown.stop
32
- @click.stop="$store.browserPage.closeBrowser(browser.id)">
33
+ @click.stop="$store.browserPage.closeBrowser(browser.id, browser.context_id)">
34
<span class="material-symbols-outlined">close</span>
35
</button>
36
</div>
plugins/_browser/webui/browser-store.js
+337
-90
@@ -95,6 +95,7 @@ const model = {
95
contextId: "",
96
browsers: [],
97
activeBrowserId: null,
98
+ activeBrowserContextId: "",
99
address: "",
100
frameSrc: "",
101
frameState: null,
@@ -142,6 +143,8 @@ const model = {
143
_connectSequence: 0,
144
_viewerToken: "",
145
_contextCreatePromise: null,
146
+ _lastSelectedContextId: "",
147
+ _sessionRefreshPromise: null,
148
extensionMenuOpen: false,
149
extensionInstallUrl: "",
150
extensionActionLoading: false,
@@ -173,7 +176,7 @@ const model = {
176
try {
177
const response = await callJsonApi("/plugins/_browser/extensions", {
178
action: "list",
176
- context_id: this.contextId,
179
+ context_id: this.resolveContextId() || this.contextId,
180
});
181
if (!response?.ok) {
182
throw new Error(response?.error || "Could not load browser extensions.");
@@ -210,7 +213,7 @@ const model = {
213
this._configRefreshPromise = (async () => {
214
const response = await callJsonApi("/plugins/_browser/extensions", {
215
action: "list",
213
- context_id: this.contextId || this.resolveContextId(),
216
+ context_id: this.resolveContextId() || this.contextId,
217
});
218
if (!response?.ok) {
219
throw new Error(response?.error || "Could not load browser settings.");
@@ -233,6 +236,39 @@ const model = {
236
return this.autofocusActivePage !== false;
237
},
238
239
+ handleSelectedContextChange(contextId = "") {
240
+ const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
241
+ if (selectedContextId === this._lastSelectedContextId) return;
242
+ this._lastSelectedContextId = selectedContextId;
243
+ if (!this._surfaceMounted) return;
244
+ void this.refreshBrowserSessions(selectedContextId);
245
+ },
246
+
247
+ async refreshBrowserSessions(contextId = "") {
248
+ if (this._sessionRefreshPromise) {
249
+ await this._sessionRefreshPromise;
250
+ return;
251
+ }
252
+ this._sessionRefreshPromise = (async () => {
253
+ const response = await websocket.request(
254
+ "browser_viewer_sessions",
255
+ { context_id: this.normalizeContextId(contextId || this.resolveContextId()) },
256
+ { timeoutMs: 10000 },
257
+ );
258
+ const data = firstOk(response);
259
+ this.applyBrowserListing(data.browsers || [], data.context_id || "", {
260
+ replaceAll: Boolean(data.all_browsers),
261
+ });
262
+ })();
263
+ try {
264
+ await this._sessionRefreshPromise;
265
+ } catch (error) {
266
+ console.warn("Browser session refresh failed", error);
267
+ } finally {
268
+ this._sessionRefreshPromise = null;
269
+ }
270
+ },
271
+
272
toggleExtensionsMenu() {
273
this.extensionMenuOpen = !this.extensionMenuOpen;
274
if (this.extensionMenuOpen) {
@@ -251,6 +287,10 @@ const model = {
287
return getContext() || urlContext || chatsStore.selected || "";
288
},
289
290
+ normalizeContextId(contextId = "") {
291
+ return String(contextId || "").trim();
292
+ },
293
+
294
async ensureContextId() {
295
const existingContextId = String(this.resolveContextId() || "").trim();
296
if (existingContextId) {
@@ -263,13 +303,24 @@ const model = {
303
}
304
305
try {
266
- this.contextId = await this._contextCreatePromise;
267
- return this.contextId;
306
+ const contextId = await this._contextCreatePromise;
307
+ this.contextId = contextId;
308
+ return contextId;
309
} finally {
310
this._contextCreatePromise = null;
311
}
312
},
313
314
+ async contextIdForNewBrowser() {
315
+ return await this.ensureContextId();
316
+ },
317
+
318
+ async contextIdForActiveBrowser() {
319
+ const activeContextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
320
+ if (activeContextId) return activeContextId;
321
+ return await this.ensureContextId();
322
+ },
323
+
324
async createChatContextForBrowser() {
325
const response = await callJsonApi("/chat_create", {
326
current_context: this.resolveContextId() || "",
@@ -361,7 +412,7 @@ const model = {
412
try {
413
const response = await callJsonApi("/plugins/_browser/extensions", {
414
action: "install_web_store",
364
- context_id: this.contextId,
415
+ context_id: this.resolveContextId() || this.contextId,
416
url,
417
});
418
if (!response?.ok) {
@@ -388,7 +439,7 @@ const model = {
439
try {
440
const response = await callJsonApi("/plugins/_browser/extensions", {
441
action: "set_extension_enabled",
391
- context_id: this.contextId,
442
+ context_id: this.resolveContextId() || this.contextId,
443
path,
444
enabled: Boolean(enabled),
445
});
@@ -415,7 +466,7 @@ const model = {
466
try {
467
const response = await callJsonApi("/plugins/_browser/extensions", {
468
action: "set_model_preset",
418
- context_id: this.contextId,
469
+ context_id: this.resolveContextId() || this.contextId,
470
model_preset: presetName,
471
});
472
if (!response?.ok) {
@@ -463,17 +514,21 @@ const model = {
514
const requestedBrowserId = this.normalizeBrowserId(
515
options.requestedBrowserId ?? options.browserId ?? options.browser_id,
516
);
517
+ const requestedContextId = this.normalizeContextId(
518
+ options.requestedContextId ?? options.contextId ?? options.context_id,
519
+ );
520
const nextMode = options?.mode === "modal" ? "modal" : "canvas";
521
if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
522
return;
523
}
470
- const openSignature = this.surfaceOpenSignature(element, nextMode, requestedBrowserId);
524
+ const openSignature = this.surfaceOpenSignature(element, nextMode, requestedBrowserId, requestedContextId);
525
if (this._openPromise && this._openSignature === openSignature) {
526
return await this._openPromise;
527
}
528
const promise = this.openSurface(element, {
529
...options,
530
requestedBrowserId,
531
+ requestedContextId,
532
nextMode,
533
});
534
this._openPromise = promise;
@@ -494,6 +549,10 @@ const model = {
549
const requestedBrowserId = this.normalizeBrowserId(
550
options.requestedBrowserId ?? options.browserId ?? options.browser_id,
551
);
552
+ const requestedContextId = this.normalizeContextId(
553
+ options.requestedContextId ?? options.contextId ?? options.context_id,
554
+ );
555
+ let targetContextId = requestedContextId;
556
const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
557
if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
558
this.loading = false;
@@ -501,22 +560,28 @@ const model = {
560
}
561
const surfaceSequence = this._surfaceOpenSequence + 1;
562
this._surfaceOpenSequence = surfaceSequence;
504
- this.prepareSurfaceOpen(nextMode, requestedBrowserId);
563
+ this.prepareSurfaceOpen(nextMode, requestedBrowserId, requestedContextId);
564
if (nextMode === "modal") {
565
this.setupFloatingModal(element);
566
} else {
567
this.setupCanvasSurface(element);
568
}
569
try {
511
- await this.ensureContextId();
570
+ if (!targetContextId && !this.activeBrowserContextId && !this.contextId) {
571
+ targetContextId = await this.ensureContextId();
572
+ }
573
if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
574
await this.refreshStatus();
575
if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
576
const viewport = await this.waitForSurfaceViewport({ sequence: surfaceSequence });
577
if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
578
if (nextMode === "canvas" && !viewport) return;
518
- this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId);
519
- await this.connectViewer({ browserId: requestedBrowserId, initialViewport: viewport });
579
+ this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId, targetContextId);
580
+ await this.connectViewer({
581
+ browserId: requestedBrowserId,
582
+ contextId: targetContextId,
583
+ initialViewport: viewport,
584
+ });
585
if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
586
await this.syncViewportAfterSurfaceOpen(surfaceSequence);
587
} catch (error) {
@@ -534,13 +599,14 @@ const model = {
599
}
600
},
601
537
- surfaceOpenSignature(element = null, mode = "", browserId = null) {
602
+ surfaceOpenSignature(element = null, mode = "", browserId = null, contextId = "") {
603
const root = element || globalThis.document?.querySelector(".browser-panel");
604
if (root && !root.__browserSurfaceOpenId) {
605
root.__browserSurfaceOpenId = makeViewerToken();
606
}
607
return [
608
mode || "",
609
+ this.normalizeContextId(contextId) || "",
610
this.normalizeBrowserId(browserId) || "",
611
root?.__browserSurfaceOpenId || "",
612
].join(":");
@@ -600,10 +666,10 @@ const model = {
666
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
667
},
668
603
- prepareSurfaceOpen(nextMode, requestedBrowserId = null) {
669
+ prepareSurfaceOpen(nextMode, requestedBrowserId = null, requestedContextId = "") {
670
const previousMode = this._mode;
671
const modeChanged = this._surfaceMounted && previousMode && previousMode !== nextMode;
606
- const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
672
+ const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId(requestedContextId);
673
this._mode = nextMode;
674
this._surfaceMounted = true;
675
this._surfaceOpenedAt = Date.now();
@@ -628,10 +694,11 @@ const model = {
694
this._lastFrameAt = 0;
695
},
696
631
- resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null) {
697
+ resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null, requestedContextId = "") {
698
if (!viewport || !this.frameSrc || !this._lastViewport) return;
699
const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
634
- if (!this.sameBrowserId(this._lastViewport.browserId, targetBrowserId)) return;
700
+ const targetContextId = this.normalizeContextId(requestedContextId || this.contextIdForBrowserId(targetBrowserId) || this.activeBrowserContextId);
701
+ if (!this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, targetBrowserId, targetContextId)) return;
702
const changed = Math.abs(this._lastViewport.width - viewport.width) > VIEWPORT_SYNC_SIZE_TOLERANCE
703
|| Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
704
if (!changed) return;
@@ -695,8 +762,16 @@ const model = {
762
763
async connectViewer(options = {}) {
764
let contextId = "";
765
+ const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
766
+ const requestedContextId = this.normalizeContextId(
767
+ options.contextId
768
+ ?? options.context_id
769
+ ?? this.contextIdForBrowserId(requestedBrowserId)
770
+ ?? this.activeBrowserContextId
771
+ ?? this.contextId
772
+ );
773
try {
699
- contextId = await this.ensureContextId();
774
+ contextId = requestedContextId || await this.ensureContextId();
775
} catch (error) {
776
this.connected = false;
777
this.switchingBrowserId = null;
@@ -710,7 +785,13 @@ const model = {
785
this._surfaceSwitching = false;
786
return;
787
}
713
- const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
788
+ const previousContextId = this.normalizeContextId(this.contextId);
789
+ if (previousContextId && previousContextId !== contextId) {
790
+ try {
791
+ await websocket.emit("browser_viewer_unsubscribe", { context_id: previousContextId });
792
+ } catch {}
793
+ }
794
+ this.contextId = contextId;
795
const sequence = this._connectSequence + 1;
796
const viewerToken = makeViewerToken();
797
this._connectSequence = sequence;
@@ -726,7 +807,7 @@ const model = {
807
response = await websocket.request(
808
"browser_viewer_subscribe",
809
{
729
- context_id: this.contextId,
810
+ context_id: contextId,
811
browser_id: requestedBrowserId,
812
viewer_id: viewerToken,
813
viewport_width: initialViewport?.width,
@@ -750,8 +831,11 @@ const model = {
831
return;
832
}
833
const data = firstOk(response);
753
- this.browsers = data.browsers || [];
754
- this.setActiveBrowserId(data.active_browser_id || requestedBrowserId || this.activeBrowserId || null);
834
+ this.applyBrowserListing(data.browsers || [], contextId, { replaceAll: Boolean(data.all_browsers) });
835
+ this.setActiveBrowserId(
836
+ data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
837
+ data.active_browser_context_id || contextId,
838
+ );
839
this.connected = true;
840
this.browserInstallExpected = false;
841
},
@@ -761,12 +845,17 @@ const model = {
845
const frameHandler = ({ data }) => {
846
if (data?.context_id !== this.contextId) return;
847
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
848
+ const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
849
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
765
- this.browsers = data.browsers || this.browsers;
850
+ this.applyBrowserListing(data.browsers || [], incomingContextId, { replaceContext: true });
851
if (incomingBrowserId && !this.activeBrowserId) {
767
- this.setActiveBrowserId(incomingBrowserId);
852
+ this.setActiveBrowserId(incomingBrowserId, incomingContextId);
853
}
769
- if (incomingBrowserId && this.activeBrowserId && !this.sameBrowserId(incomingBrowserId, this.activeBrowserId)) {
854
+ if (
855
+ incomingBrowserId
856
+ && this.activeBrowserId
857
+ && !this.sameBrowserTab(incomingBrowserId, incomingContextId, this.activeBrowserId, this.activeBrowserContextId)
858
+ ) {
859
return;
860
}
861
if (data.state) {
@@ -779,8 +868,12 @@ const model = {
868
const frameBrowserId = incomingBrowserId || this.activeBrowserId;
869
this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
870
browserId: frameBrowserId,
871
+ contextId: incomingContextId,
872
onAccepted: () => {
783
- if (this.sameBrowserId(this.switchingBrowserId, frameBrowserId)) {
873
+ if (
874
+ this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
875
+ && this.normalizeContextId(this.activeBrowserContextId) === incomingContextId
876
+ ) {
877
this.switchingBrowserId = null;
878
}
879
this._surfaceSwitching = false;
@@ -794,7 +887,7 @@ const model = {
887
}
888
if (!data.image && !data.state) {
889
if (!this.activeBrowserId) {
797
- this.setActiveBrowserId(null);
890
+ this.setActiveBrowserId(null, "");
891
this.frameState = null;
892
this.frameSrc = "";
893
}
@@ -808,27 +901,33 @@ const model = {
901
const stateHandler = ({ data }) => {
902
if (data?.context_id !== this.contextId) return;
903
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
811
- this.browsers = data.browsers || [];
904
+ const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
905
+ this.applyBrowserListing(data.browsers || [], commandContextId, { replaceAll: Boolean(data.all_browsers) });
906
const command = String(data.command || "").toLowerCase();
907
const commandBrowserId = this.normalizeBrowserId(data.browser_id);
908
const result = data.result || {};
909
const resultState = this.stateFromCommandResult(result);
910
+ const resultContextId = this.normalizeContextId(
911
+ result.context_id
912
+ || result.state?.context_id
913
+ || commandContextId
914
+ );
915
const preferredBrowserId = this.normalizeBrowserId(
916
result.id
917
|| result.state?.id
918
|| data.last_interacted_browser_id
919
|| this.activeBrowserId
821
- || this.firstBrowserId()
920
+ || this.firstBrowserId(resultContextId)
921
);
922
if (
923
!this.activeBrowserId
924
|| command === "open"
925
|| command === "close"
827
- || this.sameBrowserId(commandBrowserId, this.activeBrowserId)
926
+ || this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
927
) {
829
- this.setActiveBrowserId(preferredBrowserId);
928
+ this.setActiveBrowserId(preferredBrowserId, resultContextId);
929
}
831
- this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId));
930
+ this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId, this.activeBrowserContextId));
931
this.applySnapshot(data.snapshot);
932
};
933
await websocket.on("browser_viewer_state", stateHandler);
@@ -989,31 +1088,49 @@ const model = {
1088
this.annotationError = "";
1089
this.beginCommand();
1090
const previousActiveBrowserId = this.activeBrowserId;
1091
+ const previousActiveContextId = this.activeBrowserContextId;
1092
const commandName = String(command || "").toLowerCase();
1093
try {
994
- await this.ensureContextId();
1094
+ const targetContextId = commandName === "open"
1095
+ ? await this.contextIdForNewBrowser()
1096
+ : this.normalizeContextId(extra.context_id || extra.contextId) || await this.contextIdForActiveBrowser();
1097
+ const targetBrowserId = this.normalizeBrowserId(extra.browser_id ?? this.activeBrowserId);
1098
+ this.contextId = targetContextId;
1099
const response = await websocket.request(
1100
"browser_viewer_command",
1101
{
998
- context_id: this.contextId,
999
- browser_id: this.activeBrowserId,
1102
+ ...extra,
1103
+ context_id: targetContextId,
1104
+ browser_id: targetBrowserId,
1105
viewer_id: this._viewerToken,
1106
command,
1002
- ...extra,
1107
},
1108
{ timeoutMs: 20000 },
1109
);
1110
const data = firstOk(response);
1007
- this.browsers = data.browsers || this.browsers;
1111
+ this.applyBrowserListing(data.browsers || [], targetContextId, { replaceAll: Boolean(data.all_browsers) });
1112
const result = data.result || {};
1009
- this.setActiveBrowserId(
1113
+ const resultContextId = this.normalizeContextId(
1114
+ result.context_id
1115
+ || result.state?.context_id
1116
+ || data.active_browser_context_id
1117
+ || targetContextId
1118
+ );
1119
+ const preferredBrowser = this.browserById(
1120
result.id
1121
|| result.state?.id
1122
|| result.last_interacted_browser_id
1013
- || data.last_interacted_browser_id
1014
- || this.firstBrowserId()
1123
+ || data.last_interacted_browser_id,
1124
+ resultContextId,
1125
+ )
1126
+ || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1127
+ || this.firstBrowser(resultContextId)
1128
+ || this.firstBrowser();
1129
+ this.setActiveBrowserId(preferredBrowser?.id || null, preferredBrowser?.context_id || resultContextId);
1130
+ this.applyActiveFrameState(
1131
+ this.stateFromCommandResult(result)
1132
+ || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1133
);
1016
- this.applyActiveFrameState(this.stateFromCommandResult(result) || this.browserById(this.activeBrowserId));
1134
if (!this.activeBrowserId) {
1135
this.frameState = null;
1136
this.frameSrc = "";
@@ -1023,12 +1140,21 @@ const model = {
1140
}
1141
this.applySnapshot(data.snapshot);
1142
if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
1026
- this.clearAnnotationsForBrowser(previousActiveBrowserId);
1143
+ this.clearAnnotationsForBrowser(previousActiveBrowserId, null, previousActiveContextId);
1144
this.cancelAnnotationDraft();
1145
}
1029
- const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
1146
+ const activeChanged = this.activeBrowserId
1147
+ && !this.sameBrowserTab(
1148
+ this.activeBrowserId,
1149
+ this.activeBrowserContextId,
1150
+ previousActiveBrowserId,
1151
+ previousActiveContextId,
1152
+ );
1153
if ((commandName === "open" || commandName === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
1031
- await this.connectViewer({ browserId: this.activeBrowserId });
1154
+ await this.connectViewer({
1155
+ browserId: this.activeBrowserId,
1156
+ contextId: this.activeBrowserContextId,
1157
+ });
1158
} else if (["navigate", "back", "forward", "reload"].includes(commandName)) {
1159
await this.restartCanvasStreamAfterPageChange();
1160
}
@@ -1074,16 +1200,21 @@ const model = {
1200
}
1201
},
1202
1077
- async selectBrowser(id) {
1203
+ async selectBrowser(id, contextId = "") {
1204
const targetId = this.normalizeBrowserId(id);
1205
+ const targetContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(targetId));
1206
if (!targetId) {
1207
await this.openNewBrowser();
1208
return;
1209
}
1083
- if (this.sameBrowserId(targetId, this.activeBrowserId) && this.connected && !this.isSwitchingBrowser()) {
1210
+ if (
1211
+ this.sameBrowserTab(targetId, targetContextId, this.activeBrowserId, this.activeBrowserContextId)
1212
+ && this.connected
1213
+ && !this.isSwitchingBrowser()
1214
+ ) {
1215
return;
1216
}
1086
- const browser = this.browserById(targetId);
1217
+ const browser = this.browserById(targetId, targetContextId);
1218
this.error = "";
1219
this.switchingBrowserId = targetId;
1220
this.cancelFrameRender();
@@ -1092,12 +1223,15 @@ const model = {
1223
if (!this.addressFocused && browser?.currentUrl) {
1224
this.address = browser.currentUrl;
1225
}
1095
- this.setActiveBrowserId(targetId);
1096
- if (this.contextId) {
1226
+ this.setActiveBrowserId(targetId, targetContextId);
1227
+ if (this.activeBrowserContextId) {
1228
try {
1098
- await this.connectViewer({ browserId: targetId });
1229
+ await this.connectViewer({ browserId: targetId, contextId: targetContextId });
1230
} catch (error) {
1100
- if (this.sameBrowserId(this.switchingBrowserId, targetId)) {
1231
+ if (
1232
+ this.sameBrowserId(this.switchingBrowserId, targetId)
1233
+ && this.normalizeContextId(this.activeBrowserContextId) === targetContextId
1234
+ ) {
1235
this.switchingBrowserId = null;
1236
}
1237
this.error = error instanceof Error ? error.message : String(error);
@@ -1109,15 +1243,23 @@ const model = {
1243
await this.command("open");
1244
},
1245
1112
- isClosingBrowser(id) {
1246
+ isClosingBrowser(id, contextId = "") {
1247
const browserId = this.normalizeBrowserId(id);
1114
- return Boolean(browserId && this._closingBrowserIds[String(browserId)]);
1248
+ const key = this.browserTabKey({
1249
+ id: browserId,
1250
+ context_id: contextId || this.contextIdForBrowserId(browserId),
1251
+ });
1252
+ return Boolean(key && this._closingBrowserIds[key]);
1253
},
1254
1117
- markBrowserClosing(id, closing = true) {
1255
+ markBrowserClosing(id, contextId = "", closing = true) {
1256
const browserId = this.normalizeBrowserId(id);
1257
if (!browserId) return;
1120
- const key = String(browserId);
1258
+ const key = this.browserTabKey({
1259
+ id: browserId,
1260
+ context_id: contextId || this.contextIdForBrowserId(browserId),
1261
+ });
1262
+ if (!key) return;
1263
const nextClosing = { ...this._closingBrowserIds };
1264
if (closing) {
1265
nextClosing[key] = true;
@@ -1127,19 +1269,20 @@ const model = {
1269
this._closingBrowserIds = nextClosing;
1270
},
1271
1130
- async closeBrowser(id) {
1272
+ async closeBrowser(id, contextId = "") {
1273
const browserId = this.normalizeBrowserId(id);
1132
- if (!browserId || this.isClosingBrowser(browserId)) return;
1133
- this.markBrowserClosing(browserId, true);
1274
+ const browserContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(browserId));
1275
+ if (!browserId || !browserContextId || this.isClosingBrowser(browserId, browserContextId)) return;
1276
+ this.markBrowserClosing(browserId, browserContextId, true);
1277
try {
1135
- await this.command("close", { browser_id: browserId });
1278
+ await this.command("close", { browser_id: browserId, context_id: browserContextId });
1279
} finally {
1137
- this.markBrowserClosing(browserId, false);
1280
+ this.markBrowserClosing(browserId, browserContextId, false);
1281
}
1282
},
1283
1284
isActiveBrowser(browser) {
1142
- return Number(browser?.id) === Number(this.activeBrowserId);
1285
+ return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId);
1286
},
1287
1288
browserTabTitle(browser) {
@@ -1150,12 +1293,41 @@ const model = {
1293
1294
browserTabLabel(browser) {
1295
const id = browser?.id ? `#${browser.id}` : "Browser";
1153
- return `${id} ${this.browserTabTitle(browser)}`;
1296
+ return [id, this.browserTabTitle(browser)].filter(Boolean).join(" ");
1297
},
1298
1156
- firstBrowserId() {
1157
- const first = Array.isArray(this.browsers) ? this.browsers[0] : null;
1158
- return first?.id || null;
1299
+ browserTabTooltip(browser) {
1300
+ const chatTitle = this.browserChatTitle(browser);
1301
+ return [this.browserTabLabel(browser), chatTitle ? `Chat: ${chatTitle}` : ""]
1302
+ .filter(Boolean)
1303
+ .join("\n");
1304
+ },
1305
+
1306
+ browserTabKey(browser = {}) {
1307
+ const id = this.normalizeBrowserId(browser?.id ?? browser);
1308
+ const contextId = this.normalizeContextId(browser?.context_id || browser?.contextId || this.activeBrowserContextId || this.contextId);
1309
+ return id && contextId ? `${contextId}:${id}` : "";
1310
+ },
1311
+
1312
+ browserChatTitle(browser = {}) {
1313
+ const contextId = this.normalizeContextId(browser?.context_id || browser?.contextId);
1314
+ if (!contextId) return "";
1315
+ const context = chatsStore.contexts?.find?.((item) => item?.id === contextId);
1316
+ return String(context?.name || context?.title || "").trim();
1317
+ },
1318
+
1319
+ firstBrowser(contextId = "") {
1320
+ const normalizedContextId = this.normalizeContextId(contextId);
1321
+ const browsers = Array.isArray(this.browsers) ? this.browsers : [];
1322
+ if (normalizedContextId) {
1323
+ const scoped = browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId);
1324
+ if (scoped) return scoped;
1325
+ }
1326
+ return browsers[0] || null;
1327
+ },
1328
+
1329
+ firstBrowserId(contextId = "") {
1330
+ return this.firstBrowser(contextId)?.id || null;
1331
},
1332
1333
normalizeBrowserId(id) {
@@ -1168,10 +1340,49 @@ const model = {
1340
return Boolean(leftId && rightId && leftId === rightId);
1341
},
1342
1171
- browserById(id) {
1343
+ sameBrowserTab(leftId, leftContextId, rightId, rightContextId) {
1344
+ return this.sameBrowserId(leftId, rightId)
1345
+ && this.normalizeContextId(leftContextId) === this.normalizeContextId(rightContextId);
1346
+ },
1347
+
1348
+ browserById(id, contextId = "") {
1349
const numeric = this.normalizeBrowserId(id);
1350
if (!numeric || !Array.isArray(this.browsers)) return null;
1174
- return this.browsers.find((browser) => Number(browser?.id) === numeric) || null;
1351
+ const normalizedContextId = this.normalizeContextId(contextId);
1352
+ return this.browsers.find((browser) => (
1353
+ Number(browser?.id) === numeric
1354
+ && (!normalizedContextId || this.normalizeContextId(browser?.context_id) === normalizedContextId)
1355
+ )) || null;
1356
+ },
1357
+
1358
+ contextIdForBrowserId(id) {
1359
+ const numeric = this.normalizeBrowserId(id);
1360
+ if (!numeric) return "";
1361
+ if (this.sameBrowserId(numeric, this.activeBrowserId) && this.activeBrowserContextId) {
1362
+ return this.activeBrowserContextId;
1363
+ }
1364
+ return this.normalizeContextId(this.browserById(numeric)?.context_id);
1365
+ },
1366
+
1367
+ applyBrowserListing(browsers = [], fallbackContextId = "", options = {}) {
1368
+ const incoming = Array.isArray(browsers)
1369
+ ? browsers.map((browser) => ({
1370
+ ...browser,
1371
+ context_id: this.normalizeContextId(browser?.context_id || fallbackContextId),
1372
+ })).filter((browser) => browser.id && browser.context_id)
1373
+ : [];
1374
+ const incomingKeys = new Set(incoming.map((browser) => this.browserTabKey(browser)));
1375
+ const fallback = this.normalizeContextId(fallbackContextId);
1376
+ const existing = Array.isArray(this.browsers) ? this.browsers : [];
1377
+ const retained = options.replaceAll
1378
+ ? []
1379
+ : existing.filter((browser) => {
1380
+ const key = this.browserTabKey(browser);
1381
+ if (incomingKeys.has(key)) return false;
1382
+ if (options.replaceContext && fallback && this.normalizeContextId(browser?.context_id) === fallback) return false;
1383
+ return true;
1384
+ });
1385
+ this.browsers = [...retained, ...incoming];
1386
},
1387
1388
stateFromCommandResult(result = {}) {
@@ -1187,7 +1398,12 @@ const model = {
1398
applyActiveFrameState(nextState = null) {
1399
if (!nextState) return;
1400
const stateId = this.normalizeBrowserId(nextState.id);
1190
- if (stateId && this.activeBrowserId && !this.sameBrowserId(stateId, this.activeBrowserId)) {
1401
+ const stateContextId = this.normalizeContextId(nextState.context_id || this.activeBrowserContextId);
1402
+ if (
1403
+ stateId
1404
+ && this.activeBrowserId
1405
+ && !this.sameBrowserTab(stateId, stateContextId, this.activeBrowserId, this.activeBrowserContextId)
1406
+ ) {
1407
return;
1408
}
1409
const previousUrl = String(this.frameState?.currentUrl || "");
@@ -1204,7 +1420,12 @@ const model = {
1420
applySnapshot(snapshot = null) {
1421
if (!snapshot?.image) return;
1422
const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
1207
- if (snapshotId && this.activeBrowserId && !this.sameBrowserId(snapshotId, this.activeBrowserId)) {
1423
+ const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
1424
+ if (
1425
+ snapshotId
1426
+ && this.activeBrowserId
1427
+ && !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
1428
+ ) {
1429
return;
1430
}
1431
if (snapshot.state) {
@@ -1213,8 +1434,12 @@ const model = {
1434
const frameBrowserId = snapshotId || this.activeBrowserId;
1435
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
1436
browserId: frameBrowserId,
1437
+ contextId: snapshotContextId,
1438
onAccepted: () => {
1217
- if (this.sameBrowserId(this.switchingBrowserId, frameBrowserId)) {
1439
+ if (
1440
+ this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
1441
+ && this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
1442
+ ) {
1443
this.switchingBrowserId = null;
1444
}
1445
this._surfaceSwitching = false;
@@ -1223,19 +1448,32 @@ const model = {
1448
},
1449
1450
isSwitchingBrowser() {
1226
- return Boolean(this.switchingBrowserId && this.sameBrowserId(this.switchingBrowserId, this.activeBrowserId));
1451
+ return Boolean(
1452
+ this.switchingBrowserId
1453
+ && this.sameBrowserId(this.switchingBrowserId, this.activeBrowserId)
1454
+ && this.normalizeContextId(this.contextId) === this.normalizeContextId(this.activeBrowserContextId)
1455
+ );
1456
},
1457
1458
isBusy() {
1459
return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
1460
},
1461
1233
- setActiveBrowserId(id) {
1462
+ setActiveBrowserId(id, contextId = "") {
1463
const previous = this.activeBrowserId;
1464
+ const previousContextId = this.activeBrowserContextId;
1465
const numeric = this.normalizeBrowserId(id);
1236
- const exists = !numeric || !Array.isArray(this.browsers) || this.browsers.some((browser) => Number(browser.id) === numeric);
1466
+ const normalizedContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(numeric));
1467
+ const exists = !numeric
1468
+ || !Array.isArray(this.browsers)
1469
+ || this.browsers.some((browser) => (
1470
+ Number(browser.id) === numeric
1471
+ && (!normalizedContextId || this.normalizeContextId(browser.context_id) === normalizedContextId)
1472
+ ));
1473
this.activeBrowserId = exists ? numeric : null;
1238
- if (this.activeBrowserId !== previous) {
1474
+ this.activeBrowserContextId = this.activeBrowserId ? normalizedContextId : "";
1475
+ this.contextId = this.activeBrowserContextId || this.contextId;
1476
+ if (this.activeBrowserId !== previous || this.activeBrowserContextId !== previousContextId) {
1477
this._lastViewportKey = "";
1478
this._lastViewport = null;
1479
this.cancelAnnotationDraft();
@@ -1338,9 +1576,10 @@ const model = {
1576
1577
visibleAnnotations() {
1578
const browserId = this.normalizeBrowserId(this.activeBrowserId);
1579
+ const contextId = this.normalizeContextId(this.activeBrowserContextId);
1580
const url = this.activeAnnotationUrl();
1581
return this.annotationComments.filter((annotation) => (
1343
- this.sameBrowserId(annotation.browserId, browserId)
1582
+ this.sameBrowserTab(annotation.browserId, annotation.contextId, browserId, contextId)
1583
&& String(annotation.url || "") === url
1584
));
1585
},
@@ -1350,14 +1589,15 @@ const model = {
1589
},
1590
1591
clearVisibleAnnotations() {
1353
- this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl());
1592
+ this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId);
1593
},
1594
1356
- clearAnnotationsForBrowser(browserId, url = null) {
1595
+ clearAnnotationsForBrowser(browserId, url = null, contextId = "") {
1596
const numericBrowserId = this.normalizeBrowserId(browserId);
1597
+ const normalizedContextId = this.normalizeContextId(contextId || this.activeBrowserContextId);
1598
if (!numericBrowserId) return;
1599
this.annotationComments = this.annotationComments.filter((annotation) => {
1360
- if (!this.sameBrowserId(annotation.browserId, numericBrowserId)) return true;
1600
+ if (!this.sameBrowserTab(annotation.browserId, annotation.contextId, numericBrowserId, normalizedContextId)) return true;
1601
return url ? String(annotation.url || "") !== String(url) : false;
1602
});
1603
},
@@ -1510,7 +1750,8 @@ const model = {
1750
},
1751
1752
async createAnnotationDraft(payload, fallbackRect) {
1513
- if (!this.activeBrowserId || !this.contextId) return;
1753
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
1754
+ if (!this.activeBrowserId || !contextId) return;
1755
const sequence = this._annotationSequence + 1;
1756
const browserId = this.activeBrowserId;
1757
const url = this.activeAnnotationUrl();
@@ -1522,7 +1763,7 @@ const model = {
1763
const response = await websocket.request(
1764
"browser_viewer_annotation",
1765
{
1525
- context_id: this.contextId,
1766
+ context_id: contextId,
1767
browser_id: browserId,
1768
viewer_id: this._viewerToken,
1769
payload,
@@ -1535,6 +1776,7 @@ const model = {
1776
this.annotationDraft = {
1777
id: makeViewerToken(),
1778
browserId,
1779
+ contextId,
1780
url,
1781
title,
1782
kind: metadata.kind || payload.kind,
@@ -1733,21 +1975,22 @@ const model = {
1975
1976
async syncViewport(force = false, options = {}) {
1977
const restartStream = Boolean(options.restartStream);
1736
- if (!this.contextId || !this.activeBrowserId) {
1978
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
1979
+ if (!contextId || !this.activeBrowserId) {
1980
return;
1981
}
1982
const viewport = this.currentViewportSize();
1983
if (!viewport) {
1984
return;
1985
}
1743
- const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
1986
+ const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
1987
if (
1988
(!restartStream && this._lastViewportKey === key)
1989
|| (
1990
!force
1991
&& !restartStream
1992
&& this._lastViewport
1750
- && this.sameBrowserId(this._lastViewport.browserId, this.activeBrowserId)
1993
+ && this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, this.activeBrowserId, contextId)
1994
&& Math.abs(this._lastViewport.width - viewport.width) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1995
&& Math.abs(this._lastViewport.height - viewport.height) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1996
)
@@ -1756,7 +1999,7 @@ const model = {
1999
}
2000
try {
2001
await websocket.emit("browser_viewer_input", {
1759
- context_id: this.contextId,
2002
+ context_id: contextId,
2003
browser_id: this.activeBrowserId,
2004
viewer_id: this._viewerToken,
2005
input_type: "viewport",
@@ -1767,6 +2010,7 @@ const model = {
2010
this._lastViewportKey = key;
2011
this._lastViewport = {
2012
browserId: this.activeBrowserId,
2013
+ contextId,
2014
width: viewport.width,
2015
height: viewport.height,
2016
};
@@ -1779,11 +2023,12 @@ const model = {
2023
2024
async sendMouse(eventType, event) {
2025
if (this.annotating) return;
1782
- if (!this.activeBrowserId || !event?.currentTarget) return;
2026
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2027
+ if (!contextId || !this.activeBrowserId || !event?.currentTarget) return;
2028
const pointer = this.pointerCoordinatesFor(event);
2029
if (!pointer) return;
2030
const payload = {
1786
- context_id: this.contextId,
2031
+ context_id: contextId,
2032
browser_id: this.activeBrowserId,
2033
viewer_id: this._viewerToken,
2034
input_type: "mouse",
@@ -1807,12 +2052,13 @@ const model = {
2052
},
2053
2054
async sendWheel(event) {
1810
- if (!this.activeBrowserId || !event) return;
2055
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2056
+ if (!contextId || !this.activeBrowserId || !event) return;
2057
const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
2058
const pointer = this.pointerCoordinatesFor(event, image);
2059
if (!pointer) return;
2060
const payload = {
1815
- context_id: this.contextId,
2061
+ context_id: contextId,
2062
browser_id: this.activeBrowserId,
2063
viewer_id: this._viewerToken,
2064
input_type: "wheel",
@@ -1830,14 +2076,15 @@ const model = {
2076
2077
async sendKey(event) {
2078
if (this.annotating) return;
1833
- if (!this.activeBrowserId) return;
2079
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2080
+ if (!contextId || !this.activeBrowserId) return;
2081
if (event.ctrlKey || event.metaKey || event.altKey) return;
2082
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
2083
if (editable) return;
2084
event.preventDefault();
2085
const printable = event.key && event.key.length === 1;
2086
await websocket.emit("browser_viewer_input", {
1840
- context_id: this.contextId,
2087
+ context_id: contextId,
2088
browser_id: this.activeBrowserId,
2089
input_type: "keyboard",
2090
key: printable ? "" : event.key,
tests/test_browser_agent_regressions.py
+138
-6
@@ -97,6 +97,7 @@ import plugins._browser.helpers.extension_manager as browser_extension_manager_m
97
from plugins._browser.helpers.runtime import (
98
_BrowserRuntimeCore,
99
_BrowserScreencast,
100
+ list_runtime_sessions,
101
normalize_url,
102
)
103
import plugins._browser.helpers.runtime as browser_runtime_module
@@ -373,7 +374,11 @@ def test_browser_viewer_creates_chat_when_no_context_is_selected():
374
assert 'import { getContext, setContext } from "/index.js";' in js
375
assert "setContext(contextId)" in js
376
assert "chatsStore.setSelected?.(contextId)" in js
376
- assert "await this.ensureContextId();" in js
377
+ assert "this.contextId = existingContextId;" in js
378
+ assert "this.contextId = contextId;" in js
379
+ assert "let targetContextId = requestedContextId;" in js
380
+ assert "targetContextId = await this.ensureContextId();" in js
381
+ assert "contextId: targetContextId" in js
382
assert "No active chat context is selected." not in js
383
384
@@ -455,7 +460,7 @@ def test_browser_canvas_restarts_stream_after_page_navigation():
460
assert "await this.restartCanvasStreamAfterPageChange();" in js
461
assert "await this.waitForSurfaceViewport({ sequence: surfaceSequence });" in js
462
assert "await this.syncViewport(true, { restartStream: true });" in js
458
- reconnect_index = js.index("await this.connectViewer({ browserId: this.activeBrowserId });")
463
+ reconnect_index = js.index("contextId: this.activeBrowserContextId")
464
restart_index = js.index("await this.restartCanvasStreamAfterPageChange();")
465
assert reconnect_index < restart_index
466
@@ -623,6 +628,18 @@ def test_browser_viewer_uses_tabs_for_session_switching():
628
assert 'class="browser-session-tabs" role="tablist"' in main_html
629
assert 'class="browser-tab"' in main_html
630
assert 'class="browser-new-tab"' in main_html
631
+ assert ':key="$store.browserPage.browserTabKey(browser)"' in main_html
632
+ assert "browser.context_id" in main_html
633
+ assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
634
+ assert "browser-tab-context" not in main_html
635
+ assert 'handleSelectedContextChange($store.chats?.selected)' in main_html
636
+ assert "activeBrowserContextId" in browser_store
637
+ assert "sameBrowserTab" in browser_store
638
+ assert "applyBrowserListing" in browser_store
639
+ assert "browserTabTooltip(browser)" in browser_store
640
+ assert "browserChatTitle(browser = {})" in browser_store
641
+ assert "contextId.slice" not in browser_store
642
+ assert '"browser_viewer_sessions"' in browser_store
643
assert "$store.browserPage.openNewBrowser()" in main_html
644
assert "browser-select" not in main_html
645
assert "browser-live-dot" not in main_html
@@ -644,14 +661,14 @@ def test_browser_tabs_close_without_confirmation_or_busy_lock():
661
close_end = main_html.index("</button>", close_start)
662
close_markup = main_html[close_start:close_end]
663
647
- assert '@click.stop="$store.browserPage.closeBrowser(browser.id)"' in main_html
664
+ assert '@click.stop="$store.browserPage.closeBrowser(browser.id, browser.context_id)"' in main_html
665
assert "@pointerdown.stop" in close_markup
666
assert "$confirmClick" not in main_html
667
assert "isBusy()" not in close_markup
651
- assert ':disabled="$store.browserPage.isClosingBrowser(browser.id)"' in close_markup
668
+ assert ':disabled="$store.browserPage.isClosingBrowser(browser.id, browser.context_id)"' in close_markup
669
assert "--browser-tab-close-size: 32px;" in main_html
653
- assert "async closeBrowser(id)" in browser_store
654
- assert "isClosingBrowser(id)" in browser_store
670
+ assert "async closeBrowser(id, contextId = \"\")" in browser_store
671
+ assert "isClosingBrowser(id, contextId = \"\")" in browser_store
672
assert "_closingBrowserIds" in browser_store
673
assert "_commandInFlightCount" in browser_store
674
@@ -1088,6 +1105,121 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
1105
assert ("sid-1", "ctx") not in ws_browser_module.WsBrowser._streams
1106
1107
1108
+@pytest.mark.anyio
1109
+async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
1110
+ class FakeRuntime:
1111
+ async def call(self, method, *args, **kwargs):
1112
+ assert method == "list"
1113
+ return {
1114
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"}],
1115
+ "last_interacted_browser_id": 1,
1116
+ }
1117
+
1118
+ with browser_runtime_module._runtime_lock:
1119
+ previous_runtimes = dict(browser_runtime_module._runtimes)
1120
+ browser_runtime_module._runtimes.clear()
1121
+ browser_runtime_module._runtimes["ctx-a"] = FakeRuntime()
1122
+ try:
1123
+ sessions = await list_runtime_sessions()
1124
+ finally:
1125
+ with browser_runtime_module._runtime_lock:
1126
+ browser_runtime_module._runtimes.clear()
1127
+ browser_runtime_module._runtimes.update(previous_runtimes)
1128
+
1129
+ assert sessions == [
1130
+ {
1131
+ "context_id": "ctx-a",
1132
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"}],
1133
+ "last_interacted_browser_id": 1,
1134
+ }
1135
+ ]
1136
+
1137
+
1138
+@pytest.mark.anyio
1139
+async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch):
1140
+ class FakeRuntime:
1141
+ async def call(self, method, *args, **kwargs):
1142
+ if method == "list":
1143
+ return {
1144
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1145
+ "last_interacted_browser_id": 1,
1146
+ }
1147
+ raise AssertionError(method)
1148
+
1149
+ async def fake_get_runtime(context_id, create=True):
1150
+ assert context_id == "ctx-a"
1151
+ return FakeRuntime()
1152
+
1153
+ async def fake_list_runtime_sessions():
1154
+ return [
1155
+ {
1156
+ "context_id": "ctx-a",
1157
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1158
+ "last_interacted_browser_id": 1,
1159
+ },
1160
+ {
1161
+ "context_id": "ctx-b",
1162
+ "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"}],
1163
+ "last_interacted_browser_id": 1,
1164
+ },
1165
+ ]
1166
+
1167
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
1168
+ monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
1169
+
1170
+ handler = ws_browser_module.WsBrowser(
1171
+ SimpleNamespace(),
1172
+ threading.RLock(),
1173
+ manager=None,
1174
+ )
1175
+
1176
+ result = await handler.process(
1177
+ "browser_viewer_command",
1178
+ {"context_id": "ctx-a", "command": "list"},
1179
+ "sid-1",
1180
+ )
1181
+
1182
+ assert result["all_browsers"] is True
1183
+ assert result["active_browser_context_id"] == "ctx-a"
1184
+ assert [browser["context_id"] for browser in result["browsers"]] == ["ctx-a", "ctx-b"]
1185
+
1186
+
1187
+@pytest.mark.anyio
1188
+async def test_browser_viewer_sessions_lists_without_creating_runtime(monkeypatch):
1189
+ async def fake_list_runtime_sessions():
1190
+ return [
1191
+ {
1192
+ "context_id": "ctx-a",
1193
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1194
+ "last_interacted_browser_id": 1,
1195
+ }
1196
+ ]
1197
+
1198
+ async def fail_get_runtime(*args, **kwargs):
1199
+ raise AssertionError("sessions refresh must not create or fetch one runtime")
1200
+
1201
+ monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
1202
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fail_get_runtime)
1203
+
1204
+ handler = ws_browser_module.WsBrowser(
1205
+ SimpleNamespace(),
1206
+ threading.RLock(),
1207
+ manager=None,
1208
+ )
1209
+
1210
+ result = await handler.process(
1211
+ "browser_viewer_sessions",
1212
+ {"context_id": "ctx-b"},
1213
+ "sid-1",
1214
+ )
1215
+
1216
+ assert result == {
1217
+ "context_id": "ctx-b",
1218
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1219
+ "all_browsers": True,
1220
+ }
1221
+
1222
+
1223
@pytest.mark.anyio
1224
async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
1225
calls = []