Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1a7fb5a
fix(annotation): keep preview marks on the artifact they were drawn on
linghaoSu Aug 5, 2026
677fa56
test(annotation): update Draw render-mode expectation to the anchor c…
linghaoSu Aug 5, 2026
6e2ba8f
fix(annotation): read mark bounds in frame layout space, not the scal…
linghaoSu Aug 5, 2026
fbf371b
fix(annotation): address review — URL anchor bridge, retryable probe,…
linghaoSu Aug 5, 2026
8345bab
fix(annotation): probe anchors on the active frame; make probe give-u…
linghaoSu Aug 5, 2026
c5765ba
fix(annotation): drop stale anchor replies; keep bridge failures unan…
linghaoSu Aug 5, 2026
f284004
fix(annotation): bridge large streamed HTML; supersede stale probes b…
linghaoSu Aug 5, 2026
cbd438d
fix(annotation): invalidate probes on document load, filter invisible…
linghaoSu Aug 5, 2026
b716c74
fix(annotation): pre-capture sync joins the in-flight probe chain
linghaoSu Aug 5, 2026
11a04bd
fix(annotation): dragged labels drop their stale anchor; suffix joins…
linghaoSu Aug 5, 2026
07ccc96
fix(annotation): freeze anchor writes through capture; suffix-aware I…
linghaoSu Aug 5, 2026
fe12b0e
fix(annotation): capture freeze covers the resize re-anchor and the b…
linghaoSu Aug 5, 2026
6e59b71
ci: retrigger after UI P0 infra flake
linghaoSu Aug 5, 2026
a6260db
fix(annotation): defer the inactive cleanup while a send is in flight
linghaoSu Aug 6, 2026
12b53fc
fix(annotation): queue a trailing pass when a stale probe reply is di…
linghaoSu Aug 6, 2026
eec0415
fix(annotation): sanitize bridge anchor replies; lean bounded probe e…
linghaoSu Aug 6, 2026
c953192
fix(annotation): refuse oversized forged anchor replies before iterating
linghaoSu Aug 6, 2026
e92affd
Merge remote-tracking branch 'upstream/main' into pr6476
linghaoSu Aug 15, 2026
b770034
test(daemon): close keep-alive sockets before ending the raw-range suite
linghaoSu Aug 15, 2026
65521e4
Merge remote-tracking branch 'upstream/main' into pr6476
linghaoSu Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions apps/daemon/src/routes/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,31 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge>
var data = ev && ev.data;
if (!data || !data.type) return;
if (data.type === 'od:url-selection-bridge-probe') {
window.parent.postMessage({ type: 'od:url-selection-bridge-ready' }, '*');
window.parent.postMessage({ type: 'od:url-selection-bridge-ready', markAnchors: true }, '*');
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
return;
}
// Annotation marks anchor to the element they were drawn on so they
// survive a reflow (#6361). Draw mode needs element boxes on demand
// WITHOUT comment mode's hover/click interception, and the boxes must
// come from the frame the user actually sees — for powered previews
// that is this URL-loaded frame, not the hidden srcDoc twin. Keep the
// reply shape in sync with the srcDoc bridge in
// apps/web/src/runtime/srcdoc.ts (od:mark-anchor-targets).
if (data.type === 'od:mark-anchor-request') {
var markTargets = [];
try {
var found = allTargets();
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
for (var mi = 0; mi < found.length; mi++) {
markTargets.push({
elementId: found[mi].elementId,
selector: found[mi].selector,
position: found[mi].position
});
}
} catch (_) {}
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
try {
window.parent.postMessage({ type: 'od:mark-anchor-targets', id: data.id, targets: markTargets }, '*');
} catch (_) {}
return;
}
if (data.type === 'od:preview-runtime-state-capture' && data.id) {
Expand Down Expand Up @@ -1066,7 +1090,7 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge>
var mo = new MutationObserver(schedulePostTargets);
mo.observe(document.documentElement, { subtree: true, childList: true });
ensureStyle();
window.parent.postMessage({ type: 'od:url-selection-bridge-ready' }, '*');
window.parent.postMessage({ type: 'od:url-selection-bridge-ready', markAnchors: true }, '*');
})();
</script>`;

Expand Down
14 changes: 14 additions & 0 deletions apps/daemon/tests/project-file-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,20 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(html).not.toContain('data-od-url-scroll-bridge');
});

it('serves the Draw mark-anchor protocol from the URL selection bridge (issue #6361)', async () => {
// Draw-mode content anchoring must resolve element boxes from the frame
// the user sees. For powered previews that is the URL-loaded frame, so
// the daemon bridge answers od:mark-anchor-request and advertises the
// capability in its ready message; the host keys the URL-load decision
// (urlAnchorBridge) off that flag.
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=selection`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html).toContain("'od:mark-anchor-request'");
expect(html).toContain("type: 'od:mark-anchor-targets'");
expect(html).toContain("type: 'od:url-selection-bridge-ready', markAnchors: true");
});

