Improve Browser startup and annotations

Keep tab loading feedback stable and visible while the interactive Browser starts without waiting for a redundant screenshot. Add DOM hover feedback, multi-page annotation batches, compact composer-style controls, and shared Whisper draft or send behavior.

Alessandro committed Aug 16, 2026 at 15:22 UTC 4bf92a607265e8a235a8763bb421c6e528cc11e0
10 files changed +505 -96
plugins/_browser/AGENTS.md
+6 -1
@@ -19,6 +19,7 @@
19 - Preserve Patchright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
20 - Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
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 - 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.
25 - Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
@@ -30,6 +31,8 @@
31 - Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
32 - Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
33 - Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
34 +- Keep Chromium processes and persistent sign-in profiles isolated per chat even when the tab strip is shared; reset/removal may delete only that chat's profile.
35 +- Show an accessible in-panel startup state while an on-demand Browser runtime is cold-starting; do not create an idle Chromium/Xpra pair for every chat at Agent Zero startup.
36 - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
37 - For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`.
38 - Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
@@ -37,7 +40,9 @@
40 - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
41 - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
42 - Do not hardcode user-specific browser paths or secrets.
40 -- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection.
43 +- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. After the first Browser tool call, use the selected preset for subsequent model turns in that monologue and clear it at monologue end.
44 +- Annotation mode highlights the DOM element under the pointer, keeps saved overlays page-local, and may batch annotated pages only within the active chat context.
45 +- Annotation voice input reuses Whisper STT's configured draft/send delivery mode and shared microphone state.
46 - Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
47 - Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
48 - Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
plugins/_browser/api/ws_browser.py
+7 -2
@@ -143,7 +143,8 @@ class WsBrowser(WsHandler):
143 viewer_transport=viewer_transport,
144 )
145 self._streams[stream_key] = asyncio.create_task(stream_task)
146 - snapshot = await self._snapshot_for_browser(runtime, active_id)
146 + if viewer_transport != VIEWER_TRANSPORT_INTERACTIVE:
147 + snapshot = await self._snapshot_for_browser(runtime, active_id)
148
149 browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
150
@@ -291,7 +292,11 @@ class WsBrowser(WsHandler):
292 active_id,
293 data,
294 )
294 - snapshot = await self._snapshot_for_result(runtime, result)
295 + snapshot = (
296 + None
297 + if viewer_transport == VIEWER_TRANSPORT_INTERACTIVE
298 + else await self._snapshot_for_result(runtime, result)
299 + )
300 browsers, all_browsers, tab_scope = await self._tabs_for_scope(
301 context_id,
302 listing.get("browsers") or [],
plugins/_browser/webui/browser-panel.html
+187 -21
@@ -25,9 +25,10 @@
25 @click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
26 <x-icon class="browser-tab-icon" aria-hidden="true" name="language"></x-icon>
27 <span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
28 - <x-icon class="browser-tab-loading spinning" aria-hidden="true"
29 - x-show="$store.browserPage.isBrowserLoading(browser)" name="progress_activity"></x-icon>
28 </button>
29 + <x-icon class="browser-tab-loading" aria-hidden="true"
30 + :class="{ 'is-visible': $store.browserPage.isBrowserLoading(browser), 'spinning': $store.browserPage.isBrowserLoading(browser) }"
31 + name="progress_activity"></x-icon>
32 <button type="button" class="browser-tab-close"
33 :title="'Close ' + $store.browserPage.browserTabLabel(browser)"
34 :aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
@@ -214,7 +215,15 @@
215 @pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
216 @pointermove.stop.prevent="$store.browserPage.moveAnnotationSelection($event)"
217 @pointerup.stop.prevent="$store.browserPage.finishAnnotationSelection($event)"
217 - @pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)">
218 + @pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)"
219 + @pointerleave="$store.browserPage.clearAnnotationHover()">
220 + <template x-if="$store.browserPage.annotationHover && !$store.browserPage.annotationDraft && !$store.browserPage.annotationDragRect">
221 + <div class="browser-annotation-box is-hover"
222 + :style="$store.browserPage.annotationBoxStyle($store.browserPage.annotationHover.rect)">
223 + <span class="browser-annotation-hover-label"
224 + x-text="$store.browserPage.annotationHoverLabel()"></span>
225 + </div>
226 + </template>
227 <template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
228 <div class="browser-annotation-box is-saved"
229 :style="$store.browserPage.annotationBoxStyle(annotation.rect)">
@@ -233,22 +242,30 @@
242 :style="$store.browserPage.annotationPopoverStyle()"
243 @click.stop @pointerdown.stop @keydown.stop>
244 <div class="browser-annotation-popover-title">
236 - <span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
237 - <span x-text="$store.browserPage.annotationDraftTitle()"></span>
245 + <span class="browser-annotation-popover-heading">
246 + <span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
247 + <span x-text="$store.browserPage.annotationDraftTitle()"></span>
248 + </span>
249 + <button type="button" class="browser-annotation-popover-close"
250 + title="Cancel annotation" aria-label="Cancel annotation"
251 + @click="$store.browserPage.cancelAnnotationDraft()">
252 + <x-icon name="close"></x-icon>
253 + </button>
254 </div>
255 <textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment"
240 - maxlength="1200"></textarea>
256 + maxlength="1200" x-init="$nextTick(() => $el.focus())"></textarea>
257 <div class="browser-annotation-actions">
242 - <button type="button" class="btn btn-field"
243 - @click="$store.browserPage.cancelAnnotationDraft()">Cancel</button>
244 - <button type="button" class="btn btn-ok"
258 + <button type="button" class="btn btn-ok browser-annotation-add"
259 + title="Add annotation" aria-label="Add annotation"
260 :disabled="!String($store.browserPage.annotationDraftText || '').trim()"
246 - @click="$store.browserPage.addAnnotationComment()">Add</button>
261 + @click="$store.browserPage.addAnnotationComment()">
262 + <x-icon name="add_comment"></x-icon>
263 + </button>
264 </div>
265 </div>
266 </template>
267 <div class="browser-annotation-tray"
251 - x-show="$store.browserPage.visibleAnnotations().length"
268 + x-show="$store.browserPage.pendingAnnotations().length"
269 :class="{ 'is-floating': $store.browserPage.annotationTrayPosition, 'is-dragging': $store.browserPage.annotationTrayDragging }"
270 :style="$store.browserPage.annotationTrayStyle()"
271 x-transition style="display: none;"
@@ -258,15 +275,15 @@
275 @pointercancel.window="$store.browserPage.finishAnnotationTrayDrag($event)">
276 <div class="browser-annotation-tray-header"
277 @pointerdown.stop="$store.browserPage.startAnnotationTrayDrag($event)">
261 - <span>Annotations</span>
278 + <span x-text="$store.browserPage.annotationBatchLabel()"></span>
279 <button type="button" class="browser-annotation-clear" title="Clear annotations"
263 - aria-label="Clear annotations" @click="$store.browserPage.clearVisibleAnnotations()">
280 + aria-label="Clear annotations" @click="$store.browserPage.clearPendingAnnotations()">
281 <x-icon name="delete"></x-icon>
282 </button>
283 </div>
284 <div class="browser-annotation-chips">
268 - <template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
269 - <div class="browser-annotation-chip">
285 + <template x-for="annotation in $store.browserPage.pendingAnnotations()" :key="annotation.id">
286 + <div class="browser-annotation-chip" :title="annotation.title + ' — ' + annotation.url">
287 <span class="browser-annotation-number" x-text="annotation.index"></span>
288 <span class="browser-annotation-chip-text" x-text="annotation.comment"></span>
289 <button type="button" title="Remove annotation" aria-label="Remove annotation"
@@ -277,10 +294,20 @@
294 </template>
295 </div>
296 <div class="browser-annotation-tray-actions">
280 - <button type="button" class="btn btn-field"
281 - @click="$store.browserPage.draftAnnotationsToChat()">Draft to chat</button>
282 - <button type="button" class="btn btn-ok"
283 - @click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
297 + <button type="button" class="browser-annotation-mic mic-inactive"
298 + data-whisper-microphone title="Record annotation instruction"
299 + aria-label="Record annotation instruction"
300 + x-init="$nextTick(() => $store.browserPage.syncAnnotationMicrophoneUI())"
301 + @click="$store.browserPage.startAnnotationVoice()">
302 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18" fill="currentColor" aria-hidden="true">
303 + <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" />
304 + </svg>
305 + </button>
306 + <button type="button" class="browser-annotation-send"
307 + title="Send annotations" aria-label="Send annotations"
308 + @click="$store.browserPage.sendAnnotationsToChat()">
309 + <x-icon name="arrow_forward"></x-icon>
310 + </button>
311 </div>
312 </div>
313 <template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()">
@@ -290,6 +317,12 @@
317 Browser</button>
318 </div>
319 </template>
320 + <template x-if="!$store.browserPage.hasFrame() && $store.browserPage.isBusy()">
321 + <div class="browser-empty browser-starting" role="status" aria-live="polite">
322 + <x-icon class="spinning" name="progress_activity"></x-icon>
323 + <span x-text="$store.browserPage.browserLoadingLabel()"></span>
324 + </div>
325 + </template>
326 </div>
327
328 <template x-if="$store.browserPage.error">
@@ -531,7 +564,7 @@
564 flex: 0 1 210px;
565 position: relative;
566 display: grid;
534 - grid-template-columns: minmax(0, 1fr) var(--browser-tab-close-size);
567 + grid-template-columns: minmax(0, 1fr) 18px var(--browser-tab-close-size);
568 align-items: center;
569 gap: 3px;
570 min-width: 128px;
@@ -607,10 +640,17 @@
640 }
641
642 .browser-tab-loading {
610 - flex: 0 0 auto;
643 + justify-self: center;
644 color: color-mix(in srgb, var(--color-text) 66%, var(--color-primary) 34%);
645 font-size: 0.96rem;
646 line-height: 1;
647 + opacity: 0;
648 + visibility: hidden;
649 + }
650 +
651 + .browser-tab-loading.is-visible {
652 + opacity: 1;
653 + visibility: visible;
654 }
655
656 .browser-tab-close {
@@ -1062,6 +1102,29 @@
1102 background: rgba(51, 153, 255, 0.1);
1103 }
1104
1105 + .browser-annotation-box.is-hover {
1106 + border-color: #22c55e;
1107 + background: rgba(34, 197, 94, 0.12);
1108 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.58);
1109 + }
1110 +
1111 + .browser-annotation-hover-label {
1112 + position: absolute;
1113 + top: 2px;
1114 + left: 2px;
1115 + max-width: min(320px, calc(100vw - 24px));
1116 + overflow: hidden;
1117 + padding: 2px 5px;
1118 + border-radius: 4px;
1119 + background: rgba(6, 78, 59, 0.92);
1120 + color: #fff;
1121 + font-size: 0.7rem;
1122 + font-weight: 700;
1123 + line-height: 1.3;
1124 + text-overflow: ellipsis;
1125 + white-space: nowrap;
1126 + }
1127 +
1128 .browser-annotation-number {
1129 display: inline-flex;
1130 align-items: center;
@@ -1113,6 +1176,17 @@
1176 font-weight: 750;
1177 }
1178
1179 + .browser-annotation-popover-title {
1180 + justify-content: space-between;
1181 + }
1182 +
1183 + .browser-annotation-popover-heading {
1184 + display: inline-flex;
1185 + align-items: center;
1186 + gap: 8px;
1187 + min-width: 0;
1188 + }
1189 +
1190 .browser-annotation-popover textarea {
1191 width: 100%;
1192 min-height: 82px;
@@ -1141,6 +1215,16 @@
1215 white-space: nowrap;
1216 }
1217
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 +
1228 .browser-annotation-tray {
1229 z-index: 20;
1230 right: 10px;
@@ -1167,6 +1251,7 @@
1251 cursor: grabbing;
1252 }
1253
1254 + .browser-annotation-popover-close,
1255 .browser-annotation-clear,
1256 .browser-annotation-chip button {
1257 display: inline-flex;
@@ -1184,12 +1269,14 @@
1269 cursor: pointer;
1270 }
1271
1272 + .browser-annotation-popover-close:hover,
1273 .browser-annotation-clear:hover,
1274 .browser-annotation-chip button:hover {
1275 background: color-mix(in srgb, var(--color-panel) 82%, transparent);
1276 color: var(--color-text);
1277 }
1278
1279 + .browser-annotation-popover-close .material-symbols-outlined,
1280 .browser-annotation-clear .material-symbols-outlined,
1281 .browser-annotation-chip button .material-symbols-outlined {
1282 font-size: 16px;
@@ -1232,6 +1319,81 @@
1319 line-height: 1.25;
1320 }
1321
1322 + .browser-annotation-tray-actions {
1323 + align-items: center;
1324 + gap: 0.58rem;
1325 + }
1326 +
1327 + .browser-annotation-mic,
1328 + .browser-annotation-send {
1329 + display: inline-flex;
1330 + flex: 0 0 3.15rem;
1331 + align-items: center;
1332 + justify-content: center;
1333 + width: 3.15rem;
1334 + min-width: 3.15rem;
1335 + height: 3.15rem;
1336 + margin: 0;
1337 + padding: 0;
1338 + border: 1px solid transparent;
1339 + color: #fff;
1340 + cursor: pointer;
1341 + transition: background-color 0.2s ease, color 0.2s ease, transform 0.12s ease;
1342 + }
1343 +
1344 + .browser-annotation-mic {
1345 + border-radius: 12px;
1346 + background: transparent;
1347 + color: grey;
1348 + }
1349 +
1350 + .browser-annotation-mic svg {
1351 + transform-origin: center;
1352 + transition: color 0.2s ease, transform 0.12s ease-in-out;
1353 + }
1354 +
1355 + .browser-annotation-mic.mic-disabled {
1356 + color: #5f6368;
1357 + cursor: not-allowed;
1358 + opacity: 0.58;
1359 + }
1360 +
1361 + .browser-annotation-mic.mic-activating,
1362 + .browser-annotation-mic.mic-processing {
1363 + animation: browser-mic-pulse 0.8s infinite;
1364 + }
1365 +
1366 + .browser-annotation-mic.mic-activating { color: silver; }
1367 + .browser-annotation-mic.mic-listening { color: red; }
1368 + .browser-annotation-mic.mic-recording { color: green; }
1369 + .browser-annotation-mic.mic-waiting { color: teal; }
1370 + .browser-annotation-mic.mic-processing { color: darkcyan; }
1371 +
1372 + @media (hover: hover) {
1373 + .browser-annotation-mic:not(.mic-disabled):hover svg { transform: scale(1.08); }
1374 + }
1375 +
1376 + .browser-annotation-mic:not(.mic-disabled):active svg { transform: scale(0.92); }
1377 +
1378 + .browser-annotation-send {
1379 + border-radius: 12px;
1380 + background: #4248f1;
1381 + }
1382 +
1383 + .browser-annotation-send:hover { background: #353bc5; }
1384 + .browser-annotation-send:active { background: #2b309c; transform: translateY(1px) scale(0.98); }
1385 +
1386 + .browser-annotation-mic svg,
1387 + .browser-annotation-send .material-symbols-outlined {
1388 + width: 1.5rem;
1389 + height: 1.5rem;
1390 + font-size: 1.78rem;
1391 + }
1392 +
1393 + @keyframes browser-mic-pulse {
1394 + 50% { transform: scale(1.1); }
1395 + }
1396 +
1397 .browser-empty {
1398 display: flex;
1399 align-items: center;
@@ -1302,6 +1464,10 @@
1464 color: var(--color-text);
1465 }
1466
1467 + .browser-starting .material-symbols-outlined {
1468 + font-size: 1.45rem;
1469 + }
1470 +
1471 @container (max-width: 460px) {
1472 .browser-toolbar {
1473 grid-template-columns: auto minmax(0, 1fr) auto;
plugins/_browser/webui/browser-store.js
+179 -48
@@ -176,6 +176,7 @@ const model = {
176 tabScope: "per_context",
177 annotating: false,
178 annotationComments: [],
179 + annotationHover: null,
180 annotationDraft: null,
181 annotationDraftText: "",
182 annotationDragRect: null,
@@ -208,6 +209,8 @@ const model = {
209 _annotationPointer: null,
210 _annotationTrayDrag: null,
211 _annotationSequence: 0,
212 + _annotationHoverSequence: 0,
213 + _annotationHoverAt: 0,
214 _mode: "",
215 _surfaceMounted: false,
216 _surfaceSwitching: false,
@@ -1607,8 +1610,8 @@ const model = {
1610 }
1611 this.applySnapshot(data.snapshot);
1612 if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
1610 - this.clearAnnotationsForBrowser(previousActiveBrowserId, null, previousActiveContextId);
1613 this.cancelAnnotationDraft();
1614 + this.clearAnnotationHover();
1615 }
1616 const activeChanged = this.activeBrowserId
1617 && !this.sameBrowserTab(
@@ -1927,6 +1930,7 @@ const model = {
1930 this.frameState = nextState;
1931 if (previousUrl && nextUrl && previousUrl !== nextUrl) {
1932 this.cancelAnnotationDraft();
1933 + this.clearAnnotationHover();
1934 }
1935 if (!this.addressFocused && nextState.currentUrl) {
1936 this.address = nextState.currentUrl;
@@ -1976,6 +1980,12 @@ const model = {
1980 return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
1981 },
1982
1983 + browserLoadingLabel() {
1984 + if (this.commandInFlight && !this.activeBrowserId) return "Starting Browser…";
1985 + if (this.isSwitchingBrowser()) return "Switching Browser tab…";
1986 + return "Connecting to Browser…";
1987 + },
1988 +
1989 setActiveBrowserId(id, contextId = "") {
1990 const previous = this.activeBrowserId;
1991 const previousContextId = this.activeBrowserContextId;
@@ -1994,6 +2004,7 @@ const model = {
2004 this._lastViewportKey = "";
2005 this._lastViewport = null;
2006 this.cancelAnnotationDraft();
2007 + this.clearAnnotationHover();
2008 }
2009 },
2010
@@ -2146,6 +2157,7 @@ const model = {
2157 this.closeExtensionsMenu();
2158 if (!nextValue) {
2159 this.cancelAnnotationDraft();
2160 + this.clearAnnotationHover();
2161 this.annotationDragRect = null;
2162 this._annotationPointer = null;
2163 } else {
@@ -2171,8 +2183,25 @@ const model = {
2183 ));
2184 },
2185
2186 + pendingAnnotations() {
2187 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2188 + if (!contextId) return [];
2189 + return this.annotationComments.filter(
2190 + (annotation) => this.normalizeContextId(annotation.contextId) === contextId,
2191 + );
2192 + },
2193 +
2194 nextAnnotationIndex() {
2175 - return this.visibleAnnotations().length + 1;
2195 + return this.pendingAnnotations().length + 1;
2196 + },
2197 +
2198 + annotationBatchLabel() {
2199 + const annotations = this.pendingAnnotations();
2200 + const pageCount = new Set(
2201 + annotations.map((annotation) => `${annotation.browserId}:${annotation.url}`),
2202 + ).size;
2203 + if (pageCount <= 1) return `Annotations (${annotations.length})`;
2204 + return `Annotations (${annotations.length} across ${pageCount} pages)`;
2205 },
2206
2207 annotationTrayStyle() {
@@ -2259,21 +2288,15 @@ const model = {
2288 this.annotationTrayPosition = null;
2289 },
2290
2262 - clearVisibleAnnotations() {
2263 - this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId);
2291 + clearPendingAnnotations() {
2292 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2293 + if (!contextId) return;
2294 + this.annotationComments = this.annotationComments.filter(
2295 + (annotation) => this.normalizeContextId(annotation.contextId) !== contextId,
2296 + );
2297 this.resetAnnotationTrayPosition();
2298 },
2299
2267 - clearAnnotationsForBrowser(browserId, url = null, contextId = "") {
2268 - const numericBrowserId = this.normalizeBrowserId(browserId);
2269 - const normalizedContextId = this.normalizeContextId(contextId || this.activeBrowserContextId);
2270 - if (!numericBrowserId) return;
2271 - this.annotationComments = this.annotationComments.filter((annotation) => {
2272 - if (!this.sameBrowserTab(annotation.browserId, annotation.contextId, numericBrowserId, normalizedContextId)) return true;
2273 - return url ? String(annotation.url || "") !== String(url) : false;
2274 - });
2275 - },
2276 -
2300 annotationBoxStyle(rect = {}) {
2301 const viewport = this.currentViewportSize() || this._lastViewport || {};
2302 const width = Math.max(1, Number(viewport.width || rect.width || 1));
@@ -2352,6 +2375,7 @@ const model = {
2375 const point = this.stagePointForEvent(event);
2376 if (!point) return;
2377 this.cancelAnnotationDraft();
2378 + this.clearAnnotationHover();
2379 this.annotationError = "";
2380 this._annotationPointer = {
2381 id: event.pointerId,
@@ -2368,7 +2392,11 @@ const model = {
2392 },
2393
2394 moveAnnotationSelection(event) {
2371 - if (!this.annotating || !this._annotationPointer) return;
2395 + if (!this.annotating) return;
2396 + if (!this._annotationPointer) {
2397 + void this.updateAnnotationHover(event);
2398 + return;
2399 + }
2400 if (event.pointerId !== this._annotationPointer.id) return;
2401 const point = this.stagePointForEvent(event);
2402 if (!point) return;
@@ -2414,6 +2442,63 @@ const model = {
2442 this.annotationDragRect = null;
2443 },
2444
2445 + clearAnnotationHover() {
2446 + this._annotationHoverSequence += 1;
2447 + this.annotationHover = null;
2448 + },
2449 +
2450 + async updateAnnotationHover(event) {
2451 + if (!this.annotating || this.annotationBusy || this.annotationDraft || this._annotationPointer) return;
2452 + const now = Date.now();
2453 + if (now - this._annotationHoverAt < 90) return;
2454 + const point = this.stagePointForEvent(event);
2455 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2456 + const browserId = this.activeBrowserId;
2457 + if (!point || !contextId || !browserId) return;
2458 +
2459 + this._annotationHoverAt = now;
2460 + const url = this.activeAnnotationUrl();
2461 + const sequence = this._annotationHoverSequence + 1;
2462 + this._annotationHoverSequence = sequence;
2463 + try {
2464 + const response = await websocket.request(
2465 + "browser_viewer_annotation",
2466 + {
2467 + context_id: contextId,
2468 + browser_id: browserId,
2469 + viewer_id: this._viewerToken,
2470 + payload: {
2471 + kind: "element",
2472 + point: { x: Math.round(point.x), y: Math.round(point.y) },
2473 + viewport: this.currentViewportSize(),
2474 + url,
2475 + title: this.activeTitle,
2476 + },
2477 + },
2478 + { timeoutMs: 10000 },
2479 + );
2480 + if (
2481 + sequence !== this._annotationHoverSequence
2482 + || !this.sameBrowserTab(browserId, contextId, this.activeBrowserId, this.activeBrowserContextId)
2483 + || url !== this.activeAnnotationUrl()
2484 + ) return;
2485 + const metadata = firstOk(response).annotation || {};
2486 + const rect = metadata?.target?.rect || metadata?.rect;
2487 + this.annotationHover = rect
2488 + ? { rect: this.clampAnnotationRect(rect), metadata }
2489 + : null;
2490 + } catch {
2491 + if (sequence === this._annotationHoverSequence) this.annotationHover = null;
2492 + }
2493 + },
2494 +
2495 + annotationHoverLabel() {
2496 + const target = this.annotationHover?.metadata?.target || {};
2497 + const tag = String(target.tagName || "").toLowerCase();
2498 + const summary = String(target.summary || "").trim();
2499 + return [tag ? `<${tag}>` : "Element", summary].filter(Boolean).join(" ");
2500 + },
2501 +
2502 cancelAnnotationDraft() {
2503 this.annotationDraft = null;
2504 this.annotationDraftText = "";
@@ -2428,6 +2513,7 @@ const model = {
2513 const url = this.activeAnnotationUrl();
2514 const title = this.activeTitle;
2515 this._annotationSequence = sequence;
2516 + this.clearAnnotationHover();
2517 this.annotationBusy = true;
2518 this.annotationError = "";
2519 try {
@@ -2474,7 +2560,7 @@ const model = {
2560 addAnnotationComment() {
2561 const comment = String(this.annotationDraftText || "").trim();
2562 if (!this.annotationDraft || !comment) return;
2477 - if (this.visibleAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
2563 + if (this.pendingAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
2564 this.annotationError = `Keep each batch to ${ANNOTATION_MAX_COMMENTS} annotations or fewer.`;
2565 this.error = this.annotationError;
2566 return;
@@ -2492,18 +2578,18 @@ const model = {
2578
2579 removeAnnotationComment(annotationId) {
2580 this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
2495 - if (!this.visibleAnnotations().length) {
2581 + if (!this.pendingAnnotations().length) {
2582 this.resetAnnotationTrayPosition();
2583 }
2584 },
2585
2500 - annotationChipLabel(annotation) {
2501 - const prefix = annotation?.kind === "area" ? "Area" : "Element";
2502 - return `${prefix} ${annotation?.index || ""}`.trim();
2503 - },
2504 -
2586 formatAnnotationRect(rect = {}) {
2506 - const normalized = this.clampAnnotationRect(rect);
2587 + const normalized = {
2588 + x: Math.round(Number(rect.x || 0)),
2589 + y: Math.round(Number(rect.y || 0)),
2590 + width: Math.max(1, Math.round(Number(rect.width || 1))),
2591 + height: Math.max(1, Math.round(Number(rect.height || 1))),
2592 + };
2593 return `x=${normalized.x}, y=${normalized.y}, width=${normalized.width}, height=${normalized.height}`;
2594 },
2595
@@ -2554,45 +2640,60 @@ const model = {
2640 return lines.join("\n");
2641 },
2642
2557 - buildAnnotationsPrompt() {
2558 - const annotations = this.visibleAnnotations();
2643 + buildAnnotationsPrompt(instruction = "") {
2644 + const annotations = this.pendingAnnotations();
2645 if (!annotations.length) return "";
2560 - const lines = [
2561 - "Browser annotations",
2562 - `Page title: ${this.activeTitle}`,
2563 - `Page URL: ${this.activeAnnotationUrl()}`,
2564 - `Browser id: ${this.activeBrowserId}`,
2565 - "",
2566 - ];
2567 - annotations.forEach((annotation, index) => {
2646 + const lines = ["Browser annotations"];
2647 + const spokenInstruction = String(instruction || "").trim();
2648 + if (spokenInstruction) lines.push(`Instruction: ${spokenInstruction}`);
2649 + lines.push("");
2650 +
2651 + const pages = new Map();
2652 + for (const annotation of annotations) {
2653 + const key = `${annotation.contextId}:${annotation.browserId}:${annotation.url}`;
2654 + if (!pages.has(key)) pages.set(key, []);
2655 + pages.get(key).push(annotation);
2656 + }
2657 +
2658 + let annotationNumber = 0;
2659 + Array.from(pages.values()).forEach((pageAnnotations, pageIndex) => {
2660 + const page = pageAnnotations[0];
2661 lines.push(
2569 - `Annotation ${index + 1}`,
2570 - `Comment: ${annotation.comment}`,
2571 - `Selection kind: ${annotation.kind}`,
2572 - `Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
2662 + `Page ${pageIndex + 1}`,
2663 + `Page title: ${page.title || "Untitled"}`,
2664 + `Page URL: ${page.url || "about:blank"}`,
2665 + `Browser id: ${page.browserId}`,
2666 + "",
2667 );
2574 - const metadata = this.formatAnnotationMetadata(annotation.metadata);
2575 - if (metadata) {
2576 - lines.push(metadata);
2577 - }
2578 - lines.push("");
2668 + pageAnnotations.forEach((annotation) => {
2669 + annotationNumber += 1;
2670 + lines.push(
2671 + `Annotation ${annotationNumber}`,
2672 + `Comment: ${annotation.comment}`,
2673 + `Selection kind: ${annotation.kind}`,
2674 + `Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
2675 + );
2676 + const metadata = this.formatAnnotationMetadata(annotation.metadata);
2677 + if (metadata) lines.push(metadata);
2678 + lines.push("");
2679 + });
2680 });
2681 return lines.join("\n").trim();
2682 },
2683
2583 - draftAnnotationsToChat() {
2584 - const prompt = this.buildAnnotationsPrompt();
2684 + draftAnnotationsToChat(instruction = "") {
2685 + const prompt = this.buildAnnotationsPrompt(instruction);
2686 if (!prompt) return;
2687 const existingMessage = String(chatInputStore.message || "").trim();
2688 chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
2689 chatInputStore.adjustTextareaHeight?.();
2690 chatInputStore.focus?.();
2590 - this.clearVisibleAnnotations();
2691 + this.clearPendingAnnotations();
2692 this.toggleAnnotationMode(false);
2693 },
2694
2594 - async sendAnnotationsToChat() {
2595 - const prompt = this.buildAnnotationsPrompt();
2695 + async sendAnnotationsToChat(instruction = "") {
2696 + const prompt = this.buildAnnotationsPrompt(instruction);
2697 if (!prompt) return;
2698 chatInputStore.message = prompt;
2699 chatInputStore.adjustTextareaHeight?.();
@@ -2605,13 +2706,42 @@ const model = {
2706 chatInputStore.focus?.();
2707 return;
2708 }
2608 - this.clearVisibleAnnotations();
2709 + this.clearPendingAnnotations();
2710 this.toggleAnnotationMode(false);
2711 } catch (error) {
2712 this.error = error instanceof Error ? error.message : String(error);
2713 }
2714 },
2715
2716 + async startAnnotationVoice() {
2717 + try {
2718 + const { store: whisperStore } = await import(
2719 + "/plugins/_whisper_stt/webui/whisper-stt-store.js"
2720 + );
2721 + await whisperStore.handleMicrophoneClick(async (text, options = {}) => {
2722 + if (options.sendImmediately) {
2723 + await this.sendAnnotationsToChat(text);
2724 + } else {
2725 + this.draftAnnotationsToChat(text);
2726 + }
2727 + whisperStore.stop();
2728 + });
2729 + whisperStore.updateMicrophoneButtonUI();
2730 + } catch (error) {
2731 + this.error = error instanceof Error ? error.message : String(error);
2732 + }
2733 + },
2734 +
2735 + async syncAnnotationMicrophoneUI() {
2736 + try {
2737 + const { store: whisperStore } = await import(
2738 + "/plugins/_whisper_stt/webui/whisper-stt-store.js"
2739 + );
2740 + await whisperStore.ensureStatusLoaded({ suppressError: true });
2741 + whisperStore.updateMicrophoneButtonUI();
2742 + } catch {}
2743 + },
2744 +
2745 currentViewportSize() {
2746 const measurement = this.surfaceViewportMeasurement();
2747 if (!measurement) return null;
@@ -2880,6 +3010,7 @@ const model = {
3010 this.annotationError = "";
3011 this.cancelAnnotationDraft();
3012 this.cancelAnnotationSelection();
3013 + this.clearAnnotationHover();
3014 this.resetAnnotationTrayPosition();
3015 if (this.contextId) {
3016 try {
plugins/_browser/webui/config.html
+2 -2
@@ -218,13 +218,13 @@
218 class="browser-config-field-help"
219 x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
220 >
221 - Each chat shows only its own Browser tabs.
221 + Each chat has its own Browser tabs and sign-in profile. Resetting or removing the chat clears that profile.
222 </span>
223 <span
224 class="browser-config-field-help"
225 x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
226 >
227 - The Browser tab strip shows tabs from every active chat.
227 + The tab strip shows tabs from every active chat. Sign-ins and browser profiles remain isolated per chat.
228 </span>
229 </label>
230
plugins/_whisper_stt/AGENTS.md
+2
@@ -16,6 +16,8 @@
16
17 - Keep dependency installation and model bootstrap on Docker/bootstrap paths.
18 - Preserve configured message mode, language, silence threshold, silence duration, and waiting timeout behavior.
19 +- Keep all `data-whisper-microphone` controls synchronized with the same microphone status.
20 +- Optional final-transcript handlers must receive the configured draft/send mode without changing the main chat microphone's delivery behavior.
21 - Do not expose raw audio or transcriptions beyond intended UI/API paths.
22
23 ## Work Guidance
plugins/_whisper_stt/extensions/webui/chat-input-box-end/microphone-button.html
+1
@@ -89,6 +89,7 @@
89 <button
90 class="chat-button mic-inactive"
91 id="microphone-button"
92 + data-whisper-microphone
93 aria-label="Start/Stop recording"
94 @click="$store.whisperStt.handleMicrophoneClick()"
95 x-init="$store.whisperStt.updateMicrophoneButtonUI()"
plugins/_whisper_stt/webui/whisper-stt-store.js
+36 -17
@@ -66,6 +66,7 @@ const model = {
66 packageVersion: "",
67 providerCleanup: null,
68 microphoneInput: null,
69 + finalTranscriptHandler: null,
70 isProcessingClick: false,
71 devices: [],
72 selectedDevice: "",
@@ -176,24 +177,26 @@ const model = {
177 },
178
179 updateMicrophoneButtonUI() {
179 - const microphoneButton = document.getElementById("microphone-button");
180 - if (!microphoneButton) return;
181 -
180 const status = this.enabled ? this.micStatus : "disabled";
181 const label = MicStatusLabels[status] || "Microphone";
184 - clearMicrophoneTooltip(microphoneButton);
185 - microphoneButton.classList.remove(...MicButtonClasses);
186 - microphoneButton.classList.add(`mic-${status}`);
187 - microphoneButton.setAttribute("data-status", status);
188 - microphoneButton.setAttribute("aria-label", label);
189 - microphoneButton.setAttribute(
190 - "aria-pressed",
191 - String(
192 - status !== "disabled" &&
193 - status !== Status.INACTIVE &&
194 - status !== Status.ACTIVATING,
195 - ),
182 + const microphoneButtons = document.querySelectorAll(
183 + "[data-whisper-microphone], #microphone-button",
184 );
185 + for (const microphoneButton of microphoneButtons) {
186 + clearMicrophoneTooltip(microphoneButton);
187 + microphoneButton.classList.remove(...MicButtonClasses);
188 + microphoneButton.classList.add(`mic-${status}`);
189 + microphoneButton.setAttribute("data-status", status);
190 + microphoneButton.setAttribute("aria-label", label);
191 + microphoneButton.setAttribute(
192 + "aria-pressed",
193 + String(
194 + status !== "disabled" &&
195 + status !== Status.INACTIVE &&
196 + status !== Status.ACTIVATING,
197 + ),
198 + );
199 + }
200 },
201
202 async loadDevices() {
@@ -268,13 +271,16 @@ const model = {
271 }
272 },
273
271 - async handleMicrophoneClick() {
274 + async handleMicrophoneClick(finalTranscriptHandler = null) {
275 if (this.isProcessingClick) return;
276
277 + this.finalTranscriptHandler =
278 + typeof finalTranscriptHandler === "function" ? finalTranscriptHandler : null;
279 this.isProcessingClick = true;
280 try {
281 await this.ensureStatusLoaded({ force: true, suppressError: false });
282 if (!this.enabled) {
283 + this.finalTranscriptHandler = null;
284 globalThis.justToast?.("Whisper STT is disabled.", "info");
285 return;
286 }
@@ -309,7 +315,7 @@ const model = {
315
316 const input = new MicrophoneInput(this, async (text, isFinal) => {
317 if (isFinal) {
312 - await this.sendVoiceMessage(text);
318 + await this.deliverVoiceMessage(text);
319 }
320 });
321
@@ -318,6 +324,17 @@ const model = {
324 return this.microphoneInput;
325 },
326
327 + async deliverVoiceMessage(text) {
328 + if (this.finalTranscriptHandler) {
329 + await this.finalTranscriptHandler(text, {
330 + messageMode: this.config.message_mode,
331 + sendImmediately: this.sendsImmediately,
332 + });
333 + return;
334 + }
335 + await this.sendVoiceMessage(text);
336 + },
337 +
338 async sendVoiceMessage(text) {
339 const message = String(text || "").trim();
340 if (!message) return;
@@ -347,6 +364,8 @@ const model = {
364 this.microphoneInput = null;
365 }
366
367 + this.finalTranscriptHandler = null;
368 +
369 this.notifyStatusChange();
370 },
371
tests/test_browser_agent_regressions.py
+83 -4
@@ -1432,6 +1432,8 @@ def test_browser_extension_settings_stay_user_facing():
1432 assert "Separate per chat" in config_html
1433 assert "Shared across chats" in config_html
1434 assert "Maximum tabs per chat" in config_html
1435 + assert "Each chat has its own Browser tabs and sign-in profile." in config_html
1436 + assert "Sign-ins and browser profiles remain isolated per chat." in config_html
1437 assert 'x-model="$store.browserConfig.config.browser_tab_scope"' in config_html
1438 assert 'x-model.number="$store.browserConfig.config.max_open_tabs"' in config_html
1439 assert 'x-model="$store.browserConfig.config.proxy_server"' in config_html
@@ -1473,11 +1475,19 @@ def test_browser_viewer_uses_tabs_for_session_switching():
1475 assert "browser.context_id" in main_html
1476 assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
1477 assert ':aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"' in main_html
1476 - assert 'class="browser-tab-loading spinning"' in main_html
1477 - assert 'x-show="$store.browserPage.isBrowserLoading(browser)"' in main_html
1478 + assert 'class="browser-tab-loading"' in main_html
1479 + assert "'is-visible': $store.browserPage.isBrowserLoading(browser)" in main_html
1480 + tab_button = main_html.index('class="browser-tab"')
1481 + tab_button_end = main_html.index("</button>", tab_button)
1482 + tab_spinner = main_html.index('class="browser-tab-loading"', tab_button_end)
1483 + tab_close = main_html.index('class="browser-tab-close"', tab_spinner)
1484 + assert tab_button_end < tab_spinner < tab_close
1485 assert "<span x-text=\"$store.browserPage.loadingMessage()\">Loading</span>" not in main_html
1486 assert "isBrowserLoading(browser)" in browser_store
1487 assert "loadingMessage()" not in browser_store
1488 + assert 'class="browser-empty browser-starting" role="status" aria-live="polite"' in main_html
1489 + assert "browserLoadingLabel()" in browser_store
1490 + assert "Starting Browser…" in browser_store
1491 assert "browser-tab-context" not in main_html
1492 assert 'handleSelectedContextChange($store.chats?.selected)' in main_html
1493 assert "activeBrowserContextId" in browser_store
@@ -1570,6 +1580,8 @@ def test_browser_viewer_defaults_to_interactive_with_screencast_and_snapshot_fal
1580 assert "except TimeoutError:" in ws_browser
1581 assert "stop_screencast" in ws_browser
1582 assert "viewer_transport == VIEWER_TRANSPORT_SCREENCAST" in ws_browser
1583 + assert "viewer_transport != VIEWER_TRANSPORT_INTERACTIVE" in ws_browser
1584 + assert "if viewer_transport == VIEWER_TRANSPORT_INTERACTIVE" in ws_browser
1585 assert '"viewer_transport": viewer_transport' in ws_browser
1586 assert "viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
1587 assert '"Page.startScreencast"' in runtime
@@ -1766,22 +1778,45 @@ def test_browser_annotate_mode_ui_and_prompt_hooks():
1778 assert "browser-annotation-tray" in panel_html
1779 assert "annotationTrayStyle()" in panel_html
1780 assert "startAnnotationTrayDrag($event)" in panel_html
1769 - assert "Draft to chat" in panel_html
1770 - assert "Send now" in panel_html
1781 + assert "Draft to chat" not in panel_html
1782 + assert "Send now" not in panel_html
1783 + assert 'class="browser-annotation-popover-close"' in panel_html
1784 + assert 'class="btn btn-ok browser-annotation-add"' in panel_html
1785 + assert 'class="browser-annotation-mic mic-inactive"' in panel_html
1786 + assert 'class="browser-annotation-send"' in panel_html
1787 + assert 'name="arrow_forward"' in panel_html
1788 + assert "data-whisper-microphone" in panel_html
1789 + assert "syncAnnotationMicrophoneUI()" in panel_html
1790 assert "@pointerdown.stop.prevent=\"$store.browserPage.startAnnotationSelection($event)\"" in panel_html
1791 + assert "clearAnnotationHover()" in panel_html
1792 assert "@keydown.window=\"$store.browserPage.handleKeydown($event)\"" in panel_html
1793 assert "annotationComments: []" in browser_store
1794 + assert "annotationHover: null" in browser_store
1795 assert "annotationTrayPosition: null" in browser_store
1796 + assert "pendingAnnotations()" in browser_store
1797 + assert "annotationBatchLabel()" in browser_store
1798 + assert "updateAnnotationHover(event)" in browser_store
1799 + assert "startAnnotationVoice()" in browser_store
1800 + assert "options.sendImmediately" in browser_store
1801 assert "clampAnnotationTrayPosition" in browser_store
1802 assert '"browser_viewer_annotation"' in browser_store
1803 assert 'event?.key === "." && (event.metaKey || event.ctrlKey)' in browser_store
1804 assert "Browser annotations" in browser_store
1805 + assert "Instruction:" in browser_store
1806 + assert "Page ${pageIndex + 1}" in browser_store
1807 assert "Comment:" in browser_store
1808 assert "Coordinates:" in browser_store
1809 assert "Selector:" in browser_store
1810 assert "DOM:" in browser_store
1811 assert "value=\\\"[redacted]\\\"" in browser_store
1812
1813 + whisper_store = (
1814 + PROJECT_ROOT / "plugins" / "_whisper_stt" / "webui" / "whisper-stt-store.js"
1815 + ).read_text(encoding="utf-8")
1816 + assert "finalTranscriptHandler" in whisper_store
1817 + assert "deliverVoiceMessage(text)" in whisper_store
1818 + assert '"[data-whisper-microphone], #microphone-button"' in whisper_store
1819 +
1820
1821 def test_browser_visual_mode_bridges_clipboard_shortcuts():
1822 browser_store = (
@@ -3307,6 +3342,50 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
3342 await handler.on_disconnect("sid-snapshot")
3343
3344
3345 +@pytest.mark.anyio
3346 +async def test_browser_viewer_subscribe_skips_snapshot_for_interactive(monkeypatch):
3347 + calls = []
3348 +
3349 + class FakeRuntime:
3350 + async def call(self, method, *args, **kwargs):
3351 + calls.append(method)
3352 + if method == "list":
3353 + return {
3354 + "browsers": [{"id": 1, "context_id": "ctx", "currentUrl": "about:blank"}],
3355 + "last_interacted_browser_id": 1,
3356 + }
3357 + if method == "interactive_viewer":
3358 + return {"available": True, "browser_id": 1, "url": "/desktop/session/test/"}
3359 + if method == "screenshot":
3360 + raise AssertionError("Interactive Browser must not wait for a redundant screenshot")
3361 + raise AssertionError(method)
3362 +
3363 + async def fake_get_runtime(context_id, create=True):
3364 + assert context_id == "ctx"
3365 + assert create is False
3366 + return FakeRuntime()
3367 +
3368 + monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3369 + monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3370 + monkeypatch.setattr(
3371 + ws_browser_module.AgentContext,
3372 + "get",
3373 + staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3374 + )
3375 +
3376 + handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
3377 + result = await handler.process(
3378 + "browser_viewer_subscribe",
3379 + {"context_id": "ctx", "browser_id": 1, "viewer_transport": "interactive"},
3380 + "sid-interactive",
3381 + )
3382 +
3383 + assert result["viewer_transport"] == "interactive"
3384 + assert result["snapshot"] is None
3385 + assert "screenshot" not in calls
3386 + await handler.on_disconnect("sid-interactive")
3387 +
3388 +
3389 @pytest.mark.anyio
3390 async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(monkeypatch):
3391 async def fake_get_runtime(context_id, create=True):
tests/test_speech_plugin_split.py
+2 -1
@@ -183,11 +183,12 @@ def test_chat_bar_keeps_existing_send_and_mic_icon_contract() -> None:
183 ).read_text(encoding="utf-8")
184
185 assert 'id="send-button"' in chat_bar
186 - assert 'x-text="$store.chatInput.sendButtonIcon"' in chat_bar
186 + assert ':name="$store.chatInput.sendButtonIcon"' in chat_bar
187 assert ':class="$store.chatInput.sendButtonClass"' in chat_bar
188 assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar
189
190 assert 'id="microphone-button"' in mic_extension
191 + assert "data-whisper-microphone" in mic_extension
192 assert "<svg" in mic_extension
193 assert "material-symbols-outlined" not in mic_extension
194 assert "buttonIcon" not in mic_extension