Restore internal Browser tabs automatically
Persist Browser tab ownership and URLs in the shared KVP store, migrate an existing Chromium session once, and restore tabs automatically for per-chat and shared scopes without showing Chromium's crash advisory. Add composer-style microphone and arrow controls to the annotation dialog while preserving draft-versus-send voice behavior.
Alessandro committed
Aug 16, 2026 at 18:02 UTC
1fb9363b49b72a2e247827fac0beb960c6ae5e75
7 files changed
+525
-24
plugins/_browser/AGENTS.md
+2
@@ -21,6 +21,8 @@
21
- Default the visible WebUI Browser to the authenticated Xpra HTML5 viewer for its existing Patchright page. Keep live CDP screencast and lightweight snapshots as automatic fallbacks.
22
- Do not block an available interactive viewer on a redundant Chromium screenshot; capture initial snapshots only for fallback transports.
23
- Keep headful Chromium in a normal window with its own toolbar clipped above the private display; do not use browser fullscreen, which shows Chromium's exit warning.
24
+- Persist open-tab ownership and URLs through the shared KVP store; automatically restore the current chat when its Browser surface opens in per-chat mode and every saved chat in shared mode, then hide Chromium's redundant crash-restore advisory.
25
+- When no Browser tab manifest exists yet, use Chromium's last session once to migrate open tabs into the owned manifest.
26
- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops.
27
- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
28
- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
plugins/_browser/api/ws_browser.py
+7
-1
@@ -14,7 +14,11 @@ from plugins._browser.helpers.config import (
14
TAB_SCOPE_KEY,
15
get_browser_config,
16
)
17
-from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
17
+from plugins._browser.helpers.runtime import (
18
+ get_runtime,
19
+ has_restorable_browser_tabs,
20
+ list_runtime_sessions,
21
+)
22
23
24
FRAME_READ_TIMEOUT_SECONDS = 0.5
@@ -80,6 +84,8 @@ class WsBrowser(WsHandler):
84
85
create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
86
runtime = await get_runtime(context_id, create=create_browser)
87
+ if not runtime and not create_browser and has_restorable_browser_tabs(context_id):
88
+ runtime = await get_runtime(context_id)
89
listing = {"browsers": [], "last_interacted_browser_id": None}
90
browsers: list[dict[str, Any]] = []
91
if runtime:
plugins/_browser/helpers/config.py
+1
-1
@@ -408,7 +408,7 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
408
def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
409
config = normalize_browser_config(settings)
410
extensions = describe_browser_extensions(config)
411
- args: list[str] = []
411
+ args = ["--hide-crash-restore-bubble"]
412
channel: str | None = None
413
browser_mode = "chromium"
414
proxy = None
plugins/_browser/helpers/runtime.py
+306
-3
@@ -16,15 +16,17 @@ from dataclasses import dataclass
16
from pathlib import Path
17
from typing import Any
18
19
-from helpers import chat_media, files
19
+from helpers import chat_media, files, kvp
20
from helpers.defer import DeferredTask
21
from helpers.errors import RepairableException
22
from helpers.print_style import PrintStyle
23
24
from plugins._browser.helpers.config import (
25
+ DEFAULT_BROWSER_TAB_SCOPE,
26
DEFAULT_HOMEPAGE_KEY,
27
DEFAULT_MAX_OPEN_TABS,
28
MAX_OPEN_TABS_KEY,
29
+ TAB_SCOPE_KEY,
30
build_browser_launch_config,
31
get_browser_config,
32
)
@@ -37,6 +39,8 @@ DOM_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-dom-helper.js"
39
CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
40
RUNTIME_DATA_KEY = "_browser_runtime"
41
SHARED_RUNTIME_ID = "shared"
42
+BROWSER_TABS_KEY = "browser_open_tabs"
43
+BROWSER_TABS_VERSION = 1
44
DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
45
CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
46
SCREENCAST_MAX_WIDTH = 4096
@@ -287,6 +291,85 @@ def _safe_context_id(context_id: str) -> str:
291
return _SAFE_CONTEXT_RE.sub("_", str(context_id or "default")).strip("._") or "default"
292
293
294
+def _load_browser_tabs() -> tuple[bool, list[dict[str, Any]]]:
295
+ try:
296
+ payload = kvp.get_persistent(BROWSER_TABS_KEY, None)
297
+ except Exception as exc:
298
+ PrintStyle.warning(f"Browser tab recovery state could not be read: {exc}")
299
+ return False, []
300
+ if payload is None:
301
+ return False, []
302
+ if not isinstance(payload, dict) or not isinstance(payload.get("tabs"), list):
303
+ PrintStyle.warning("Browser tab recovery state is invalid; starting without it.")
304
+ return False, []
305
+
306
+ tabs: list[dict[str, Any]] = []
307
+ for entry in payload["tabs"]:
308
+ if not isinstance(entry, dict):
309
+ continue
310
+ context_id = str(entry.get("context_id") or "").strip()
311
+ url = str(entry.get("url") or "").strip()
312
+ if not context_id or not url:
313
+ continue
314
+ tabs.append(
315
+ {
316
+ "context_id": context_id,
317
+ "url": url,
318
+ "active": bool(entry.get("active")),
319
+ }
320
+ )
321
+ return True, tabs
322
+
323
+
324
+def _save_browser_tabs(tabs: list[dict[str, Any]]) -> None:
325
+ kvp.set_persistent(
326
+ BROWSER_TABS_KEY,
327
+ {"version": BROWSER_TABS_VERSION, "tabs": tabs},
328
+ )
329
+
330
+
331
+def _forget_browser_context(context_id: str) -> None:
332
+ exists, tabs = _load_browser_tabs()
333
+ if not exists:
334
+ return
335
+ remaining = [entry for entry in tabs if entry["context_id"] != context_id]
336
+ if len(remaining) != len(tabs):
337
+ try:
338
+ _save_browser_tabs(remaining)
339
+ except Exception as exc:
340
+ PrintStyle.warning(f"Browser tab recovery state could not be updated: {exc}")
341
+
342
+
343
+def has_restorable_browser_tabs(context_id: str) -> bool:
344
+ exists, tabs = _load_browser_tabs()
345
+ if not exists:
346
+ for runtime_id in (SHARED_RUNTIME_ID, _safe_context_id(context_id)):
347
+ session_dir = Path(
348
+ files.get_abs_path(
349
+ "tmp",
350
+ "browser",
351
+ "sessions",
352
+ runtime_id,
353
+ "Default",
354
+ "Sessions",
355
+ )
356
+ )
357
+ try:
358
+ if any(session_dir.glob("Session_*")) or any(session_dir.glob("Tabs_*")):
359
+ return True
360
+ except OSError:
361
+ continue
362
+ return False
363
+ if not tabs:
364
+ return False
365
+ if str(
366
+ get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
367
+ or DEFAULT_BROWSER_TAB_SCOPE
368
+ ) == "shared":
369
+ return True
370
+ return any(entry["context_id"] == str(context_id) for entry in tabs)
371
+
372
+
373
@dataclass
374
class BrowserPage:
375
id: int
@@ -625,6 +708,12 @@ class _BrowserRuntimeCore:
708
self._pending_popup_contexts: dict[asyncio.Future[int], str] = {}
709
self._background_popup_pages: set[int] = set()
710
self._bootstrap_page: Any | None = None
711
+ self._restore_state_exists = False
712
+ self._restore_state_loaded = False
713
+ self._restore_entries: list[dict[str, Any]] = []
714
+ self._restored_context_ids: set[str] = set()
715
+ self._restored_all = False
716
+ self._restoring_tabs = False
717
self._browser_chrome_height: int | None = None
718
self._browser_window_page: Any | None = None
719
self._browser_window_session: Any | None = None
@@ -650,6 +739,123 @@ class _BrowserRuntimeCore:
739
else:
740
self._last_interacted_browser_ids[context_id] = int(browser_id)
741
742
+ def _load_restore_state(self) -> None:
743
+ self._restore_state_exists, self._restore_entries = _load_browser_tabs()
744
+ self._restore_state_loaded = True
745
+ self._restored_context_ids.clear()
746
+ self._restored_all = False
747
+ self._restoring_tabs = False
748
+
749
+ def _tab_scope(self) -> str:
750
+ return str(
751
+ get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
752
+ or DEFAULT_BROWSER_TAB_SCOPE
753
+ )
754
+
755
+ def _persist_browser_tabs(self) -> None:
756
+ if not self._restore_state_loaded or self._restoring_tabs:
757
+ return
758
+
759
+ replaced_contexts = set(self._restored_context_ids)
760
+ live_tabs: list[dict[str, Any]] = []
761
+ for browser_id in sorted(self.pages):
762
+ browser_page = self.pages[browser_id]
763
+ context_id = self._page_context_id(browser_page)
764
+ replaced_contexts.add(context_id)
765
+ try:
766
+ url = str(browser_page.page.url or "about:blank").strip()
767
+ except Exception:
768
+ continue
769
+ if not url:
770
+ url = "about:blank"
771
+ live_tabs.append(
772
+ {
773
+ "context_id": context_id,
774
+ "url": url,
775
+ "active": (
776
+ self._last_interacted_browser_ids.get(context_id) == browser_id
777
+ ),
778
+ }
779
+ )
780
+
781
+ preserved_tabs = (
782
+ []
783
+ if self._restored_all
784
+ else [
785
+ entry
786
+ for entry in self._restore_entries
787
+ if entry["context_id"] not in replaced_contexts
788
+ ]
789
+ )
790
+ tabs = preserved_tabs + live_tabs
791
+ try:
792
+ _save_browser_tabs(tabs)
793
+ except Exception as exc:
794
+ PrintStyle.warning(f"Browser tab recovery state could not be saved: {exc}")
795
+ return
796
+ self._restore_state_exists = True
797
+ self._restore_entries = tabs
798
+
799
+ async def _restore_tabs_for_scope(self) -> None:
800
+ if not self._restore_state_loaded or self._restoring_tabs or not self.context:
801
+ return
802
+
803
+ tab_scope = self._tab_scope()
804
+ if tab_scope == "shared":
805
+ if self._restored_all:
806
+ return
807
+ entries = [
808
+ entry
809
+ for entry in self._restore_entries
810
+ if entry["context_id"] not in self._restored_context_ids
811
+ ]
812
+ else:
813
+ context_id = self.current_context_id
814
+ if self._restored_all or context_id in self._restored_context_ids:
815
+ return
816
+ entries = [
817
+ entry for entry in self._restore_entries if entry["context_id"] == context_id
818
+ ]
819
+
820
+ restored_ids: dict[str, list[int]] = {}
821
+ active_ids: dict[str, int] = {}
822
+ navigations: list[tuple[Any, str]] = []
823
+ self._restoring_tabs = True
824
+ try:
825
+ for entry in entries:
826
+ context_id = entry["context_id"]
827
+ if len(self._context_browser_ids(context_id)) >= self._max_open_tabs():
828
+ continue
829
+ page = self._bootstrap_page
830
+ self._bootstrap_page = None
831
+ if not page or page.is_closed():
832
+ page = await self.context.new_page()
833
+ browser_page = await self._register_page(page, context_id)
834
+ navigations.append((page, normalize_url(entry["url"])))
835
+ restored_ids.setdefault(context_id, []).append(browser_page.id)
836
+ if entry["active"]:
837
+ active_ids[context_id] = browser_page.id
838
+ await asyncio.gather(
839
+ *(
840
+ self._goto(page, url, wait_until="commit")
841
+ for page, url in navigations
842
+ )
843
+ )
844
+ finally:
845
+ self._restoring_tabs = False
846
+
847
+ for context_id, browser_ids in restored_ids.items():
848
+ self._set_last_interacted(
849
+ context_id,
850
+ active_ids.get(context_id, browser_ids[0]),
851
+ )
852
+ if tab_scope == "shared":
853
+ self._restored_all = True
854
+ self._restored_context_ids.update(entry["context_id"] for entry in entries)
855
+ else:
856
+ self._restored_context_ids.add(self.current_context_id)
857
+ self._persist_browser_tabs()
858
+
859
def _page_context_id(self, browser_page: BrowserPage) -> str:
860
return str(browser_page.context_id or self.context_id)
861
@@ -803,6 +1009,7 @@ class _BrowserRuntimeCore:
1009
1010
async def ensure_started(self) -> None:
1011
if self._context_is_alive():
1012
+ await self._restore_tabs_for_scope()
1013
return
1014
if self.context:
1015
await self._discard_stale_context("Browser context is stale; restarting.")
@@ -812,12 +1019,14 @@ class _BrowserRuntimeCore:
1019
1020
async with self._start_lock:
1021
if self._context_is_alive():
1022
+ await self._restore_tabs_for_scope()
1023
return
1024
if self.context:
1025
await self._discard_stale_context("Browser context is stale; restarting.")
1026
elif self.playwright and not self._closing:
1027
await self._stop_playwright("Browser context closed; restarting Playwright.")
1028
await self._start()
1029
+ await self._restore_tabs_for_scope()
1030
1031
def _context_is_alive(self) -> bool:
1032
if not self.context:
@@ -875,6 +1084,7 @@ class _BrowserRuntimeCore:
1084
async def _start(self) -> None:
1085
from plugins._browser import hooks
1086
1087
+ self._load_restore_state()
1088
preparation = hooks.prepare_playwright_cache()
1089
if preparation.get("errors") or not preparation.get("binary"):
1090
problem = preparation.get("errors") or "missing binary"
@@ -891,6 +1101,8 @@ class _BrowserRuntimeCore:
1101
browser_binary = Path(preparation["binary"])
1102
browser_display = self.interactive_view.ensure_display()
1103
launch_args = list(launch_config["args"])
1104
+ if not self._restore_state_exists:
1105
+ launch_args.append("--restore-last-session")
1106
if browser_display:
1107
launch_args.extend(
1108
[
@@ -939,7 +1151,18 @@ class _BrowserRuntimeCore:
1151
self.context.on("close", self._on_context_closed)
1152
self.context.on("page", self._on_new_page_sync)
1153
942
- for page in list(self.context.pages):
1154
+ existing_pages = list(self.context.pages)
1155
+ if self._restore_state_exists:
1156
+ for page in existing_pages:
1157
+ if self._bootstrap_page is None:
1158
+ self._bootstrap_page = page
1159
+ await self._fit_browser_window(page)
1160
+ continue
1161
+ with contextlib.suppress(Exception):
1162
+ await page.close()
1163
+ return
1164
+
1165
+ for page in existing_pages:
1166
if page.url == "about:blank":
1167
if browser_display and self._bootstrap_page is None:
1168
self._bootstrap_page = page
@@ -1043,6 +1266,7 @@ class _BrowserRuntimeCore:
1266
await self._goto(page, normalize_url(target_url))
1267
else:
1268
await self._settle(page)
1269
+ self._persist_browser_tabs()
1270
return {"id": browser_page.id, "state": await self._state(browser_page.id)}
1271
1272
def _initial_url(self, url: str = "") -> str:
@@ -1101,6 +1325,16 @@ class _BrowserRuntimeCore:
1325
"last_interacted_browser_id": self.last_interacted_browser_id,
1326
}
1327
1328
+ async def list_all(self) -> dict[str, Any]:
1329
+ await self.ensure_started()
1330
+ browser_ids = sorted(self.pages)
1331
+ return {
1332
+ "browsers": await asyncio.gather(
1333
+ *(self._state(browser_id) for browser_id in browser_ids)
1334
+ ),
1335
+ "last_interacted_browser_ids": dict(self._last_interacted_browser_ids),
1336
+ }
1337
+
1338
async def multi(self, calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
1339
if not isinstance(calls, list) or not calls:
1340
raise ValueError("multi requires a non-empty list of calls")
@@ -1347,6 +1581,7 @@ class _BrowserRuntimeCore:
1581
with contextlib.suppress(Exception):
1582
await page.bring_to_front()
1583
await self._fit_browser_window(page)
1584
+ self._persist_browser_tabs()
1585
return await self._state(resolved_id)
1586
1587
async def state(self, browser_id: int | str | None = None) -> dict[str, Any]:
@@ -1365,6 +1600,7 @@ class _BrowserRuntimeCore:
1600
page = self._page(resolved_id)
1601
await self._goto(page, normalize_url(url), wait_until=wait_until)
1602
self._maybe_promote(resolved_id)
1603
+ self._persist_browser_tabs()
1604
return await self._state(resolved_id)
1605
1606
async def back(
@@ -1379,6 +1615,7 @@ class _BrowserRuntimeCore:
1615
await page.go_back(wait_until=wait_until, timeout=10000)
1616
await self._settle(page, short=wait_until == "commit")
1617
self._maybe_promote(resolved_id)
1618
+ self._persist_browser_tabs()
1619
return await self._state(resolved_id)
1620
1621
async def forward(
@@ -1393,6 +1630,7 @@ class _BrowserRuntimeCore:
1630
await page.go_forward(wait_until=wait_until, timeout=10000)
1631
await self._settle(page, short=wait_until == "commit")
1632
self._maybe_promote(resolved_id)
1633
+ self._persist_browser_tabs()
1634
return await self._state(resolved_id)
1635
1636
async def reload(
@@ -1407,6 +1645,7 @@ class _BrowserRuntimeCore:
1645
await page.reload(wait_until=wait_until, timeout=15000)
1646
await self._settle(page, short=wait_until == "commit")
1647
self._maybe_promote(resolved_id)
1648
+ self._persist_browser_tabs()
1649
return await self._state(resolved_id)
1650
1651
async def content(
@@ -1690,6 +1929,7 @@ class _BrowserRuntimeCore:
1929
self.pages.pop(resolved_id, None)
1930
if self.last_interacted_browser_id == resolved_id:
1931
self.last_interacted_browser_id = next(iter(self._context_browser_ids()), None)
1932
+ self._persist_browser_tabs()
1933
return await self.list()
1934
1935
async def close_all_browsers(self) -> dict[str, Any]:
@@ -1706,6 +1946,8 @@ class _BrowserRuntimeCore:
1946
pass
1947
self.pages.pop(browser_id, None)
1948
self.last_interacted_browser_id = None
1949
+ self._restored_context_ids.add(self.current_context_id)
1950
+ self._persist_browser_tabs()
1951
1952
async def screenshot(
1953
self,
@@ -2361,6 +2603,13 @@ class _BrowserRuntimeCore:
2603
return True
2604
2605
async def close(self, delete_profile: bool = False) -> None:
2606
+ if delete_profile:
2607
+ with contextlib.suppress(Exception):
2608
+ kvp.remove_persistent(BROWSER_TABS_KEY)
2609
+ self._restore_entries.clear()
2610
+ self._restore_state_exists = False
2611
+ else:
2612
+ self._persist_browser_tabs()
2613
self._closing = True
2614
for waiter in self._pending_popups:
2615
if not waiter.done():
@@ -2516,6 +2765,17 @@ class _BrowserRuntimeCore:
2765
self.pages.pop(browser_id, None)
2766
2767
page.on("close", on_close)
2768
+
2769
+ def on_navigated(frame: Any) -> None:
2770
+ main_frame = getattr(page, "main_frame", None)
2771
+ if main_frame is not None and frame is not main_frame:
2772
+ return
2773
+ try:
2774
+ asyncio.create_task(self._persist_page_change_async(browser_id))
2775
+ except RuntimeError:
2776
+ return
2777
+
2778
+ page.on("framenavigated", on_navigated)
2779
return browser_page
2780
2781
async def _register_page(
@@ -2604,6 +2864,12 @@ class _BrowserRuntimeCore:
2864
except Exception as exc:
2865
PrintStyle.warning(f"Page unregister failed: {exc}")
2866
2867
+ async def _persist_page_change_async(self, browser_id: int) -> None:
2868
+ await asyncio.sleep(0)
2869
+ if self._closing or self._restoring_tabs or browser_id not in self.pages:
2870
+ return
2871
+ self._persist_browser_tabs()
2872
+
2873
def _on_new_page_sync(self, page: Any) -> None:
2874
if self._closing or self.context is None:
2875
return
@@ -2647,6 +2913,7 @@ class _BrowserRuntimeCore:
2913
await page.close()
2914
else:
2915
await self._fit_browser_window(page)
2916
+ self._persist_browser_tabs()
2917
except Exception as exc:
2918
PrintStyle.warning(f"Popup registration failed: {exc}")
2919
@@ -2767,7 +3034,11 @@ async def get_runtime(
3034
raise ValueError("context_id is required")
3035
with _runtime_lock:
3036
runtime = _runtimes.get(context_id)
2770
- if runtime is None and create:
3037
+ if runtime is None and _shared_runtime is not None:
3038
+ runtime = BrowserRuntimeSession(context_id, _shared_runtime)
3039
+ if create:
3040
+ _runtimes[context_id] = runtime
3041
+ elif runtime is None and create:
3042
if _shared_runtime is None:
3043
_shared_runtime = BrowserRuntime(SHARED_RUNTIME_ID)
3044
runtime = BrowserRuntimeSession(context_id, _shared_runtime)
@@ -2779,10 +3050,14 @@ async def close_runtime(context_id: str, *, delete_profile: bool = True) -> None
3050
context_id = str(context_id or "").strip()
3051
if not context_id:
3052
return
3053
+ _forget_browser_context(context_id)
3054
with _runtime_lock:
3055
runtime = _runtimes.pop(context_id, None)
3056
+ shared_runtime = _shared_runtime
3057
if runtime:
3058
await runtime.call("close_context")
3059
+ elif shared_runtime:
3060
+ await shared_runtime.call_for(context_id, "close_context")
3061
3062
3063
def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None:
@@ -2800,6 +3075,9 @@ async def close_all_runtimes(*, delete_profiles: bool = False) -> None:
3075
_runtimes.clear()
3076
runtime = _shared_runtime
3077
_shared_runtime = None
3078
+ if delete_profiles:
3079
+ with contextlib.suppress(Exception):
3080
+ kvp.remove_persistent(BROWSER_TABS_KEY)
3081
if runtime:
3082
try:
3083
await runtime.close(delete_profile=delete_profiles)
@@ -2824,6 +3102,31 @@ def known_context_ids() -> list[str]:
3102
async def list_runtime_sessions() -> list[dict[str, Any]]:
3103
with _runtime_lock:
3104
runtimes = list(_runtimes.items())
3105
+ shared_runtime = _shared_runtime
3106
+
3107
+ if shared_runtime and str(
3108
+ get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
3109
+ or DEFAULT_BROWSER_TAB_SCOPE
3110
+ ) == "shared":
3111
+ request_context_id = runtimes[0][0] if runtimes else SHARED_RUNTIME_ID
3112
+ try:
3113
+ listing = await shared_runtime.call_for(request_context_id, "list_all")
3114
+ except Exception as exc:
3115
+ PrintStyle.warning(f"Shared Browser runtime list failed: {exc}")
3116
+ return []
3117
+ grouped: dict[str, list[dict[str, Any]]] = {}
3118
+ for browser in listing.get("browsers") or []:
3119
+ context_id = str(browser.get("context_id") or SHARED_RUNTIME_ID)
3120
+ grouped.setdefault(context_id, []).append(browser)
3121
+ active_ids = listing.get("last_interacted_browser_ids") or {}
3122
+ return [
3123
+ {
3124
+ "context_id": context_id,
3125
+ "browsers": browsers,
3126
+ "last_interacted_browser_id": active_ids.get(context_id),
3127
+ }
3128
+ for context_id, browsers in grouped.items()
3129
+ ]
3130
3131
sessions: list[dict[str, Any]] = []
3132
for context_id, runtime in runtimes:
plugins/_browser/webui/browser-panel.html
+17
-13
@@ -255,11 +255,20 @@
255
<textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment"
256
maxlength="1200" x-init="$nextTick(() => $el.focus())"></textarea>
257
<div class="browser-annotation-actions">
258
- <button type="button" class="btn btn-ok browser-annotation-add"
258
+ <button type="button" class="browser-annotation-mic mic-inactive"
259
+ data-whisper-microphone title="Record annotation comment"
260
+ aria-label="Record annotation comment"
261
+ x-init="$nextTick(() => $store.browserPage.syncAnnotationMicrophoneUI())"
262
+ @click="$store.browserPage.startAnnotationVoice(true)">
263
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18" fill="currentColor" aria-hidden="true">
264
+ <path d="m8,12c1.66,0,3-1.34,3-3V3c0-1.66-1.34-3-3-3s-3,1.34-3,3v6c0,1.66,1.34,3,3,3Zm-1,1.9c-2.7-.4-4.8-2.6-5-5.4H0c.2,3.8,3.1,6.9,7,7.5v2h2v-2c3.9-.6,6.8-3.7,7-7.5h-2c-.2,2.8-2.3,5-5,5.4h-2Z" />
265
+ </svg>
266
+ </button>
267
+ <button type="button" class="browser-annotation-send"
268
title="Add annotation" aria-label="Add annotation"
269
:disabled="!String($store.browserPage.annotationDraftText || '').trim()"
270
@click="$store.browserPage.addAnnotationComment()">
262
- <x-icon name="add_comment"></x-icon>
271
+ <x-icon name="arrow_forward"></x-icon>
272
</button>
273
</div>
274
</div>
@@ -1208,23 +1217,12 @@
1217
gap: 7px;
1218
}
1219
1211
- .browser-annotation-actions .btn,
1220
.browser-annotation-tray-actions .btn {
1221
min-height: 30px;
1222
padding: 0 10px;
1223
white-space: nowrap;
1224
}
1225
1218
- .browser-annotation-add {
1219
- width: 34px;
1220
- min-width: 34px;
1221
- padding: 0;
1222
- }
1223
-
1224
- .browser-annotation-add .material-symbols-outlined {
1225
- font-size: 18px;
1226
- }
1227
-
1226
.browser-annotation-tray {
1227
z-index: 20;
1228
right: 10px;
@@ -1319,6 +1317,7 @@
1317
line-height: 1.25;
1318
}
1319
1320
+ .browser-annotation-actions,
1321
.browser-annotation-tray-actions {
1322
align-items: center;
1323
gap: 0.58rem;
@@ -1382,6 +1381,11 @@
1381
1382
.browser-annotation-send:hover { background: #353bc5; }
1383
.browser-annotation-send:active { background: #2b309c; transform: translateY(1px) scale(0.98); }
1384
+ .browser-annotation-send:disabled {
1385
+ background: #4248f1;
1386
+ cursor: not-allowed;
1387
+ opacity: 0.5;
1388
+ }
1389
1390
.browser-annotation-mic svg,
1391
.browser-annotation-send .material-symbols-outlined {
plugins/_browser/webui/browser-store.js
+12
-2
@@ -2718,13 +2718,23 @@ const model = {
2718
}
2719
},
2720
2721
- async startAnnotationVoice() {
2721
+ async startAnnotationVoice(draftComment = false) {
2722
try {
2723
const { store: whisperStore } = await import(
2724
"/plugins/_whisper_stt/webui/whisper-stt-store.js"
2725
);
2726
await whisperStore.handleMicrophoneClick(async (text, options = {}) => {
2727
- if (options.sendImmediately) {
2727
+ if (draftComment) {
2728
+ const transcript = String(text || "").trim();
2729
+ if (transcript && this.annotationDraft) {
2730
+ const existing = String(this.annotationDraftText || "").trim();
2731
+ this.annotationDraftText = existing ? `${existing}\n${transcript}` : transcript;
2732
+ if (options.sendImmediately) {
2733
+ this.addAnnotationComment();
2734
+ await this.sendAnnotationsToChat();
2735
+ }
2736
+ }
2737
+ } else if (options.sendImmediately) {
2738
await this.sendAnnotationsToChat(text);
2739
} else {
2740
this.draftAnnotationsToChat(text);
tests/test_browser_agent_regressions.py
+180
-4
@@ -395,6 +395,7 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
395
assert default_launch["channel"] is None
396
assert default_launch["requires_full_browser"] is True
397
assert default_launch["proxy"] is None
398
+ assert "--hide-crash-restore-bubble" in default_launch["args"]
399
assert not any(arg.startswith("--load-extension=") for arg in default_launch["args"])
400
assert "--no-sandbox" not in default_launch["args"]
401
assert "--disable-dev-shm-usage" not in default_launch["args"]
@@ -1783,12 +1784,13 @@ def test_browser_annotate_mode_ui_and_prompt_hooks():
1784
assert "Draft to chat" not in panel_html
1785
assert "Send now" not in panel_html
1786
assert 'class="browser-annotation-popover-close"' in panel_html
1786
- assert 'class="btn btn-ok browser-annotation-add"' in panel_html
1787
- assert 'class="browser-annotation-mic mic-inactive"' in panel_html
1787
+ assert panel_html.count('class="browser-annotation-mic mic-inactive"') == 2
1788
assert 'class="browser-annotation-send"' in panel_html
1789
assert 'name="arrow_forward"' in panel_html
1790
+ assert 'name="add_comment"' not in panel_html
1791
assert "data-whisper-microphone" in panel_html
1792
assert "syncAnnotationMicrophoneUI()" in panel_html
1793
+ assert "startAnnotationVoice(true)" in panel_html
1794
assert "@pointerdown.stop.prevent=\"$store.browserPage.startAnnotationSelection($event)\"" in panel_html
1795
assert "clearAnnotationHover()" in panel_html
1796
assert "@keydown.window=\"$store.browserPage.handleKeydown($event)\"" in panel_html
@@ -1798,7 +1800,8 @@ def test_browser_annotate_mode_ui_and_prompt_hooks():
1800
assert "pendingAnnotations()" in browser_store
1801
assert "annotationBatchLabel()" in browser_store
1802
assert "updateAnnotationHover(event)" in browser_store
1801
- assert "startAnnotationVoice()" in browser_store
1803
+ assert "startAnnotationVoice(draftComment = false)" in browser_store
1804
+ assert "this.annotationDraftText = existing" in browser_store
1805
assert "options.sendImmediately" in browser_store
1806
assert "clampAnnotationTrayPosition" in browser_store
1807
assert '"browser_viewer_annotation"' in browser_store
@@ -3397,6 +3400,7 @@ async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(
3400
return None
3401
3402
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3403
+ monkeypatch.setattr(ws_browser_module, "has_restorable_browser_tabs", lambda context_id: False)
3404
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3405
monkeypatch.setattr(
3406
ws_browser_module.AgentContext,
@@ -3421,6 +3425,49 @@ async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(
3425
assert ("sid-empty", "ctx") not in ws_browser_module.WsBrowser._streams
3426
3427
3428
+@pytest.mark.anyio
3429
+async def test_browser_viewer_subscribe_starts_runtime_for_saved_tabs(monkeypatch):
3430
+ calls = []
3431
+
3432
+ class FakeRuntime:
3433
+ async def call(self, method, *args, **kwargs):
3434
+ if method == "list":
3435
+ return {
3436
+ "browsers": [
3437
+ {"id": 4, "context_id": "ctx", "currentUrl": "https://example.com/"}
3438
+ ],
3439
+ "last_interacted_browser_id": 4,
3440
+ }
3441
+ if method == "interactive_viewer":
3442
+ return {"available": True, "browser_id": 4, "url": "/desktop/session/test/"}
3443
+ raise AssertionError(method)
3444
+
3445
+ async def fake_get_runtime(context_id, create=True):
3446
+ calls.append(create)
3447
+ return FakeRuntime() if create else None
3448
+
3449
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3450
+ monkeypatch.setattr(ws_browser_module, "has_restorable_browser_tabs", lambda context_id: True)
3451
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3452
+ monkeypatch.setattr(
3453
+ ws_browser_module.AgentContext,
3454
+ "get",
3455
+ staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3456
+ )
3457
+
3458
+ handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
3459
+ result = await handler.process(
3460
+ "browser_viewer_subscribe",
3461
+ {"context_id": "ctx", "viewer_transport": "interactive"},
3462
+ "sid-restore",
3463
+ )
3464
+
3465
+ assert calls[:2] == [False, True]
3466
+ assert result["active_browser_id"] == 4
3467
+ assert result["browsers"][0]["currentUrl"] == "https://example.com/"
3468
+ await handler.on_disconnect("sid-restore")
3469
+
3470
+
3471
@pytest.mark.anyio
3472
async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
3473
class FakeRuntime:
@@ -3451,6 +3498,42 @@ async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
3498
]
3499
3500
3501
+@pytest.mark.anyio
3502
+async def test_shared_runtime_session_list_includes_restored_inactive_chats(monkeypatch):
3503
+ class FakeSharedRuntime:
3504
+ async def call_for(self, context_id, method):
3505
+ assert context_id == browser_runtime_module.SHARED_RUNTIME_ID
3506
+ assert method == "list_all"
3507
+ return {
3508
+ "browsers": [
3509
+ {"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"},
3510
+ {"id": 2, "context_id": "ctx-b", "currentUrl": "https://example.org/"},
3511
+ ],
3512
+ "last_interacted_browser_ids": {"ctx-a": 1, "ctx-b": 2},
3513
+ }
3514
+
3515
+ monkeypatch.setattr(
3516
+ browser_runtime_module,
3517
+ "get_browser_config",
3518
+ lambda: {"browser_tab_scope": "shared"},
3519
+ )
3520
+ with browser_runtime_module._runtime_lock:
3521
+ previous_runtimes = dict(browser_runtime_module._runtimes)
3522
+ previous_shared = browser_runtime_module._shared_runtime
3523
+ browser_runtime_module._runtimes.clear()
3524
+ browser_runtime_module._shared_runtime = FakeSharedRuntime()
3525
+ try:
3526
+ sessions = await list_runtime_sessions()
3527
+ finally:
3528
+ with browser_runtime_module._runtime_lock:
3529
+ browser_runtime_module._runtimes.clear()
3530
+ browser_runtime_module._runtimes.update(previous_runtimes)
3531
+ browser_runtime_module._shared_runtime = previous_shared
3532
+
3533
+ assert [session["context_id"] for session in sessions] == ["ctx-a", "ctx-b"]
3534
+ assert [session["last_interacted_browser_id"] for session in sessions] == [1, 2]
3535
+
3536
+
3537
@pytest.mark.anyio
3538
async def test_browser_context_handles_share_one_runtime(monkeypatch):
3539
class FakeSharedRuntime:
@@ -3584,7 +3667,7 @@ def test_explicit_open_claims_page_registered_by_playwright_event():
3667
class Page:
3668
@staticmethod
3669
def on(event, callback):
3587
- assert event == "close"
3670
+ assert event in {"close", "framenavigated"}
3671
3672
core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3673
page = Page()
@@ -3605,6 +3688,99 @@ def test_explicit_open_claims_page_registered_by_playwright_event():
3688
assert core._last_interacted_browser_ids.get("ctx-a") is None
3689
3690
3691
+@pytest.mark.anyio
3692
+async def test_browser_restores_per_context_then_all_shared_tabs(monkeypatch):
3693
+ saved = []
3694
+ browser_config = {"browser_tab_scope": "per_context", "max_open_tabs": 32}
3695
+
3696
+ class Page:
3697
+ def __init__(self):
3698
+ self.url = "about:blank"
3699
+
3700
+ @staticmethod
3701
+ def is_closed():
3702
+ return False
3703
+
3704
+ @staticmethod
3705
+ def on(event, callback):
3706
+ assert event in {"close", "framenavigated"}
3707
+
3708
+ class Context:
3709
+ async def new_page(self):
3710
+ return Page()
3711
+
3712
+ async def goto(page, url, **kwargs):
3713
+ page.url = url
3714
+
3715
+ monkeypatch.setattr(
3716
+ browser_runtime_module,
3717
+ "get_browser_config",
3718
+ lambda: browser_config,
3719
+ )
3720
+ monkeypatch.setattr(
3721
+ browser_runtime_module,
3722
+ "_save_browser_tabs",
3723
+ lambda tabs: saved.append(tabs),
3724
+ )
3725
+
3726
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3727
+ core.context = Context()
3728
+ core._restore_state_loaded = True
3729
+ core._restore_state_exists = True
3730
+ core._restore_entries = [
3731
+ {"context_id": "ctx-a", "url": "https://example.com/one", "active": True},
3732
+ {"context_id": "ctx-b", "url": "https://example.org/two", "active": True},
3733
+ ]
3734
+ monkeypatch.setattr(core, "_goto", goto)
3735
+
3736
+ token = core.request_context_id.set("ctx-a")
3737
+ try:
3738
+ await core._restore_tabs_for_scope()
3739
+ finally:
3740
+ core.request_context_id.reset(token)
3741
+
3742
+ assert [page.context_id for page in core.pages.values()] == ["ctx-a"]
3743
+ assert [entry["context_id"] for entry in saved[-1]] == ["ctx-b", "ctx-a"]
3744
+
3745
+ browser_config["browser_tab_scope"] = "shared"
3746
+ token = core.request_context_id.set("ctx-a")
3747
+ try:
3748
+ await core._restore_tabs_for_scope()
3749
+ finally:
3750
+ core.request_context_id.reset(token)
3751
+
3752
+ assert [page.context_id for page in core.pages.values()] == ["ctx-a", "ctx-b"]
3753
+ assert {entry["url"] for entry in saved[-1]} == {
3754
+ "https://example.com/one",
3755
+ "https://example.org/two",
3756
+ }
3757
+ assert core._restored_all is True
3758
+
3759
+
3760
+def test_unexpected_browser_exit_keeps_last_saved_tabs(monkeypatch):
3761
+ saved = []
3762
+ restore_entries = [
3763
+ {"context_id": "ctx-a", "url": "https://example.com/", "active": True}
3764
+ ]
3765
+ monkeypatch.setattr(
3766
+ browser_runtime_module,
3767
+ "_save_browser_tabs",
3768
+ lambda tabs: saved.append(tabs),
3769
+ )
3770
+
3771
+ core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3772
+ core.context = object()
3773
+ core._restore_state_loaded = True
3774
+ core._restore_entries = restore_entries
3775
+ core.pages = {1: BrowserPage(1, SimpleNamespace(url="https://example.com/"), "ctx-a")}
3776
+
3777
+ core._on_context_closed()
3778
+
3779
+ assert saved == []
3780
+ assert core._restore_entries == restore_entries
3781
+ assert core.pages == {}
3782
+
3783
+
3784
def test_shared_browser_runtime_adopts_first_requesting_legacy_profile(monkeypatch, tmp_path):
3785
monkeypatch.setattr(
3786
browser_runtime_module.files,