it('injects the URL preview snapshot bridge only when requested', async () => {
const plain = await fetch(rawUrl('page.html'));
expect(await plain.text()).toBe('<html/>');
Expand Down
23 changes: 17 additions & 6 deletions apps/desktop/src/main/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1870,7 +1870,10 @@ function parsePrintReadyPdfOptions(value: unknown): PrintReadyPdfOptions {
// Rectangle. Returns undefined (capture the full page) when the payload is
// missing, not an object, or carries an invalid clip; valid clips are
// rounded and clamped so x/y stay >= 0 and width/height stay >= 1.
function parseCaptureClip(value: unknown): Electron.Rectangle | undefined {
export function parseCaptureClip(
value: unknown,
zoomFactor = 1,
): Electron.Rectangle | undefined {
if (value == null || typeof value !== "object" || Array.isArray(value)) return undefined;
const clip = (value as { clip?: unknown }).clip;
if (clip == null || typeof clip !== "object" || Array.isArray(clip)) return undefined;
Expand All @@ -1888,11 +1891,19 @@ function parseCaptureClip(value: unknown): Electron.Rectangle | undefined {
) {
return undefined;
}
// Invariant (issue #6361): the renderer measures the preview frame in CSS
// pixels (getBoundingClientRect), but capturePage() clips in DIP page
// coordinates. Those two spaces only coincide at zoom factor 1 — at any
// other UI zoom a CSS-pixel clip lands on the wrong window region, so the
// annotation screenshot captures app chrome instead of the marked artifact
// and the structured position no longer describes the same pixels. Convert
// once, here, at the single boundary where the two spaces meet.
const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
return {
x: Math.max(0, Math.round(x)),
y: Math.max(0, Math.round(y)),
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
x: Math.max(0, Math.round(x * zoom)),
y: Math.max(0, Math.round(y * zoom)),
width: Math.max(1, Math.round(width * zoom)),
height: Math.max(1, Math.round(height * zoom)),
};
}

Expand Down Expand Up @@ -2581,7 +2592,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
return { ok: false, reason: 'capture sender not allowed' };
}
try {
const clip = parseCaptureClip(rawOptions);
const clip = parseCaptureClip(rawOptions, window.webContents.getZoomFactor());
const image = clip
? await window.webContents.capturePage(clip)
: await window.webContents.capturePage();
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/tests/main/capture-clip-zoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';

import { parseCaptureClip } from '../../src/main/runtime.js';

// Issue #6361: the renderer measures the preview frame with
// getBoundingClientRect() — CSS pixels — but Electron's capturePage() clips in
// DIP page coordinates. The two spaces coincide only at zoom factor 1. Without
// the conversion a mark made inside the preview at a non-100% zoom captured a
// region shifted up/left off the artifact, so the annotation PNG and the
// structured position handed to the agent described different pixels.
//
// Measured on macOS/HiDPI at zoom 1.095 (Cmd + once), marking a 40px band:
// frame rect (CSS px) {x:468, y:148, w:692, h:666}
// returned bitmap 1384 × 1332 == 692 × 666 × 2.0
// but devicePixelRatio 2.1909 (== 2 × 1.095)
// The bitmap being exactly 2.0× the CSS rect — not 2.1909× — is the proof that
// Electron consumed the numbers as DIP. The mark landed one band high: the red
// box painted at rows 683–765 while the marked band occupied rows 777–864.
const PREVIEW_FRAME = { x: 420, y: 96, width: 1000, height: 600 };

describe('parseCaptureClip zoom conversion', () => {
test('100% zoom is identity', () => {
expect(parseCaptureClip({ clip: PREVIEW_FRAME }, 1)).toEqual(PREVIEW_FRAME);
});

test('125% zoom scales origin and size together', () => {
expect(parseCaptureClip({ clip: PREVIEW_FRAME }, 1.25)).toEqual({
x: 525,
y: 120,
width: 1250,
height: 750,
});
});

test('150% zoom', () => {
expect(parseCaptureClip({ clip: PREVIEW_FRAME }, 1.5)).toEqual({
x: 630,
y: 144,
width: 1500,
height: 900,
});
});

test('80% zoom', () => {
expect(parseCaptureClip({ clip: PREVIEW_FRAME }, 0.8)).toEqual({
x: 336,
y: 77,
width: 800,
height: 480,
});
});

test('the clip stays inside the frame it was measured from at every zoom', () => {
// The failure users saw was the clip drifting *out* of the preview frame.
// At any zoom the converted clip must be exactly the frame in DIP space.
for (const zoom of [0.8, 1, 1.25, 1.5, 2]) {
const clip = parseCaptureClip({ clip: PREVIEW_FRAME }, zoom)!;
expect(clip.x / zoom).toBeCloseTo(PREVIEW_FRAME.x, 0);
expect(clip.y / zoom).toBeCloseTo(PREVIEW_FRAME.y, 0);
expect(clip.width / zoom).toBeCloseTo(PREVIEW_FRAME.width, 0);
expect(clip.height / zoom).toBeCloseTo(PREVIEW_FRAME.height, 0);
}
});

test('defaults to identity when the zoom factor is missing or nonsensical', () => {
expect(parseCaptureClip({ clip: PREVIEW_FRAME })).toEqual(PREVIEW_FRAME);
expect(parseCaptureClip({ clip: PREVIEW_FRAME }, 0)).toEqual(PREVIEW_FRAME);
expect(parseCaptureClip({ clip: PREVIEW_FRAME }, Number.NaN)).toEqual(PREVIEW_FRAME);
});

test('invalid payloads still yield a full-page capture', () => {
expect(parseCaptureClip(null, 1.25)).toBeUndefined();
expect(parseCaptureClip({}, 1.25)).toBeUndefined();
expect(parseCaptureClip({ clip: { x: 1, y: 2, width: 'wide', height: 4 } }, 1.25)).toBeUndefined();
});
});
13 changes: 12 additions & 1 deletion apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9498,12 +9498,19 @@ function HtmlViewer({
return s != null && htmlNeedsPoweredPreview(s);
}, [routingHtmlSource, serverPoweredPreviewRequired]);
const [urlSelectionBridgeReady, setUrlSelectionBridgeReady] = useState(false);
// The daemon's URL selection bridge advertises `markAnchors: true` once it
// serves the od:mark-anchor-request protocol Draw anchoring needs (#6361).
// Older daemon responses (or the raw preview route's legacy bridge) omit the
// flag, so Draw falls back to srcDoc there instead of resolving anchors
// against a frame that cannot answer.
const [urlAnchorBridgeReady, setUrlAnchorBridgeReady] = useState(false);
const urlLoadDecision: UrlLoadDecision = {
mode,
isDeck: effectiveDeck,
commentMode: boardMode,
urlCommentBridge: urlSelectionBridgeReady,
urlSnapshotBridge: urlSelectionBridgeReady,
urlAnchorBridge: urlAnchorBridgeReady,
editMode: manualEditMode,
urlModeBridge,
inspectMode,
Expand Down Expand Up @@ -9651,6 +9658,7 @@ function HtmlViewer({
if (activeFilesRefreshPending || previewSrcCarriesCurrentRefresh) return;
setPreviewSrcUrl(effectiveBasePreviewSrcUrl);
setUrlSelectionBridgeReady(false);
setUrlAnchorBridgeReady(false);
}, [activeFilesRefreshPending, effectiveBasePreviewSrcUrl, previewSrcCarriesCurrentRefresh]);
useEffect(() => {
const activeFrame = useUrlLoadPreview
Expand Down Expand Up @@ -10066,9 +10074,10 @@ function HtmlViewer({
const frame = urlPreviewIframeRef.current;
if (ev.source !== frame?.contentWindow) return;
if (frame.getAttribute('src') === 'about:blank') return;
const data = ev.data as { type?: string } | null;
const data = ev.data as { type?: string; markAnchors?: boolean } | null;
if (data?.type !== 'od:url-selection-bridge-ready') return;
setUrlSelectionBridgeReady(true);
setUrlAnchorBridgeReady(data.markAnchors === true);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
Expand Down Expand Up @@ -15335,6 +15344,7 @@ function HtmlViewer({
setUrlPreviewFirstLoadPending(false);
}
setUrlSelectionBridgeReady(false);
setUrlAnchorBridgeReady(false);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
Expand Down Expand Up @@ -15378,6 +15388,7 @@ function HtmlViewer({
setUrlPreviewFirstLoadPending(false);
}
setUrlSelectionBridgeReady(false);
setUrlAnchorBridgeReady(false);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
Expand Down
Loading
Loading