Make browser annotation tray draggable

Fix annotation panel stacking so draft popovers render above the annotations recap.\n\nAllow the annotations recap tray to float within the browser stage by dragging its header, with bounded positioning and cleanup when annotations are cleared or the browser surface unmounts.

Alessandro committed May 2, 2026 at 16:55 UTC 39a96012f998a5222763d69c4d48be0896705dbf
3 files changed +117 -3
plugins/_browser/webui/browser-panel.html
+20 -3
@@ -220,9 +220,15 @@
220 </template>
221 <div class="browser-annotation-tray"
222 x-show="$store.browserPage.visibleAnnotations().length"
223 + :class="{ 'is-floating': $store.browserPage.annotationTrayPosition, 'is-dragging': $store.browserPage.annotationTrayDragging }"
224 + :style="$store.browserPage.annotationTrayStyle()"
225 x-transition style="display: none;"
224 - @click.stop @pointerdown.stop @keydown.stop>
225 - <div class="browser-annotation-tray-header">
226 + @click.stop @pointerdown.stop @keydown.stop
227 + @pointermove.window="$store.browserPage.moveAnnotationTrayDrag($event)"
228 + @pointerup.window="$store.browserPage.finishAnnotationTrayDrag($event)"
229 + @pointercancel.window="$store.browserPage.finishAnnotationTrayDrag($event)">
230 + <div class="browser-annotation-tray-header"
231 + @pointerdown.stop="$store.browserPage.startAnnotationTrayDrag($event)">
232 <span>Annotations</span>
233 <button type="button" class="browser-annotation-clear" title="Clear annotations"
234 aria-label="Clear annotations" @click="$store.browserPage.clearVisibleAnnotations()">
@@ -990,7 +996,6 @@
996 .browser-annotation-popover,
997 .browser-annotation-tray {
998 position: absolute;
993 - z-index: 18;
999 border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
1000 border-radius: 7px;
1001 background: color-mix(in srgb, var(--color-background) 96%, #000 4%);
@@ -999,6 +1004,7 @@
1004 }
1005
1006 .browser-annotation-popover {
1007 + z-index: 24;
1008 display: flex;
1009 flex-direction: column;
1010 gap: 8px;
@@ -1044,6 +1050,7 @@
1050 }
1051
1052 .browser-annotation-tray {
1053 + z-index: 20;
1054 right: 10px;
1055 bottom: 10px;
1056 display: flex;
@@ -1054,8 +1061,18 @@
1061 padding: 10px;
1062 }
1063
1064 + .browser-annotation-tray.is-dragging {
1065 + user-select: none;
1066 + }
1067 +
1068 .browser-annotation-tray-header {
1069 justify-content: space-between;
1070 + cursor: grab;
1071 + user-select: none;
1072 + }
1073 +
1074 + .browser-annotation-tray.is-dragging .browser-annotation-tray-header {
1075 + cursor: grabbing;
1076 }
1077
1078 .browser-annotation-clear,
plugins/_browser/webui/browser-store.js
+93
@@ -23,6 +23,7 @@ const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
23 const ANNOTATION_DRAG_THRESHOLD = 6;
24 const ANNOTATION_MAX_COMMENTS = 24;
25 const ANNOTATION_DOM_LIMIT = 1200;
26 +const ANNOTATION_TRAY_MARGIN = 10;
27
28 function makeViewerToken() {
29 return globalThis.crypto?.randomUUID?.()
@@ -106,6 +107,8 @@ const model = {
107 annotationDragRect: null,
108 annotationBusy: false,
109 annotationError: "",
110 + annotationTrayPosition: null,
111 + annotationTrayDragging: false,
112 connected: false,
113 switchingBrowserId: null,
114 commandInFlight: false,
@@ -127,6 +130,7 @@ const model = {
130 _lastViewportKey: "",
131 _lastViewport: null,
132 _annotationPointer: null,
133 + _annotationTrayDrag: null,
134 _annotationSequence: 0,
135 _mode: "",
136 _surfaceMounted: false,
@@ -1588,8 +1592,93 @@ const model = {
1592 return this.visibleAnnotations().length + 1;
1593 },
1594
1595 + annotationTrayStyle() {
1596 + if (!this.annotationTrayPosition) return {};
1597 + const position = this.clampAnnotationTrayPosition(this.annotationTrayPosition);
1598 + return {
1599 + left: `${position.x}px`,
1600 + top: `${position.y}px`,
1601 + right: "auto",
1602 + bottom: "auto",
1603 + };
1604 + },
1605 +
1606 + clampAnnotationTrayPosition(position = {}) {
1607 + const stageRect = this._stageElement?.getBoundingClientRect?.();
1608 + const stageWidth = Math.max(1, Number(stageRect?.width || 0));
1609 + const stageHeight = Math.max(1, Number(stageRect?.height || 0));
1610 + const width = Math.max(180, Number(position.width || 0));
1611 + const height = Math.max(90, Number(position.height || 0));
1612 + const maxX = Math.max(ANNOTATION_TRAY_MARGIN, stageWidth - width - ANNOTATION_TRAY_MARGIN);
1613 + const maxY = Math.max(ANNOTATION_TRAY_MARGIN, stageHeight - height - ANNOTATION_TRAY_MARGIN);
1614 + return {
1615 + x: Math.min(Math.max(ANNOTATION_TRAY_MARGIN, Number(position.x || 0)), maxX),
1616 + y: Math.min(Math.max(ANNOTATION_TRAY_MARGIN, Number(position.y || 0)), maxY),
1617 + width,
1618 + height,
1619 + };
1620 + },
1621 +
1622 + startAnnotationTrayDrag(event) {
1623 + if (event.button !== 0) return;
1624 + if (event.target?.closest?.("button, input, select, textarea, a")) return;
1625 + const tray = event.currentTarget?.closest?.(".browser-annotation-tray");
1626 + const stageRect = this._stageElement?.getBoundingClientRect?.();
1627 + const trayRect = tray?.getBoundingClientRect?.();
1628 + if (!tray || !stageRect || !trayRect) return;
1629 +
1630 + const position = this.clampAnnotationTrayPosition({
1631 + x: trayRect.left - stageRect.left,
1632 + y: trayRect.top - stageRect.top,
1633 + width: trayRect.width,
1634 + height: trayRect.height,
1635 + });
1636 + this.annotationTrayPosition = position;
1637 + this.annotationTrayDragging = true;
1638 + this._annotationTrayDrag = {
1639 + id: event.pointerId,
1640 + target: event.currentTarget,
1641 + x: event.clientX,
1642 + y: event.clientY,
1643 + startX: position.x,
1644 + startY: position.y,
1645 + width: position.width,
1646 + height: position.height,
1647 + };
1648 + event.currentTarget?.setPointerCapture?.(event.pointerId);
1649 + event.preventDefault();
1650 + },
1651 +
1652 + moveAnnotationTrayDrag(event) {
1653 + const drag = this._annotationTrayDrag;
1654 + if (!drag || event.pointerId !== drag.id) return;
1655 + this.annotationTrayPosition = this.clampAnnotationTrayPosition({
1656 + x: drag.startX + event.clientX - drag.x,
1657 + y: drag.startY + event.clientY - drag.y,
1658 + width: drag.width,
1659 + height: drag.height,
1660 + });
1661 + event.preventDefault();
1662 + },
1663 +
1664 + finishAnnotationTrayDrag(event = null) {
1665 + const drag = this._annotationTrayDrag;
1666 + if (!drag || (event?.pointerId && event.pointerId !== drag.id)) return;
1667 + try {
1668 + drag.target?.releasePointerCapture?.(drag.id);
1669 + } catch {}
1670 + this._annotationTrayDrag = null;
1671 + this.annotationTrayDragging = false;
1672 + },
1673 +
1674 + resetAnnotationTrayPosition() {
1675 + this.finishAnnotationTrayDrag();
1676 + this.annotationTrayPosition = null;
1677 + },
1678 +
1679 clearVisibleAnnotations() {
1680 this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId);
1681 + this.resetAnnotationTrayPosition();
1682 },
1683
1684 clearAnnotationsForBrowser(browserId, url = null, contextId = "") {
@@ -1821,6 +1910,9 @@ const model = {
1910
1911 removeAnnotationComment(annotationId) {
1912 this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
1913 + if (!this.visibleAnnotations().length) {
1914 + this.resetAnnotationTrayPosition();
1915 + }
1916 },
1917
1918 annotationChipLabel(annotation) {
@@ -2114,6 +2206,7 @@ const model = {
2206 this.annotationError = "";
2207 this.cancelAnnotationDraft();
2208 this.cancelAnnotationSelection();
2209 + this.resetAnnotationTrayPosition();
2210 if (this.contextId) {
2211 try {
2212 await websocket.emit("browser_viewer_unsubscribe", { context_id: this.contextId });
tests/test_browser_agent_regressions.py
+4
@@ -788,11 +788,15 @@ def test_browser_annotate_mode_ui_and_prompt_hooks():
788 assert "Annotating" in panel_html
789 assert "browser-annotation-layer" in panel_html
790 assert "browser-annotation-tray" in panel_html
791 + assert "annotationTrayStyle()" in panel_html
792 + assert "startAnnotationTrayDrag($event)" in panel_html
793 assert "Draft to chat" in panel_html
794 assert "Send now" in panel_html
795 assert "@pointerdown.stop.prevent=\"$store.browserPage.startAnnotationSelection($event)\"" in panel_html
796 assert "@keydown.window=\"$store.browserPage.handleKeydown($event)\"" in panel_html
797 assert "annotationComments: []" in browser_store
798 + assert "annotationTrayPosition: null" in browser_store
799 + assert "clampAnnotationTrayPosition" in browser_store
800 assert '"browser_viewer_annotation"' in browser_store
801 assert 'event?.key === "." && (event.metaKey || event.ctrlKey)' in browser_store
802 assert "Browser annotations" in browser_store