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,