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