Agent Zero AI framework
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,