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,