Add browser annotate mode
Add Codex-inspired annotation UI to the built-in Browser surfaces, including the Annotate toggle, Cmd/Ctrl+. shortcut, selection overlay, inline comments, and batch Draft to chat / Send now actions. Wire browser_viewer_annotation through the WebSocket and runtime layers, and expose safe DOM metadata extraction for clicked elements and selected areas without leaking password/value data. Expand regression coverage for the Browser UI, annotation dispatch, runtime helper exposure, prompt formatting, and WebUI extension surface harness behavior.
Alessandro committed
Apr 26, 2026 at 23:57 UTC
4ff3244ce63ee336071f875de99cf6922db93daa
7 files changed
+1382
-35
plugins/_browser/api/ws_browser.py
+25
@@ -43,6 +43,8 @@ class WsBrowser(WsHandler):
43
return await self._command(data, sid)
44
if event == "browser_viewer_input":
45
return await self._input(data, sid)
46
+ if event == "browser_viewer_annotation":
47
+ return await self._annotation(data, sid)
48
49
return WsResult.error(
50
code="UNKNOWN_BROWSER_EVENT",
@@ -215,6 +217,29 @@ class WsBrowser(WsHandler):
217
else None,
218
}
219
220
+ async def _annotation(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
221
+ context_id = self._context_id(data)
222
+ if not context_id:
223
+ return self._error("MISSING_CONTEXT", "context_id is required", data)
224
+ runtime = await get_runtime(context_id, create=False)
225
+ if not runtime:
226
+ return self._error("NO_BROWSER_RUNTIME", "No browser runtime exists for this context", data)
227
+
228
+ browser_id = data.get("browser_id")
229
+ viewer_id = str(data.get("viewer_id") or "")
230
+ payload = data.get("payload") if isinstance(data.get("payload"), dict) else {}
231
+ try:
232
+ annotation = await runtime.call("annotation_target", browser_id, payload)
233
+ except Exception as exc:
234
+ return self._error("ANNOTATION_FAILED", str(exc), data)
235
+
236
+ return {
237
+ "annotation": annotation,
238
+ "context_id": context_id,
239
+ "browser_id": browser_id,
240
+ "viewer_id": viewer_id,
241
+ }
242
+
243
async def _snapshot_for_result(
244
self,
245
runtime: Any,
plugins/_browser/assets/browser-page-content.js
+368
-1
@@ -1,7 +1,7 @@
1
(() => {
2
const GLOBAL_KEY = "__spaceBrowserPageContent__";
3
const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
4
- const VERSION = "6";
4
+ const VERSION = "7";
5
const BLOCK_TAGS = new Set([
6
"ADDRESS",
7
"ARTICLE",
@@ -2842,10 +2842,377 @@
2842
});
2843
}
2844
2845
+ function cssEscape(value) {
2846
+ const rawValue = String(value || "");
2847
+ if (!rawValue) {
2848
+ return "";
2849
+ }
2850
+
2851
+ if (typeof globalThis.CSS?.escape === "function") {
2852
+ return globalThis.CSS.escape(rawValue);
2853
+ }
2854
+
2855
+ return rawValue.replace(/[^a-zA-Z0-9_-]/gu, (character) => `\\${character}`);
2856
+ }
2857
+
2858
+ function getClassSummary(element) {
2859
+ try {
2860
+ return [...(element?.classList || [])]
2861
+ .map((className) => normalizeAttributeText(className))
2862
+ .filter(Boolean)
2863
+ .slice(0, 4)
2864
+ .join(" ");
2865
+ } catch {
2866
+ return "";
2867
+ }
2868
+ }
2869
+
2870
+ function buildCssSelector(element) {
2871
+ if (!isElementNode(element)) {
2872
+ return "";
2873
+ }
2874
+
2875
+ const id = normalizeAttributeText(element.getAttribute?.("id"));
2876
+ if (id) {
2877
+ return `#${cssEscape(id)}`;
2878
+ }
2879
+
2880
+ const parts = [];
2881
+ let current = element;
2882
+ while (isElementNode(current) && current !== globalThis.document?.documentElement && parts.length < 6) {
2883
+ const tagName = getTagName(current).toLowerCase();
2884
+ if (!tagName) {
2885
+ break;
2886
+ }
2887
+
2888
+ let part = tagName;
2889
+ const classes = getClassSummary(current)
2890
+ .split(/\s+/u)
2891
+ .filter(Boolean)
2892
+ .slice(0, 2);
2893
+ if (classes.length && !["body", "html"].includes(tagName)) {
2894
+ part += classes.map((className) => `.${cssEscape(className)}`).join("");
2895
+ }
2896
+
2897
+ const parent = current.parentElement;
2898
+ if (parent) {
2899
+ const siblings = [...parent.children].filter((sibling) => getTagName(sibling) === getTagName(current));
2900
+ if (siblings.length > 1) {
2901
+ part += `:nth-of-type(${siblings.indexOf(current) + 1})`;
2902
+ }
2903
+ }
2904
+
2905
+ parts.unshift(part);
2906
+ if (tagName === "body") {
2907
+ break;
2908
+ }
2909
+ current = parent;
2910
+ }
2911
+
2912
+ return parts.join(" > ");
2913
+ }
2914
+
2915
+ function sanitizeAnnotationDom(value) {
2916
+ return truncateText(
2917
+ String(value || "")
2918
+ .replace(/(<input\b(?=[^>]*\btype\s*=\s*(["'])?password\2?)[^>]*?)\s+value\s*=\s*(["'])[\s\S]*?\3/giu, "$1 value=\"[redacted]\"")
2919
+ .replace(/\svalue\s*=\s*(["'])[\s\S]{0,600}?\1/giu, " value=\"[redacted]\"")
2920
+ .replace(/\sdata-space-browser-live-value\s*=\s*(["'])[\s\S]{0,600}?\1/giu, "")
2921
+ .replace(/\sdata-space-browser-selected-text\s*=\s*(["'])[\s\S]{0,600}?\1/giu, ""),
2922
+ 1200
2923
+ );
2924
+ }
2925
+
2926
+ function summarizeAnnotationElement(element) {
2927
+ if (!isElementNode(element)) {
2928
+ return null;
2929
+ }
2930
+
2931
+ const summaryData = collectReferenceSummaryData(element, {
2932
+ includeLabelQuotes: false,
2933
+ includeLinkUrls: true,
2934
+ includeSemanticTags: true,
2935
+ includeStateTags: true
2936
+ });
2937
+ const rawDom = serializeElementSnapshot(element);
2938
+ return {
2939
+ classes: getClassSummary(element),
2940
+ dom: sanitizeAnnotationDom(rawDom),
2941
+ id: normalizeAttributeText(element.getAttribute?.("id")),
2942
+ kind: summaryData.kind,
2943
+ name: normalizeAttributeText(element.getAttribute?.("name")),
2944
+ rect: getElementRectSafe(element),
2945
+ role: normalizeAttributeText(element.getAttribute?.("role")).toLowerCase(),
2946
+ selector: buildCssSelector(element),
2947
+ semanticTags: Array.isArray(summaryData.semanticTags) ? summaryData.semanticTags.slice(0, 4) : [],
2948
+ stateTags: Array.isArray(summaryData.state?.stateTags) ? summaryData.state.stateTags.slice(0, 8) : [],
2949
+ summary: truncateText(summaryData.summary || getLabelText(element, {
2950
+ includeAlt: true,
2951
+ includeDescendantImageAlt: true,
2952
+ includePlaceholder: true,
2953
+ includeText: true
2954
+ }), 240),
2955
+ tagName: getTagName(element)
2956
+ };
2957
+ }
2958
+
2959
+ function annotationViewport() {
2960
+ return {
2961
+ height: Math.max(0, Number(globalThis.innerHeight || globalThis.document?.documentElement?.clientHeight || 0)),
2962
+ scrollX: Number(globalThis.scrollX || globalThis.pageXOffset || 0),
2963
+ scrollY: Number(globalThis.scrollY || globalThis.pageYOffset || 0),
2964
+ width: Math.max(0, Number(globalThis.innerWidth || globalThis.document?.documentElement?.clientWidth || 0))
2965
+ };
2966
+ }
2967
+
2968
+ function normalizeAnnotationPoint(payload = {}, viewport = annotationViewport()) {
2969
+ const source = payload?.point && typeof payload.point === "object" ? payload.point : payload;
2970
+ const width = Math.max(1, Number(viewport.width || 1));
2971
+ const height = Math.max(1, Number(viewport.height || 1));
2972
+ return {
2973
+ x: Math.max(0, Math.min(width, Number(source?.x || 0))),
2974
+ y: Math.max(0, Math.min(height, Number(source?.y || 0)))
2975
+ };
2976
+ }
2977
+
2978
+ function normalizeAnnotationRectPayload(payload = {}, viewport = annotationViewport()) {
2979
+ const source = payload?.rect && typeof payload.rect === "object" ? payload.rect : payload;
2980
+ const width = Math.max(1, Number(viewport.width || 1));
2981
+ const height = Math.max(1, Number(viewport.height || 1));
2982
+ const x = Math.max(0, Math.min(width, Number(source?.x || 0)));
2983
+ const y = Math.max(0, Math.min(height, Number(source?.y || 0)));
2984
+ return {
2985
+ height: Math.max(1, Math.min(height - y, Number(source?.height || source?.h || 1))),
2986
+ width: Math.max(1, Math.min(width - x, Number(source?.width || source?.w || 1))),
2987
+ x,
2988
+ y
2989
+ };
2990
+ }
2991
+
2992
+ function intersectRects(leftRect, rightRect) {
2993
+ if (!leftRect || !rightRect) {
2994
+ return null;
2995
+ }
2996
+
2997
+ const x = Math.max(Number(leftRect.x || 0), Number(rightRect.x || 0));
2998
+ const y = Math.max(Number(leftRect.y || 0), Number(rightRect.y || 0));
2999
+ const right = Math.min(
3000
+ Number(leftRect.x || 0) + Number(leftRect.width || 0),
3001
+ Number(rightRect.x || 0) + Number(rightRect.width || 0)
3002
+ );
3003
+ const bottom = Math.min(
3004
+ Number(leftRect.y || 0) + Number(leftRect.height || 0),
3005
+ Number(rightRect.y || 0) + Number(rightRect.height || 0)
3006
+ );
3007
+ const width = right - x;
3008
+ const height = bottom - y;
3009
+ if (width <= 0 || height <= 0) {
3010
+ return null;
3011
+ }
3012
+ return {
3013
+ area: width * height,
3014
+ height,
3015
+ width,
3016
+ x,
3017
+ y
3018
+ };
3019
+ }
3020
+
3021
+ function deepElementFromPoint(x, y) {
3022
+ let element = null;
3023
+ try {
3024
+ element = globalThis.document?.elementFromPoint?.(x, y) || null;
3025
+ } catch {
3026
+ return null;
3027
+ }
3028
+
3029
+ let guard = 0;
3030
+ while (isElementNode(element) && element.shadowRoot && guard < 8) {
3031
+ guard += 1;
3032
+ try {
3033
+ const nestedElement = element.shadowRoot.elementFromPoint?.(x, y);
3034
+ if (!nestedElement || nestedElement === element) {
3035
+ break;
3036
+ }
3037
+ element = nestedElement;
3038
+ } catch {
3039
+ break;
3040
+ }
3041
+ }
3042
+
3043
+ return element;
3044
+ }
3045
+
3046
+ function findAnnotationTarget(element) {
3047
+ if (!isElementNode(element)) {
3048
+ return null;
3049
+ }
3050
+
3051
+ const selector = [
3052
+ "a[href]",
3053
+ "button",
3054
+ "input",
3055
+ "textarea",
3056
+ "select",
3057
+ "summary",
3058
+ "[role]",
3059
+ "img",
3060
+ "label",
3061
+ "form",
3062
+ "h1",
3063
+ "h2",
3064
+ "h3",
3065
+ "h4",
3066
+ "h5",
3067
+ "h6",
3068
+ "p",
3069
+ "li",
3070
+ "td",
3071
+ "th",
3072
+ "article",
3073
+ "section",
3074
+ "nav",
3075
+ "header",
3076
+ "main",
3077
+ "footer"
3078
+ ].join(",");
3079
+ const target = element.closest?.(selector) || element;
3080
+ return isElementNode(target) && !isHiddenElement(target) ? target : element;
3081
+ }
3082
+
3083
+ function isMeaningfulAnnotationElement(element) {
3084
+ if (!isElementNode(element) || isHiddenElement(element)) {
3085
+ return false;
3086
+ }
3087
+
3088
+ if (isInteractiveElement(element) || getTagName(element) === "IMG") {
3089
+ return true;
3090
+ }
3091
+
3092
+ const tagName = getTagName(element);
3093
+ const role = normalizeAttributeText(element.getAttribute?.("role")).toLowerCase();
3094
+ return Boolean(
3095
+ role
3096
+ || /^H[1-6]$/u.test(tagName)
3097
+ || ["ARTICLE", "SECTION", "MAIN", "NAV", "HEADER", "FOOTER", "FORM", "LABEL", "P", "LI", "TD", "TH"].includes(tagName)
3098
+ );
3099
+ }
3100
+
3101
+ function collectIntersectingAnnotationElements(rect) {
3102
+ const selector = [
3103
+ "a[href]",
3104
+ "button",
3105
+ "input",
3106
+ "textarea",
3107
+ "select",
3108
+ "summary",
3109
+ "[role]",
3110
+ "img",
3111
+ "label",
3112
+ "form",
3113
+ "h1",
3114
+ "h2",
3115
+ "h3",
3116
+ "h4",
3117
+ "h5",
3118
+ "h6",
3119
+ "p",
3120
+ "li",
3121
+ "td",
3122
+ "th",
3123
+ "article",
3124
+ "section",
3125
+ "main",
3126
+ "nav",
3127
+ "header",
3128
+ "footer"
3129
+ ].join(",");
3130
+ let candidates = [];
3131
+ try {
3132
+ candidates = [...(globalThis.document?.querySelectorAll?.(selector) || [])];
3133
+ } catch {
3134
+ candidates = [];
3135
+ }
3136
+
3137
+ const seen = new Set();
3138
+ return candidates
3139
+ .map((element) => {
3140
+ if (!isMeaningfulAnnotationElement(element) || seen.has(element)) {
3141
+ return null;
3142
+ }
3143
+ seen.add(element);
3144
+ const elementRect = getElementRectSafe(element);
3145
+ const intersection = intersectRects(rect, elementRect);
3146
+ if (!intersection || intersection.area < 48) {
3147
+ return null;
3148
+ }
3149
+ return {
3150
+ element,
3151
+ elementArea: Math.max(1, Number(elementRect.width || 0) * Number(elementRect.height || 0)),
3152
+ intersection
3153
+ };
3154
+ })
3155
+ .filter(Boolean)
3156
+ .sort((left, right) => {
3157
+ if (right.intersection.area !== left.intersection.area) {
3158
+ return right.intersection.area - left.intersection.area;
3159
+ }
3160
+ return left.elementArea - right.elementArea;
3161
+ })
3162
+ .slice(0, 12)
3163
+ .map((entry) => summarizeAnnotationElement(entry.element))
3164
+ .filter(Boolean);
3165
+ }
3166
+
3167
+ function annotate(payload = null) {
3168
+ const request = payload && typeof payload === "object" ? payload : {};
3169
+ const viewport = annotationViewport();
3170
+ const kind = request.kind === "area" || request.rect ? "area" : "element";
3171
+
3172
+ if (kind === "area") {
3173
+ const rect = normalizeAnnotationRectPayload(request, viewport);
3174
+ const point = {
3175
+ x: rect.x + rect.width / 2,
3176
+ y: rect.y + rect.height / 2
3177
+ };
3178
+ const elements = collectIntersectingAnnotationElements(rect);
3179
+ const fallbackElement = findAnnotationTarget(deepElementFromPoint(point.x, point.y));
3180
+ const fallbackTarget = fallbackElement ? summarizeAnnotationElement(fallbackElement) : null;
3181
+ return {
3182
+ elements,
3183
+ kind,
3184
+ point,
3185
+ rect,
3186
+ status: elements.length || fallbackTarget ? "ok" : "empty",
3187
+ target: elements[0] || fallbackTarget,
3188
+ viewport
3189
+ };
3190
+ }
3191
+
3192
+ const point = normalizeAnnotationPoint(request, viewport);
3193
+ const rawElement = deepElementFromPoint(point.x, point.y);
3194
+ const targetElement = findAnnotationTarget(rawElement);
3195
+ const target = targetElement ? summarizeAnnotationElement(targetElement) : null;
3196
+ return {
3197
+ kind,
3198
+ point,
3199
+ rect: target?.rect || {
3200
+ height: 1,
3201
+ width: 1,
3202
+ x: point.x,
3203
+ y: point.y
3204
+ },
3205
+ status: target ? "ok" : "empty",
3206
+ target,
3207
+ viewport
3208
+ };
3209
+ }
3210
+
3211
globalThis[GLOBAL_KEY] = {
3212
click(referenceId) {
3213
return activateElement(referenceId);
3214
},
3215
+ annotate,
3216
capture,
3217
clear() {
3218
state.captureId = 0;
plugins/_browser/helpers/runtime.py
+17
-1
@@ -557,6 +557,22 @@ class _BrowserRuntimeCore:
557
self.last_interacted_browser_id = resolved_id
558
return result or {}
559
560
+ async def annotation_target(
561
+ self,
562
+ browser_id: int | str | None,
563
+ payload: dict[str, Any] | None = None,
564
+ ) -> dict[str, Any]:
565
+ await self.ensure_started()
566
+ resolved_id = self._resolve_browser_id(browser_id)
567
+ page = self._page(resolved_id)
568
+ await self._ensure_content_helper(page)
569
+ result = await page.evaluate(
570
+ "(payload) => globalThis.__spaceBrowserPageContent__.annotate(payload || null)",
571
+ payload or None,
572
+ )
573
+ self.last_interacted_browser_id = resolved_id
574
+ return result or {}
575
+
576
async def evaluate(self, browser_id: int | str | None, script: str) -> dict[str, Any]:
577
await self.ensure_started()
578
resolved_id = self._resolve_browser_id(browser_id)
@@ -918,7 +934,7 @@ class _BrowserRuntimeCore:
934
935
async def _ensure_content_helper(self, page: Any) -> None:
936
has_helper = await page.evaluate(
921
- "() => Boolean(globalThis.__spaceBrowserPageContent__?.capture)"
937
+ "() => Boolean(globalThis.__spaceBrowserPageContent__?.capture && globalThis.__spaceBrowserPageContent__?.annotate)"
938
)
939
if has_helper:
940
return
plugins/_browser/webui/browser-panel.html
+311
-2
@@ -11,7 +11,7 @@
11
<div x-data>
12
<template x-if="$store.browserPage">
13
<div class="browser-panel" x-create="$store.browserPage.onOpen($el, xAttrs($el) || {})" x-destroy="$store.browserPage.cleanup()"
14
- @keydown.window="$store.browserPage.sendKey($event)">
14
+ @keydown.window="$store.browserPage.handleKeydown($event)">
15
<div class="browser-meta">
16
<div class="browser-meta-top">
17
<div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
@@ -41,6 +41,15 @@
41
</div>
42
43
<div class="browser-session-controls">
44
+ <button type="button" class="btn browser-annotate-toggle" title="Annotate" aria-label="Annotate"
45
+ :aria-pressed="$store.browserPage.annotating.toString()"
46
+ :class="{ 'is-active': $store.browserPage.annotating }"
47
+ :disabled="!$store.browserPage.canAnnotate() && !$store.browserPage.annotating"
48
+ @click="$store.browserPage.toggleAnnotationMode()">
49
+ <span class="material-symbols-outlined" aria-hidden="true"
50
+ x-text="$store.browserPage.annotating ? 'rate_review' : 'edit_note'"></span>
51
+ <span x-text="$store.browserPage.annotating ? 'Annotating' : 'Annotate'"></span>
52
+ </button>
53
<div class="browser-extension-menu" @click.outside="$store.browserPage.closeExtensionsMenu()"
54
@keydown.escape.window="$store.browserPage.closeExtensionsMenu()">
55
<button type="button" class="btn btn-icon-action browser-extensions" title="Browser settings"
@@ -161,12 +170,82 @@
170
</div>
171
172
<div class="browser-stage" tabindex="0" @click="$el.focus()"
164
- @wheel.prevent="$store.browserPage.sendWheel($event)">
173
+ :class="{ 'is-annotating': $store.browserPage.annotating }"
174
+ @wheel.prevent="$store.browserPage.handleStageWheel($event)">
175
<template x-if="$store.browserPage.frameSrc">
176
<img class="browser-frame" :src="$store.browserPage.frameSrc"
177
@click="$store.browserPage.sendMouse('click', $event)"
178
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
179
</template>
180
+ <template x-if="$store.browserPage.annotating && $store.browserPage.frameSrc">
181
+ <div class="browser-annotation-layer"
182
+ :class="{ 'is-busy': $store.browserPage.annotationBusy }"
183
+ @pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
184
+ @pointermove.stop.prevent="$store.browserPage.moveAnnotationSelection($event)"
185
+ @pointerup.stop.prevent="$store.browserPage.finishAnnotationSelection($event)"
186
+ @pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)">
187
+ <template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
188
+ <div class="browser-annotation-box is-saved"
189
+ :style="$store.browserPage.annotationBoxStyle(annotation.rect)">
190
+ <span class="browser-annotation-number" x-text="annotation.index"></span>
191
+ </div>
192
+ </template>
193
+ <template x-if="$store.browserPage.annotationDragRect">
194
+ <div class="browser-annotation-box is-draft"
195
+ :style="$store.browserPage.annotationBoxStyle($store.browserPage.annotationDragRect)">
196
+ </div>
197
+ </template>
198
+ </div>
199
+ </template>
200
+ <template x-if="$store.browserPage.annotationDraft">
201
+ <div class="browser-annotation-popover"
202
+ :style="$store.browserPage.annotationPopoverStyle()"
203
+ @click.stop @pointerdown.stop @keydown.stop>
204
+ <div class="browser-annotation-popover-title">
205
+ <span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
206
+ <span x-text="$store.browserPage.annotationDraftTitle()"></span>
207
+ </div>
208
+ <textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment"
209
+ maxlength="1200"></textarea>
210
+ <div class="browser-annotation-actions">
211
+ <button type="button" class="btn btn-field"
212
+ @click="$store.browserPage.cancelAnnotationDraft()">Cancel</button>
213
+ <button type="button" class="btn btn-ok"
214
+ :disabled="!String($store.browserPage.annotationDraftText || '').trim()"
215
+ @click="$store.browserPage.addAnnotationComment()">Add</button>
216
+ </div>
217
+ </div>
218
+ </template>
219
+ <div class="browser-annotation-tray"
220
+ x-show="$store.browserPage.visibleAnnotations().length"
221
+ x-transition style="display: none;"
222
+ @click.stop @pointerdown.stop @keydown.stop>
223
+ <div class="browser-annotation-tray-header">
224
+ <span>Annotations</span>
225
+ <button type="button" class="browser-annotation-clear" title="Clear annotations"
226
+ aria-label="Clear annotations" @click="$store.browserPage.clearVisibleAnnotations()">
227
+ <span class="material-symbols-outlined">delete</span>
228
+ </button>
229
+ </div>
230
+ <div class="browser-annotation-chips">
231
+ <template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
232
+ <div class="browser-annotation-chip">
233
+ <span class="browser-annotation-number" x-text="annotation.index"></span>
234
+ <span class="browser-annotation-chip-text" x-text="annotation.comment"></span>
235
+ <button type="button" title="Remove annotation" aria-label="Remove annotation"
236
+ @click="$store.browserPage.removeAnnotationComment(annotation.id)">
237
+ <span class="material-symbols-outlined">close</span>
238
+ </button>
239
+ </div>
240
+ </template>
241
+ </div>
242
+ <div class="browser-annotation-tray-actions">
243
+ <button type="button" class="btn btn-field"
244
+ @click="$store.browserPage.draftAnnotationsToChat()">Draft to chat</button>
245
+ <button type="button" class="btn btn-ok"
246
+ @click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
247
+ </div>
248
+ </div>
249
<template x-if="!$store.browserPage.frameSrc && !$store.browserPage.isBusy()">
250
<div class="browser-empty">
251
<span class="material-symbols-outlined">captive_portal</span>
@@ -549,6 +628,34 @@
628
padding-bottom: 1px;
629
}
630
631
+ .browser-annotate-toggle {
632
+ display: inline-flex;
633
+ align-items: center;
634
+ justify-content: center;
635
+ gap: 6px;
636
+ min-width: 0;
637
+ min-height: var(--browser-control-size);
638
+ padding: 0 10px;
639
+ border: 1px solid var(--browser-chrome-border);
640
+ border-radius: var(--browser-control-radius);
641
+ background: color-mix(in srgb, var(--color-background) 26%, transparent);
642
+ color: color-mix(in srgb, var(--color-text) 74%, var(--color-primary) 26%);
643
+ font-size: 0.82rem;
644
+ font-weight: 650;
645
+ white-space: nowrap;
646
+ }
647
+
648
+ .browser-annotate-toggle:hover:not(:disabled),
649
+ .browser-annotate-toggle.is-active {
650
+ border-color: color-mix(in srgb, var(--color-primary) 48%, var(--browser-chrome-border));
651
+ background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
652
+ color: var(--color-text);
653
+ }
654
+
655
+ .browser-annotate-toggle .material-symbols-outlined {
656
+ font-size: 1.05rem;
657
+ }
658
+
659
.browser-session-controls .browser-extensions.is-active {
660
color: #2e7d32;
661
}
@@ -813,6 +920,10 @@
920
outline: none;
921
}
922
923
+ .browser-stage.is-annotating {
924
+ cursor: crosshair;
925
+ }
926
+
927
.browser-frame {
928
display: block;
929
position: absolute;
@@ -827,6 +938,194 @@
938
background: #fff;
939
}
940
941
+ .browser-annotation-layer {
942
+ position: absolute;
943
+ inset: 0;
944
+ z-index: 12;
945
+ cursor: crosshair;
946
+ touch-action: none;
947
+ background: rgba(37, 99, 235, 0.035);
948
+ }
949
+
950
+ .browser-annotation-layer.is-busy {
951
+ cursor: progress;
952
+ }
953
+
954
+ .browser-annotation-box {
955
+ position: absolute;
956
+ box-sizing: border-box;
957
+ min-width: 8px;
958
+ min-height: 8px;
959
+ border: 2px solid #3399ff;
960
+ background: rgba(51, 153, 255, 0.16);
961
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.52), 0 8px 22px rgba(0, 0, 0, 0.18);
962
+ pointer-events: none;
963
+ }
964
+
965
+ .browser-annotation-box.is-draft {
966
+ border-style: dashed;
967
+ background: rgba(51, 153, 255, 0.1);
968
+ }
969
+
970
+ .browser-annotation-number {
971
+ display: inline-flex;
972
+ align-items: center;
973
+ justify-content: center;
974
+ width: 22px;
975
+ min-width: 22px;
976
+ height: 22px;
977
+ min-height: 22px;
978
+ border-radius: 50%;
979
+ background: #3399ff;
980
+ color: #fff;
981
+ font-size: 0.74rem;
982
+ font-weight: 800;
983
+ line-height: 1;
984
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
985
+ }
986
+
987
+ .browser-annotation-box .browser-annotation-number {
988
+ position: absolute;
989
+ top: -12px;
990
+ left: -12px;
991
+ }
992
+
993
+ .browser-annotation-popover,
994
+ .browser-annotation-tray {
995
+ position: absolute;
996
+ z-index: 18;
997
+ border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
998
+ border-radius: 7px;
999
+ background: color-mix(in srgb, var(--color-background) 96%, #000 4%);
1000
+ color: var(--color-text);
1001
+ box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28);
1002
+ }
1003
+
1004
+ .browser-annotation-popover {
1005
+ display: flex;
1006
+ flex-direction: column;
1007
+ gap: 8px;
1008
+ padding: 10px;
1009
+ }
1010
+
1011
+ .browser-annotation-popover-title,
1012
+ .browser-annotation-tray-header {
1013
+ display: flex;
1014
+ align-items: center;
1015
+ gap: 8px;
1016
+ min-width: 0;
1017
+ font-size: 0.82rem;
1018
+ font-weight: 750;
1019
+ }
1020
+
1021
+ .browser-annotation-popover textarea {
1022
+ width: 100%;
1023
+ min-height: 82px;
1024
+ max-height: 160px;
1025
+ resize: vertical;
1026
+ padding: 8px;
1027
+ border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
1028
+ border-radius: 6px;
1029
+ background: var(--color-input);
1030
+ color: var(--color-text);
1031
+ font: inherit;
1032
+ line-height: 1.35;
1033
+ }
1034
+
1035
+ .browser-annotation-actions,
1036
+ .browser-annotation-tray-actions {
1037
+ display: flex;
1038
+ justify-content: flex-end;
1039
+ gap: 7px;
1040
+ }
1041
+
1042
+ .browser-annotation-actions .btn,
1043
+ .browser-annotation-tray-actions .btn {
1044
+ min-height: 30px;
1045
+ padding: 0 10px;
1046
+ white-space: nowrap;
1047
+ }
1048
+
1049
+ .browser-annotation-tray {
1050
+ right: 10px;
1051
+ bottom: 10px;
1052
+ display: flex;
1053
+ flex-direction: column;
1054
+ gap: 9px;
1055
+ width: min(360px, calc(100% - 20px));
1056
+ max-height: min(48%, 310px);
1057
+ padding: 10px;
1058
+ }
1059
+
1060
+ .browser-annotation-tray-header {
1061
+ justify-content: space-between;
1062
+ }
1063
+
1064
+ .browser-annotation-clear,
1065
+ .browser-annotation-chip button {
1066
+ display: inline-flex;
1067
+ align-items: center;
1068
+ justify-content: center;
1069
+ width: 24px;
1070
+ height: 24px;
1071
+ min-width: 24px;
1072
+ min-height: 24px;
1073
+ padding: 0;
1074
+ border: 0;
1075
+ border-radius: 6px;
1076
+ background: transparent;
1077
+ color: color-mix(in srgb, var(--color-text) 64%, transparent);
1078
+ cursor: pointer;
1079
+ }
1080
+
1081
+ .browser-annotation-clear:hover,
1082
+ .browser-annotation-chip button:hover {
1083
+ background: color-mix(in srgb, var(--color-panel) 82%, transparent);
1084
+ color: var(--color-text);
1085
+ }
1086
+
1087
+ .browser-annotation-clear .material-symbols-outlined,
1088
+ .browser-annotation-chip button .material-symbols-outlined {
1089
+ font-size: 16px;
1090
+ }
1091
+
1092
+ .browser-annotation-chips {
1093
+ display: flex;
1094
+ flex-direction: column;
1095
+ gap: 6px;
1096
+ min-height: 0;
1097
+ overflow: auto;
1098
+ }
1099
+
1100
+ .browser-annotation-chip {
1101
+ display: grid;
1102
+ grid-template-columns: auto minmax(0, 1fr) auto;
1103
+ align-items: center;
1104
+ gap: 7px;
1105
+ min-height: 32px;
1106
+ padding: 5px 6px;
1107
+ border: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
1108
+ border-radius: 7px;
1109
+ background: color-mix(in srgb, var(--color-panel) 74%, transparent);
1110
+ }
1111
+
1112
+ .browser-annotation-chip .browser-annotation-number {
1113
+ width: 20px;
1114
+ min-width: 20px;
1115
+ height: 20px;
1116
+ min-height: 20px;
1117
+ font-size: 0.68rem;
1118
+ }
1119
+
1120
+ .browser-annotation-chip-text {
1121
+ min-width: 0;
1122
+ overflow: hidden;
1123
+ text-overflow: ellipsis;
1124
+ white-space: nowrap;
1125
+ font-size: 0.78rem;
1126
+ line-height: 1.25;
1127
+ }
1128
+
1129
.browser-empty {
1130
display: flex;
1131
align-items: center;
@@ -928,6 +1227,16 @@
1227
height: var(--browser-control-size);
1228
}
1229
1230
+ .browser-annotate-toggle {
1231
+ width: var(--browser-control-size);
1232
+ min-width: var(--browser-control-size);
1233
+ padding: 0;
1234
+ }
1235
+
1236
+ .browser-annotate-toggle span:not(.material-symbols-outlined) {
1237
+ display: none;
1238
+ }
1239
+
1240
.browser-extension-dropdown {
1241
right: 0;
1242
left: auto;
plugins/_browser/webui/browser-store.js
+457
-14
@@ -13,6 +13,9 @@ const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
13
const BROWSER_CONFIG_REFRESH_MS = 15000;
14
const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
15
const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
16
+const ANNOTATION_DRAG_THRESHOLD = 6;
17
+const ANNOTATION_MAX_COMMENTS = 24;
18
+const ANNOTATION_DOM_LIMIT = 1200;
19
20
function makeViewerToken() {
21
return globalThis.crypto?.randomUUID?.()
@@ -60,6 +63,13 @@ const model = {
63
address: "",
64
frameSrc: "",
65
frameState: null,
66
+ annotating: false,
67
+ annotationComments: [],
68
+ annotationDraft: null,
69
+ annotationDraftText: "",
70
+ annotationDragRect: null,
71
+ annotationBusy: false,
72
+ annotationError: "",
73
connected: false,
74
switchingBrowserId: null,
75
commandInFlight: false,
@@ -76,6 +86,8 @@ const model = {
86
_viewportSyncTimer: null,
87
_lastViewportKey: "",
88
_lastViewport: null,
89
+ _annotationPointer: null,
90
+ _annotationSequence: 0,
91
_mode: "",
92
_surfaceMounted: false,
93
_surfaceSwitching: false,
@@ -613,6 +625,7 @@ const model = {
625
626
async command(command, extra = {}) {
627
this.error = "";
628
+ this.annotationError = "";
629
this.commandInFlight = true;
630
const previousActiveBrowserId = this.activeBrowserId;
631
try {
@@ -642,13 +655,17 @@ const model = {
655
this.frameState = null;
656
this.frameSrc = "";
657
}
645
- if (result.state?.currentUrl || result.currentUrl) {
646
- this.address = result.state?.currentUrl || result.currentUrl;
647
- }
648
- this.applySnapshot(data.snapshot);
649
- const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
650
- if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
651
- await this.connectViewer({ browserId: this.activeBrowserId });
658
+ if (result.state?.currentUrl || result.currentUrl) {
659
+ this.address = result.state?.currentUrl || result.currentUrl;
660
+ }
661
+ this.applySnapshot(data.snapshot);
662
+ if (["navigate", "back", "forward", "reload", "close"].includes(String(command || "").toLowerCase())) {
663
+ this.clearAnnotationsForBrowser(previousActiveBrowserId);
664
+ this.cancelAnnotationDraft();
665
+ }
666
+ const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
667
+ if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
668
+ await this.connectViewer({ browserId: this.activeBrowserId });
669
}
670
} catch (error) {
671
this.error = error instanceof Error ? error.message : String(error);
@@ -761,17 +778,22 @@ const model = {
778
return null;
779
},
780
764
- applyActiveFrameState(nextState = null) {
765
- if (!nextState) return;
766
- const stateId = this.normalizeBrowserId(nextState.id);
767
- if (stateId && this.activeBrowserId && !this.sameBrowserId(stateId, this.activeBrowserId)) {
768
- return;
781
+ applyActiveFrameState(nextState = null) {
782
+ if (!nextState) return;
783
+ const stateId = this.normalizeBrowserId(nextState.id);
784
+ if (stateId && this.activeBrowserId && !this.sameBrowserId(stateId, this.activeBrowserId)) {
785
+ return;
786
}
787
+ const previousUrl = String(this.frameState?.currentUrl || "");
788
+ const nextUrl = String(nextState.currentUrl || "");
789
this.frameState = nextState;
790
+ if (previousUrl && nextUrl && previousUrl !== nextUrl) {
791
+ this.cancelAnnotationDraft();
792
+ }
793
if (!this.addressFocused && nextState.currentUrl) {
794
this.address = nextState.currentUrl;
773
- }
774
- },
795
+ }
796
+ },
797
798
applySnapshot(snapshot = null) {
799
if (!snapshot?.image) return;
@@ -805,6 +827,7 @@ const model = {
827
if (this.activeBrowserId !== previous) {
828
this._lastViewportKey = "";
829
this._lastViewport = null;
830
+ this.cancelAnnotationDraft();
831
}
832
},
833
@@ -849,6 +872,419 @@ const model = {
872
};
873
},
874
875
+ handleKeydown(event) {
876
+ const annotateShortcut = event?.key === "." && (event.metaKey || event.ctrlKey) && !event.altKey;
877
+ if (annotateShortcut && this._surfaceMounted) {
878
+ event.preventDefault();
879
+ event.stopPropagation?.();
880
+ this.toggleAnnotationMode();
881
+ return;
882
+ }
883
+
884
+ if (this.annotating) {
885
+ if (event?.key === "Escape") {
886
+ event.preventDefault();
887
+ if (this.annotationDraft || this.annotationDragRect) {
888
+ this.cancelAnnotationDraft();
889
+ } else {
890
+ this.toggleAnnotationMode(false);
891
+ }
892
+ }
893
+ return;
894
+ }
895
+
896
+ void this.sendKey(event);
897
+ },
898
+
899
+ handleStageWheel(event) {
900
+ if (this.annotating) return;
901
+ void this.sendWheel(event);
902
+ },
903
+
904
+ toggleAnnotationMode(force = null) {
905
+ const nextValue = force === null ? !this.annotating : Boolean(force);
906
+ if (nextValue && !this.canAnnotate()) return;
907
+
908
+ this.annotating = nextValue;
909
+ this.annotationError = "";
910
+ this.closeExtensionsMenu();
911
+ if (!nextValue) {
912
+ this.cancelAnnotationDraft();
913
+ this.annotationDragRect = null;
914
+ this._annotationPointer = null;
915
+ } else {
916
+ this._stageElement?.focus?.({ preventScroll: true });
917
+ }
918
+ },
919
+
920
+ canAnnotate() {
921
+ return Boolean(this.activeBrowserId && this.frameSrc && !this.isBusy());
922
+ },
923
+
924
+ activeAnnotationUrl() {
925
+ return String(this.frameState?.currentUrl || this.address || "about:blank");
926
+ },
927
+
928
+ visibleAnnotations() {
929
+ const browserId = this.normalizeBrowserId(this.activeBrowserId);
930
+ const url = this.activeAnnotationUrl();
931
+ return this.annotationComments.filter((annotation) => (
932
+ this.sameBrowserId(annotation.browserId, browserId)
933
+ && String(annotation.url || "") === url
934
+ ));
935
+ },
936
+
937
+ nextAnnotationIndex() {
938
+ return this.visibleAnnotations().length + 1;
939
+ },
940
+
941
+ clearVisibleAnnotations() {
942
+ this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl());
943
+ },
944
+
945
+ clearAnnotationsForBrowser(browserId, url = null) {
946
+ const numericBrowserId = this.normalizeBrowserId(browserId);
947
+ if (!numericBrowserId) return;
948
+ this.annotationComments = this.annotationComments.filter((annotation) => {
949
+ if (!this.sameBrowserId(annotation.browserId, numericBrowserId)) return true;
950
+ return url ? String(annotation.url || "") !== String(url) : false;
951
+ });
952
+ },
953
+
954
+ annotationBoxStyle(rect = {}) {
955
+ const viewport = this.currentViewportSize() || this._lastViewport || {};
956
+ const width = Math.max(1, Number(viewport.width || rect.width || 1));
957
+ const height = Math.max(1, Number(viewport.height || rect.height || 1));
958
+ const normalized = this.clampAnnotationRect(rect);
959
+ return [
960
+ `left: ${(normalized.x / width) * 100}%`,
961
+ `top: ${(normalized.y / height) * 100}%`,
962
+ `width: ${(Math.max(1, normalized.width) / width) * 100}%`,
963
+ `height: ${(Math.max(1, normalized.height) / height) * 100}%`,
964
+ ].join("; ");
965
+ },
966
+
967
+ annotationPopoverStyle() {
968
+ const rect = this.annotationDraft?.rect || this.annotationDragRect || {};
969
+ const viewport = this.currentViewportSize() || this._lastViewport || {};
970
+ const width = Math.max(1, Number(viewport.width || 1));
971
+ const height = Math.max(1, Number(viewport.height || 1));
972
+ const popoverWidth = Math.min(320, Math.max(240, width - 20));
973
+ const popoverHeight = 190;
974
+ const nextLeft = Math.min(
975
+ Math.max(10, Number(rect.x || 0) + Number(rect.width || 0) + 10),
976
+ Math.max(10, width - popoverWidth - 10),
977
+ );
978
+ const nextTop = Math.min(
979
+ Math.max(10, Number(rect.y || 0) + Number(rect.height || 0) + 10),
980
+ Math.max(10, height - popoverHeight - 10),
981
+ );
982
+ return [
983
+ `left: ${(nextLeft / width) * 100}%`,
984
+ `top: ${(nextTop / height) * 100}%`,
985
+ `width: min(${popoverWidth}px, calc(100% - 20px))`,
986
+ ].join("; ");
987
+ },
988
+
989
+ annotationDraftTitle() {
990
+ if (!this.annotationDraft) return "Annotation";
991
+ return this.annotationDraft.kind === "area" ? "Area annotation" : "Element annotation";
992
+ },
993
+
994
+ stagePointForEvent(event) {
995
+ const image = this._stageElement?.querySelector?.(".browser-frame") || null;
996
+ return this.pointerCoordinatesFor(event, image);
997
+ },
998
+
999
+ normalizeAnnotationRect(start = {}, end = {}) {
1000
+ const x1 = Number(start.x || 0);
1001
+ const y1 = Number(start.y || 0);
1002
+ const x2 = Number(end.x || x1);
1003
+ const y2 = Number(end.y || y1);
1004
+ return this.clampAnnotationRect({
1005
+ x: Math.min(x1, x2),
1006
+ y: Math.min(y1, y2),
1007
+ width: Math.abs(x2 - x1),
1008
+ height: Math.abs(y2 - y1),
1009
+ });
1010
+ },
1011
+
1012
+ clampAnnotationRect(rect = {}) {
1013
+ const viewport = this.currentViewportSize() || this._lastViewport || {};
1014
+ const viewportWidth = Math.max(1, Number(viewport.width || rect.x + rect.width || 1));
1015
+ const viewportHeight = Math.max(1, Number(viewport.height || rect.y + rect.height || 1));
1016
+ const x = Math.max(0, Math.min(viewportWidth, Number(rect.x || 0)));
1017
+ const y = Math.max(0, Math.min(viewportHeight, Number(rect.y || 0)));
1018
+ const width = Math.max(1, Math.min(viewportWidth - x, Number(rect.width || 1)));
1019
+ const height = Math.max(1, Math.min(viewportHeight - y, Number(rect.height || 1)));
1020
+ return {
1021
+ x: Math.round(x),
1022
+ y: Math.round(y),
1023
+ width: Math.round(width),
1024
+ height: Math.round(height),
1025
+ };
1026
+ },
1027
+
1028
+ startAnnotationSelection(event) {
1029
+ if (!this.annotating || this.annotationBusy || !this.canAnnotate()) return;
1030
+ const point = this.stagePointForEvent(event);
1031
+ if (!point) return;
1032
+ this.cancelAnnotationDraft();
1033
+ this.annotationError = "";
1034
+ this._annotationPointer = {
1035
+ id: event.pointerId,
1036
+ start: point,
1037
+ last: point,
1038
+ };
1039
+ this.annotationDragRect = this.clampAnnotationRect({
1040
+ x: point.x,
1041
+ y: point.y,
1042
+ width: 1,
1043
+ height: 1,
1044
+ });
1045
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
1046
+ },
1047
+
1048
+ moveAnnotationSelection(event) {
1049
+ if (!this.annotating || !this._annotationPointer) return;
1050
+ if (event.pointerId !== this._annotationPointer.id) return;
1051
+ const point = this.stagePointForEvent(event);
1052
+ if (!point) return;
1053
+ this._annotationPointer.last = point;
1054
+ this.annotationDragRect = this.normalizeAnnotationRect(this._annotationPointer.start, point);
1055
+ },
1056
+
1057
+ async finishAnnotationSelection(event) {
1058
+ if (!this.annotating || !this._annotationPointer) return;
1059
+ if (event.pointerId !== this._annotationPointer.id) return;
1060
+ const pointer = this._annotationPointer;
1061
+ this._annotationPointer = null;
1062
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
1063
+ const endPoint = this.stagePointForEvent(event) || pointer.last || pointer.start;
1064
+ const rect = this.normalizeAnnotationRect(pointer.start, endPoint);
1065
+ this.annotationDragRect = null;
1066
+ const isDrag = rect.width >= ANNOTATION_DRAG_THRESHOLD || rect.height >= ANNOTATION_DRAG_THRESHOLD;
1067
+ const point = {
1068
+ x: Math.round(endPoint.x),
1069
+ y: Math.round(endPoint.y),
1070
+ };
1071
+ const payload = {
1072
+ kind: isDrag ? "area" : "element",
1073
+ point,
1074
+ rect: isDrag ? rect : null,
1075
+ viewport: this.currentViewportSize(),
1076
+ url: this.activeAnnotationUrl(),
1077
+ title: this.activeTitle,
1078
+ };
1079
+ await this.createAnnotationDraft(payload, isDrag ? rect : {
1080
+ x: point.x - 10,
1081
+ y: point.y - 10,
1082
+ width: 20,
1083
+ height: 20,
1084
+ });
1085
+ },
1086
+
1087
+ cancelAnnotationSelection(event = null) {
1088
+ if (event && this._annotationPointer?.id === event.pointerId) {
1089
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
1090
+ }
1091
+ this._annotationPointer = null;
1092
+ this.annotationDragRect = null;
1093
+ },
1094
+
1095
+ cancelAnnotationDraft() {
1096
+ this.annotationDraft = null;
1097
+ this.annotationDraftText = "";
1098
+ this.annotationDragRect = null;
1099
+ },
1100
+
1101
+ async createAnnotationDraft(payload, fallbackRect) {
1102
+ if (!this.activeBrowserId || !this.contextId) return;
1103
+ const sequence = this._annotationSequence + 1;
1104
+ const browserId = this.activeBrowserId;
1105
+ const url = this.activeAnnotationUrl();
1106
+ const title = this.activeTitle;
1107
+ this._annotationSequence = sequence;
1108
+ this.annotationBusy = true;
1109
+ this.annotationError = "";
1110
+ try {
1111
+ const response = await websocket.request(
1112
+ "browser_viewer_annotation",
1113
+ {
1114
+ context_id: this.contextId,
1115
+ browser_id: browserId,
1116
+ viewer_id: this._viewerToken,
1117
+ payload,
1118
+ },
1119
+ { timeoutMs: 10000 },
1120
+ );
1121
+ if (sequence !== this._annotationSequence) return;
1122
+ const data = firstOk(response);
1123
+ const metadata = data.annotation || {};
1124
+ this.annotationDraft = {
1125
+ id: makeViewerToken(),
1126
+ browserId,
1127
+ url,
1128
+ title,
1129
+ kind: metadata.kind || payload.kind,
1130
+ rect: this.annotationRectFromMetadata(metadata, fallbackRect),
1131
+ metadata,
1132
+ createdAt: Date.now(),
1133
+ };
1134
+ this.annotationDraftText = "";
1135
+ } catch (error) {
1136
+ this.annotationError = error instanceof Error ? error.message : String(error);
1137
+ this.error = this.annotationError;
1138
+ } finally {
1139
+ if (sequence === this._annotationSequence) {
1140
+ this.annotationBusy = false;
1141
+ }
1142
+ }
1143
+ },
1144
+
1145
+ annotationRectFromMetadata(metadata = {}, fallbackRect = {}) {
1146
+ const targetRect = metadata?.target?.rect || metadata?.rect || null;
1147
+ return this.clampAnnotationRect(targetRect || fallbackRect);
1148
+ },
1149
+
1150
+ addAnnotationComment() {
1151
+ const comment = String(this.annotationDraftText || "").trim();
1152
+ if (!this.annotationDraft || !comment) return;
1153
+ if (this.visibleAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
1154
+ this.annotationError = `Keep each batch to ${ANNOTATION_MAX_COMMENTS} annotations or fewer.`;
1155
+ this.error = this.annotationError;
1156
+ return;
1157
+ }
1158
+ this.annotationComments = [
1159
+ ...this.annotationComments,
1160
+ {
1161
+ ...this.annotationDraft,
1162
+ comment,
1163
+ index: this.nextAnnotationIndex(),
1164
+ },
1165
+ ];
1166
+ this.cancelAnnotationDraft();
1167
+ },
1168
+
1169
+ removeAnnotationComment(annotationId) {
1170
+ this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
1171
+ },
1172
+
1173
+ annotationChipLabel(annotation) {
1174
+ const prefix = annotation?.kind === "area" ? "Area" : "Element";
1175
+ return `${prefix} ${annotation?.index || ""}`.trim();
1176
+ },
1177
+
1178
+ formatAnnotationRect(rect = {}) {
1179
+ const normalized = this.clampAnnotationRect(rect);
1180
+ return `x=${normalized.x}, y=${normalized.y}, width=${normalized.width}, height=${normalized.height}`;
1181
+ },
1182
+
1183
+ redactAnnotationText(value) {
1184
+ return String(value || "")
1185
+ .replace(/(<input\b(?=[^>]*\btype=(["'])?password\2?)[^>]*?)\svalue=(["'])[\s\S]*?\3/giu, "$1 value=\"[redacted]\"")
1186
+ .replace(/\b(password|passcode|token|secret|value)=((["'])[\s\S]{1,240}?\3)/giu, "$1=\"[redacted]\"");
1187
+ },
1188
+
1189
+ formatAnnotationMetadata(metadata = {}) {
1190
+ const lines = [];
1191
+ const target = metadata.target || {};
1192
+ const selector = target.selector || metadata.selector || "";
1193
+ const summary = target.summary || metadata.summary || "";
1194
+ const dom = this.redactAnnotationText(target.dom || metadata.dom || "").slice(0, ANNOTATION_DOM_LIMIT);
1195
+
1196
+ if (selector) {
1197
+ lines.push(`Selector: ${selector}`);
1198
+ }
1199
+ if (target.tagName || target.role || target.id || target.name || target.classes) {
1200
+ lines.push([
1201
+ "Element:",
1202
+ target.tagName ? `<${String(target.tagName).toLowerCase()}>` : "",
1203
+ target.role ? `role=${target.role}` : "",
1204
+ target.id ? `id=${target.id}` : "",
1205
+ target.name ? `name=${target.name}` : "",
1206
+ target.classes ? `class=${target.classes}` : "",
1207
+ ].filter(Boolean).join(" "));
1208
+ }
1209
+ if (summary) {
1210
+ lines.push(`Summary: ${summary}`);
1211
+ }
1212
+ if (Array.isArray(metadata.elements) && metadata.elements.length) {
1213
+ lines.push("Intersecting elements:");
1214
+ metadata.elements.slice(0, 8).forEach((element, index) => {
1215
+ const elementLabel = [
1216
+ `${index + 1}.`,
1217
+ element.tagName ? `<${String(element.tagName).toLowerCase()}>` : "",
1218
+ element.selector || "",
1219
+ element.summary || "",
1220
+ ].filter(Boolean).join(" ");
1221
+ lines.push(elementLabel);
1222
+ });
1223
+ }
1224
+ if (dom) {
1225
+ lines.push(`DOM: ${dom}`);
1226
+ }
1227
+ return lines.join("\n");
1228
+ },
1229
+
1230
+ buildAnnotationsPrompt() {
1231
+ const annotations = this.visibleAnnotations();
1232
+ if (!annotations.length) return "";
1233
+ const lines = [
1234
+ "Browser annotations",
1235
+ `Page title: ${this.activeTitle}`,
1236
+ `Page URL: ${this.activeAnnotationUrl()}`,
1237
+ `Browser id: ${this.activeBrowserId}`,
1238
+ "",
1239
+ ];
1240
+ annotations.forEach((annotation, index) => {
1241
+ lines.push(
1242
+ `Annotation ${index + 1}`,
1243
+ `Comment: ${annotation.comment}`,
1244
+ `Selection kind: ${annotation.kind}`,
1245
+ `Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
1246
+ );
1247
+ const metadata = this.formatAnnotationMetadata(annotation.metadata);
1248
+ if (metadata) {
1249
+ lines.push(metadata);
1250
+ }
1251
+ lines.push("");
1252
+ });
1253
+ return lines.join("\n").trim();
1254
+ },
1255
+
1256
+ draftAnnotationsToChat() {
1257
+ const prompt = this.buildAnnotationsPrompt();
1258
+ if (!prompt) return;
1259
+ const existingMessage = String(chatInputStore.message || "").trim();
1260
+ chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
1261
+ chatInputStore.adjustTextareaHeight?.();
1262
+ chatInputStore.focus?.();
1263
+ this.clearVisibleAnnotations();
1264
+ this.toggleAnnotationMode(false);
1265
+ },
1266
+
1267
+ async sendAnnotationsToChat() {
1268
+ const prompt = this.buildAnnotationsPrompt();
1269
+ if (!prompt) return;
1270
+ chatInputStore.message = prompt;
1271
+ chatInputStore.adjustTextareaHeight?.();
1272
+ try {
1273
+ if (typeof chatInputStore.sendMessage === "function") {
1274
+ await chatInputStore.sendMessage();
1275
+ } else if (typeof globalThis.sendMessage === "function") {
1276
+ await globalThis.sendMessage();
1277
+ } else {
1278
+ chatInputStore.focus?.();
1279
+ return;
1280
+ }
1281
+ this.clearVisibleAnnotations();
1282
+ this.toggleAnnotationMode(false);
1283
+ } catch (error) {
1284
+ this.error = error instanceof Error ? error.message : String(error);
1285
+ }
1286
+ },
1287
+
1288
currentViewportSize() {
1289
const stage = this._stageElement;
1290
if (!stage) return null;
@@ -910,6 +1346,7 @@ const model = {
1346
},
1347
1348
async sendMouse(eventType, event) {
1349
+ if (this.annotating) return;
1350
if (!this.activeBrowserId || !event?.currentTarget) return;
1351
const pointer = this.pointerCoordinatesFor(event);
1352
if (!pointer) return;
@@ -960,6 +1397,7 @@ const model = {
1397
},
1398
1399
async sendKey(event) {
1400
+ if (this.annotating) return;
1401
if (!this.activeBrowserId) return;
1402
if (event.ctrlKey || event.metaKey || event.altKey) return;
1403
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
@@ -982,6 +1420,11 @@ const model = {
1420
this._surfaceMounted = false;
1421
this._surfaceSwitching = false;
1422
this.commandInFlight = false;
1423
+ this.annotating = false;
1424
+ this.annotationBusy = false;
1425
+ this.annotationError = "";
1426
+ this.cancelAnnotationDraft();
1427
+ this.cancelAnnotationSelection();
1428
if (this.contextId) {
1429
try {
1430
await websocket.emit("browser_viewer_unsubscribe", { context_id: this.contextId });
tests/test_browser_agent_regressions.py
+188
-16
@@ -2,7 +2,7 @@ import asyncio
2
import sys
3
import threading
4
from pathlib import Path
5
-from types import SimpleNamespace
5
+from types import ModuleType, SimpleNamespace
6
7
import pytest
8
@@ -11,6 +11,73 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
11
if str(PROJECT_ROOT) not in sys.path:
12
sys.path.insert(0, str(PROJECT_ROOT))
13
14
+
15
+class _TestAgentContext:
16
+ @staticmethod
17
+ def get(context_id):
18
+ return None
19
+
20
+
21
+class _TestResponse(SimpleNamespace):
22
+ def __init__(self, message="", break_loop=False, **kwargs):
23
+ super().__init__(message=message, break_loop=break_loop, **kwargs)
24
+
25
+
26
+class _TestTool:
27
+ def __init__(
28
+ self,
29
+ agent=None,
30
+ name="",
31
+ method=None,
32
+ args=None,
33
+ message="",
34
+ loop_data=None,
35
+ **kwargs,
36
+ ):
37
+ self.agent = agent
38
+ self.name = name
39
+ self.method = method
40
+ self.args = args or {}
41
+ self.message = message
42
+ self.loop_data = loop_data
43
+
44
+
45
+class _TestWsHandler:
46
+ def __init__(self, *args, **kwargs):
47
+ self.emitted = []
48
+
49
+ async def emit_to(self, sid, event, data, correlation_id=None):
50
+ self.emitted.append((sid, event, data, correlation_id))
51
+
52
+
53
+class _TestWsResult(dict):
54
+ @staticmethod
55
+ def error(code="", message="", correlation_id=None):
56
+ return _TestWsResult(
57
+ {
58
+ "ok": False,
59
+ "code": code,
60
+ "error": message,
61
+ "correlation_id": correlation_id,
62
+ }
63
+ )
64
+
65
+
66
+sys.modules.setdefault("agent", SimpleNamespace(AgentContext=_TestAgentContext))
67
+sys.modules.setdefault("helpers.tool", SimpleNamespace(Response=_TestResponse, Tool=_TestTool))
68
+sys.modules.setdefault("helpers.ws", SimpleNamespace(WsHandler=_TestWsHandler))
69
+sys.modules.setdefault("helpers.ws_manager", SimpleNamespace(WsResult=_TestWsResult))
70
+_model_config_stub = ModuleType("plugins._model_config.helpers.model_config")
71
+_model_config_stub.get_presets = lambda: []
72
+_model_config_stub.get_preset_by_name = lambda name: None
73
+_model_config_stub.get_chat_model_config = lambda agent=None: {}
74
+sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_config_stub)
75
+
76
+
77
+@pytest.fixture
78
+def anyio_backend():
79
+ return "asyncio"
80
+
81
from plugins._browser.helpers.config import (
82
build_browser_launch_config,
83
get_browser_main_model_summary,
@@ -63,6 +130,8 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
130
131
assert config == {
132
"extension_paths": [str(extension_dir)],
133
+ "default_homepage": "about:blank",
134
+ "autofocus_active_page": True,
135
"model_preset": "",
136
}
137
@@ -246,7 +315,7 @@ def test_browser_extension_manager_uses_modern_chrome_prodversion(monkeypatch):
315
316
317
def test_browser_extension_menu_exposes_agent_and_url_paths():
249
- html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
318
+ html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
319
encoding="utf-8"
320
)
321
skill = PROJECT_ROOT / "skills" / "a0-browser-ext" / "SKILL.md"
@@ -279,7 +348,7 @@ def test_browser_viewer_allows_slow_extension_startup():
348
349
350
def test_browser_ui_spinners_have_browser_local_animation():
282
- main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
351
+ main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
352
encoding="utf-8"
353
)
354
config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
@@ -305,7 +374,7 @@ def test_browser_extension_settings_stay_user_facing():
374
375
376
def test_browser_viewer_uses_tabs_for_session_switching():
308
- main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
377
+ main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
378
encoding="utf-8"
379
)
380
browser_store = (
@@ -329,7 +398,7 @@ def test_browser_viewer_uses_cdp_screencast_transport():
398
ws_browser = (PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py").read_text(
399
encoding="utf-8"
400
)
332
- main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
401
+ main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
402
encoding="utf-8"
403
)
404
runtime = (
@@ -339,12 +408,12 @@ def test_browser_viewer_uses_cdp_screencast_transport():
408
PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
409
).read_text(encoding="utf-8")
410
342
- assert 'runtime.call("screenshot"' not in ws_browser
411
+ assert 'runtime.call("screenshot"' in ws_browser
412
assert "SCREENCAST_QUALITY = 92" in ws_browser
413
assert "initial_viewport = self._viewport_from_data(data)" in ws_browser
414
assert '"set_viewport"' in ws_browser
415
assert "start_screencast" in ws_browser
347
- assert "read_screencast_frame" in ws_browser
416
+ assert "pop_screencast_frame" in ws_browser
417
assert "stop_screencast" in ws_browser
418
assert '"Page.startScreencast"' in runtime
419
assert '"Page.screencastFrame"' in runtime
@@ -360,12 +429,54 @@ def test_browser_viewer_uses_cdp_screencast_transport():
429
assert "viewport_height: initialViewport?.height" in browser_store
430
assert "this.frameState = data.state || null" not in browser_store
431
assert "overflow: hidden;" in main_html
363
- assert "object-fit: fill;" not in main_html
364
- assert "height: auto;" in main_html
432
+ assert "object-fit: fill;" in main_html
433
assert "image-rendering: auto;" in main_html
434
435
368
-@pytest.mark.asyncio
436
+def test_browser_annotate_mode_ui_and_prompt_hooks():
437
+ panel_html = (
438
+ PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html"
439
+ ).read_text(encoding="utf-8")
440
+ browser_store = (
441
+ PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
442
+ ).read_text(encoding="utf-8")
443
+
444
+ assert "Annotate" in panel_html
445
+ assert "Annotating" in panel_html
446
+ assert "browser-annotation-layer" in panel_html
447
+ assert "browser-annotation-tray" in panel_html
448
+ assert "Draft to chat" in panel_html
449
+ assert "Send now" in panel_html
450
+ assert "@pointerdown.stop.prevent=\"$store.browserPage.startAnnotationSelection($event)\"" in panel_html
451
+ assert "@keydown.window=\"$store.browserPage.handleKeydown($event)\"" in panel_html
452
+ assert "annotationComments: []" in browser_store
453
+ assert '"browser_viewer_annotation"' in browser_store
454
+ assert 'event?.key === "." && (event.metaKey || event.ctrlKey)' in browser_store
455
+ assert "Browser annotations" in browser_store
456
+ assert "Comment:" in browser_store
457
+ assert "Coordinates:" in browser_store
458
+ assert "Selector:" in browser_store
459
+ assert "DOM:" in browser_store
460
+ assert "value=\\\"[redacted]\\\"" in browser_store
461
+
462
+
463
+def test_browser_runtime_and_content_helper_expose_annotation_target():
464
+ runtime = (
465
+ PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
466
+ ).read_text(encoding="utf-8")
467
+ helper = (
468
+ PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
469
+ ).read_text(encoding="utf-8")
470
+
471
+ assert "async def annotation_target" in runtime
472
+ assert "globalThis.__spaceBrowserPageContent__.annotate(payload || null)" in runtime
473
+ assert "function annotate(payload = null)" in helper
474
+ assert "annotate," in helper
475
+ assert "sanitizeAnnotationDom" in helper
476
+ assert "password" in helper
477
+
478
+
479
+@pytest.mark.anyio
480
async def test_browser_screencast_acknowledges_and_drops_stale_frames():
481
first_image = (
482
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL"
@@ -526,7 +637,7 @@ def test_browser_save_plugin_config_does_not_restart_runtimes_for_preset_only(mo
637
assert restarted == []
638
639
529
-@pytest.mark.asyncio
640
+@pytest.mark.anyio
641
async def test_browser_tool_dispatches_direct_actions(monkeypatch):
642
calls = []
643
@@ -558,7 +669,7 @@ async def test_browser_tool_dispatches_direct_actions(monkeypatch):
669
assert calls == [("content", (1, None))]
670
671
561
-@pytest.mark.asyncio
672
+@pytest.mark.anyio
673
async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
674
class FakeRuntime:
675
def __init__(self) -> None:
@@ -608,7 +719,7 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
719
assert ("sid-1", "ctx") not in ws_browser_module.WsBrowser._streams
720
721
611
-@pytest.mark.asyncio
722
+@pytest.mark.anyio
723
async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
724
calls = []
725
@@ -642,11 +753,14 @@ async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
753
"sid-1",
754
)
755
645
- assert result == {"state": {"ok": True, "method": "set_viewport", "args": (7, 1280, 720)}}
756
+ assert result == {
757
+ "state": {"ok": True, "method": "set_viewport", "args": (7, 1280, 720)},
758
+ "snapshot": None,
759
+ }
760
assert calls == [("set_viewport", (7, 1280, 720), {})]
761
762
649
-@pytest.mark.asyncio
763
+@pytest.mark.anyio
764
async def test_browser_viewer_wheel_input_dispatches_scroll(monkeypatch):
765
calls = []
766
@@ -682,10 +796,68 @@ async def test_browser_viewer_wheel_input_dispatches_scroll(monkeypatch):
796
"sid-1",
797
)
798
685
- assert result == {"state": {"ok": True, "method": "wheel", "args": (3, 320.0, 480.0, 0.0, 640.0)}}
799
+ assert result == {
800
+ "state": {"ok": True, "method": "wheel", "args": (3, 320.0, 480.0, 0.0, 640.0)},
801
+ "snapshot": None,
802
+ }
803
assert calls == [("wheel", (3, 320.0, 480.0, 0.0, 640.0), {})]
804
805
806
+@pytest.mark.anyio
807
+async def test_browser_viewer_annotation_dispatches_runtime(monkeypatch):
808
+ calls = []
809
+
810
+ class FakeRuntime:
811
+ async def call(self, method, *args, **kwargs):
812
+ calls.append((method, args, kwargs))
813
+ return {
814
+ "kind": "element",
815
+ "point": {"x": 320, "y": 180},
816
+ "target": {"tagName": "BUTTON", "selector": "#save"},
817
+ }
818
+
819
+ async def fake_get_runtime(context_id, create=True):
820
+ assert context_id == "ctx"
821
+ assert create is False
822
+ return FakeRuntime()
823
+
824
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
825
+
826
+ handler = ws_browser_module.WsBrowser(
827
+ SimpleNamespace(),
828
+ threading.RLock(),
829
+ manager=None,
830
+ )
831
+
832
+ payload = {
833
+ "kind": "element",
834
+ "point": {"x": 320, "y": 180},
835
+ "viewport": {"width": 1280, "height": 720},
836
+ }
837
+ result = await handler.process(
838
+ "browser_viewer_annotation",
839
+ {
840
+ "context_id": "ctx",
841
+ "browser_id": 4,
842
+ "viewer_id": "viewer-1",
843
+ "payload": payload,
844
+ },
845
+ "sid-1",
846
+ )
847
+
848
+ assert result == {
849
+ "annotation": {
850
+ "kind": "element",
851
+ "point": {"x": 320, "y": 180},
852
+ "target": {"tagName": "BUTTON", "selector": "#save"},
853
+ },
854
+ "context_id": "ctx",
855
+ "browser_id": 4,
856
+ "viewer_id": "viewer-1",
857
+ }
858
+ assert calls == [("annotation_target", (4, payload), {})]
859
+
860
+
861
def test_browser_cleanup_extensions_follow_extensible_path_layout():
862
extension = __import__("helpers.extension", fromlist=["_get_extension_classes"])
863
remove_classes = extension._get_extension_classes( # type: ignore[attr-defined]
tests/test_webui_extension_surfaces.py
+16
-1
@@ -5,6 +5,7 @@ import tempfile
5
import threading
6
from contextlib import contextmanager
7
from pathlib import Path
8
+from types import SimpleNamespace
9
from typing import Iterator
10
11
import pytest
@@ -14,6 +15,15 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
15
if str(PROJECT_ROOT) not in sys.path:
16
sys.path.insert(0, str(PROJECT_ROOT))
17
18
+
19
+class _TestAgentContext:
20
+ @staticmethod
21
+ def get(context_id):
22
+ return None
23
+
24
+
25
+sys.modules.setdefault("agent", SimpleNamespace(AgentContext=_TestAgentContext))
26
+
27
from api.load_webui_extensions import LoadWebuiExtensions
28
29
@@ -69,6 +79,11 @@ def _new_handler() -> LoadWebuiExtensions:
79
return LoadWebuiExtensions(app, threading.RLock())
80
81
82
+@pytest.fixture
83
+def anyio_backend():
84
+ return "asyncio"
85
+
86
+
87
def _assert_surface_anchor_in_template(surface: str, template_rel_path: str) -> None:
88
template_path = PROJECT_ROOT / template_rel_path
89
template_html = template_path.read_text(encoding="utf-8")
@@ -118,7 +133,7 @@ def _temporary_probe_plugin(surface: str) -> Iterator[tuple[str, str]]:
133
cache.clear("*(plugins)*")
134
135
121
-@pytest.mark.asyncio
136
+@pytest.mark.anyio
137
@pytest.mark.parametrize(
138
("surface", "template_rel_path"),
139
SURFACE_SCENARIOS,