Skip to content

Commit 712ba41

Browse files
esokulluclaude
andcommitted
fix(screenshots): mechanical image→CSS click conversion + hi-DPI tabs-fallback alignment
Follow-ups to the #311 image budget (issue #311 review): - click({x, y, from_screenshot: true}): when the last screenshot shown to the model was downscaled for maxImageDimension, the extension now stores the image→CSS scale per tab and converts coordinates mechanically — the model no longer does arithmetic from a prose note. 1:1 captures clear the stored scale so it can never go stale. - Chrome tabs-API screenshot fallback: normalize native-DPR captures to exact CSS pixels before claiming coord alignment (pre-existing 2× drift on hi-DPI displays), and decode real bitmap dims for the budget shrink instead of trusting CSS dims. - Chrome screenshot tool: structured result now reports coordAligned: false when the cap forced a resize, matching the description. - Firefox: screenshot notes no longer claim 1:1 when the capture was downscaled; the tool capture is CSS-locked with scale:1; both notes and tool descriptions point at from_screenshot instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1085064 commit 712ba41

5 files changed

Lines changed: 267 additions & 19 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ export class Agent {
191191
// `maxScreenshotsPerTurn` for every automatic capture (initial viewport
192192
// AND post-action). Reset when a run starts and on tab cleanup.
193193
this.autoScreenshotCount = new Map();
194+
// tabId -> {scaleX, scaleY} image-pixel→CSS-pixel factors for the most
195+
// recent screenshot shown to the model. Set when maxImageDimension forced
196+
// a downscale; cleared when the last capture was 1:1. Consumed by
197+
// click({x, y, from_screenshot: true}) so the extension — not the model —
198+
// does the coordinate conversion.
199+
this.screenshotClickScale = new Map();
194200
this.costAllowanceSessionUsd = DEFAULT_CLOUD_COST_ALLOWANCE_USD;
195201
this.costAllowanceTotalUsd = DEFAULT_CLOUD_COST_ALLOWANCE_USD;
196202
this.cloudCostSpentUsd = 0;
@@ -5690,6 +5696,35 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
56905696
}
56915697
}
56925698

5699+
/**
5700+
* Downscale a capture to exactly `cssW`×`cssH` so image pixel (X, Y) ==
5701+
* CSS pixel (X, Y). `tabs.captureVisibleTab` has no clip/scale control and
5702+
* snapshots at native devicePixelRatio, so on hi-DPI displays (e.g. Apple
5703+
* Retina, DPR 2) the raw image is DPR× larger than the CSS viewport —
5704+
* pixel coords read off it would be 2× off. Returns the input unchanged
5705+
* when it already matches (DPR 1) or on any decode failure.
5706+
*/
5707+
async _normalizeDataUrlToCssPixels(dataUrl, cssW, cssH) {
5708+
try {
5709+
if (!dataUrl || !(cssW > 0) || !(cssH > 0)) return dataUrl;
5710+
const resp = await fetch(dataUrl);
5711+
const bmp = await createImageBitmap(await resp.blob());
5712+
if (bmp.width === cssW && bmp.height === cssH) return dataUrl;
5713+
const canvas = new OffscreenCanvas(cssW, cssH);
5714+
const ctx = canvas.getContext('2d');
5715+
ctx.imageSmoothingEnabled = true;
5716+
ctx.imageSmoothingQuality = 'high';
5717+
ctx.drawImage(bmp, 0, 0, bmp.width, bmp.height, 0, 0, cssW, cssH);
5718+
const blob = await canvas.convertToBlob({
5719+
type: 'image/jpeg',
5720+
quality: Agent.IMAGE_BUDGET.initialJpegQuality,
5721+
});
5722+
return Agent._bufferToDataUrl(await blob.arrayBuffer(), 'image/jpeg');
5723+
} catch {
5724+
return dataUrl;
5725+
}
5726+
}
5727+
56935728
/**
56945729
* End-to-end "shrink this screenshot to fit the vision budget" pass.
56955730
* Decodes, resizes to the target dims picked by `_fitImageDimensions`,
@@ -5810,6 +5845,45 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
58105845
};
58115846
}
58125847

5848+
/**
5849+
* Record the image-pixel→CSS-pixel factors of the most recent screenshot
5850+
* shown to the model for `tabId`. Factors of 1 (a true 1:1 capture) clear
5851+
* the entry so a stale scale from an earlier downscaled capture can never
5852+
* corrupt clicks planned off a newer aligned one.
5853+
*/
5854+
_setScreenshotClickScale(tabId, scaleX, scaleY) {
5855+
const sx = Number(scaleX);
5856+
const sy = Number(scaleY);
5857+
if (!Number.isFinite(sx) || !Number.isFinite(sy) || sx <= 0 || sy <= 0) return;
5858+
if (Math.abs(sx - 1) < 1e-6 && Math.abs(sy - 1) < 1e-6) {
5859+
this.screenshotClickScale.delete(tabId);
5860+
return;
5861+
}
5862+
this.screenshotClickScale.set(tabId, { scaleX: sx, scaleY: sy });
5863+
}
5864+
5865+
/**
5866+
* Resolve click({x, y}) args to CSS pixels. When the model sets
5867+
* `from_screenshot: true` AND the last screenshot for this tab was
5868+
* downscaled, multiply by the stored factors — the extension does the
5869+
* conversion mechanically instead of trusting the model to do arithmetic
5870+
* from a prose note. Otherwise coords pass through unchanged (an aligned
5871+
* screenshot's image pixels already ARE CSS pixels).
5872+
*/
5873+
_screenshotClickCoords(tabId, args = {}) {
5874+
const x = Number(args.x);
5875+
const y = Number(args.y);
5876+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
5877+
if (!args.from_screenshot) return { x, y, converted: false };
5878+
const scale = this.screenshotClickScale.get(tabId);
5879+
if (!scale) return { x, y, converted: false };
5880+
return {
5881+
x: Math.round(x * scale.scaleX),
5882+
y: Math.round(y * scale.scaleY),
5883+
converted: true,
5884+
};
5885+
}
5886+
58135887
/**
58145888
* Coerce storage / settings values for image budget (issue #311). Rejects
58155889
* corrupted or out-of-range values so provider payloads never see e.g.
@@ -9144,6 +9218,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
91449218
this.mastodonStates.delete(tabId);
91459219
this.lastAutoScreenshotTs.delete(tabId);
91469220
this.autoScreenshotCount.delete(tabId);
9221+
this.screenshotClickScale.delete(tabId);
91479222
this.lastSeenAdapter.delete(tabId);
91489223
this.activeSkillIds.delete(tabId);
91499224
this._runModeOverrides.delete(tabId);
@@ -13567,6 +13642,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1356713642
let description = '';
1356813643
let probe = null;
1356913644
let coordAligned = false;
13645+
// True when maxImageDimension forced a resize of a coord_aligned
13646+
// capture — the structured result must then report coordAligned:
13647+
// false so the model never trusts raw image pixels as CSS pixels.
13648+
let coordDownscaled = false;
1357013649
let blankFrameRetry = null;
1357113650
const wantSave = !!(args && args.save);
1357213651
try {
@@ -13596,14 +13675,19 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1359613675
const needsResize = cssW > budget.maxTargetPx || cssH > budget.maxTargetPx;
1359713676
if (needsResize) {
1359813677
const shrunk = await this._shrinkImageForBudget(rawUrl, cssW, cssH, budget);
13599-
const scaleX = (cssW / Math.max(1, shrunk.width)).toFixed(4);
13600-
const scaleY = (cssH / Math.max(1, shrunk.height)).toFixed(4);
13678+
this._setScreenshotClickScale(
13679+
tabId,
13680+
cssW / Math.max(1, shrunk.width),
13681+
cssH / Math.max(1, shrunk.height),
13682+
);
1360113683
return {
1360213684
dataUrl: shrunk.dataUrl,
1360313685
saveDataUrl: rawUrl,
13604-
description: `Screenshot captured via CDP (${screenshot.data.length} bytes). CSS viewport ${cssW}×${cssH} downscaled to ${shrunk.width}×${shrunk.height} for maxImageDimension — multiply image-pixel coords by (${scaleX}, ${scaleY}) to get CSS click coords.`,
13686+
coordDownscaled: true,
13687+
description: `Screenshot captured via CDP (${screenshot.data.length} bytes). CSS viewport ${cssW}×${cssH} downscaled to ${shrunk.width}×${shrunk.height} for maxImageDimension. To click something you located on this image, pass its image-pixel coords as click({x, y, from_screenshot: true}) — they are converted to CSS pixels automatically.`,
1360513688
};
1360613689
}
13690+
this._setScreenshotClickScale(tabId, 1, 1);
1360713691
return {
1360813692
dataUrl: await this._compressJpegToByteCeiling(rawUrl, budget),
1360913693
saveDataUrl: rawUrl,
@@ -13661,6 +13745,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1366113745
dataUrl = captured.dataUrl;
1366213746
saveDataUrl = captured.saveDataUrl || null;
1366313747
description = captured.description || '';
13748+
coordDownscaled = !!captured.coordDownscaled;
1366413749
blankFrameRetry = captured.blankFrameRetry || null;
1366513750
} catch {
1366613751
const tab = await chrome.tabs.get(tabId);
@@ -13679,7 +13764,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1367913764
const cssH = Math.max(1, Math.round(probe?.innerHeight || 768));
1368013765
const budget = this._budgetForCapture();
1368113766
if (!coordAligned) {
13682-
const shrunk = await this._shrinkImageForBudget(rawUrl, cssW, cssH, budget);
13767+
// captureVisibleTab snapshots at native DPR — pass 0,0 so the
13768+
// shrink decodes the real bitmap dims instead of trusting CSS
13769+
// dims (which would skip the resize entirely on hi-DPI and ship
13770+
// an oversized image while reporting CSS-sized dimensions).
13771+
const shrunk = await this._shrinkImageForBudget(rawUrl, 0, 0, budget);
1368313772
return {
1368413773
dataUrl: shrunk.dataUrl,
1368513774
saveDataUrl: rawUrl,
@@ -13690,16 +13779,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1369013779
const coordBudget = this._budgetForCoordAlignedCapture();
1369113780
const needsResize = cssW > coordBudget.maxTargetPx || cssH > coordBudget.maxTargetPx;
1369213781
if (needsResize) {
13693-
const shrunk = await this._shrinkImageForBudget(rawUrl, cssW, cssH, coordBudget);
13694-
const scaleX = (cssW / Math.max(1, shrunk.width)).toFixed(4);
13695-
const scaleY = (cssH / Math.max(1, shrunk.height)).toFixed(4);
13782+
const shrunk = await this._shrinkImageForBudget(rawUrl, 0, 0, coordBudget);
13783+
this._setScreenshotClickScale(
13784+
tabId,
13785+
cssW / Math.max(1, shrunk.width),
13786+
cssH / Math.max(1, shrunk.height),
13787+
);
1369613788
return {
1369713789
dataUrl: shrunk.dataUrl,
1369813790
saveDataUrl: rawUrl,
13699-
description: `Screenshot captured via tabs API (${shrunk.dataUrl.length} bytes base64). CSS viewport ${cssW}×${cssH} downscaled to ${shrunk.width}×${shrunk.height} for maxImageDimension — multiply image-pixel coords by (${scaleX}, ${scaleY}) to get CSS click coords.`,
13791+
coordDownscaled: true,
13792+
description: `Screenshot captured via tabs API (${shrunk.dataUrl.length} bytes base64). CSS viewport ${cssW}×${cssH} downscaled to ${shrunk.width}×${shrunk.height} for maxImageDimension. To click something you located on this image, pass its image-pixel coords as click({x, y, from_screenshot: true}) — they are converted to CSS pixels automatically.`,
1370013793
};
1370113794
}
13702-
const compressed = await this._compressJpegToByteCeiling(rawUrl, coordBudget);
13795+
// Native-DPR raw → exact CSS dims so "CSS-pixel aligned" is true
13796+
// on hi-DPI displays too (pre-#311 this shipped the DPR-scaled
13797+
// image while claiming alignment).
13798+
const aligned = await this._normalizeDataUrlToCssPixels(rawUrl, cssW, cssH);
13799+
const compressed = await this._compressJpegToByteCeiling(aligned, coordBudget);
13800+
this._setScreenshotClickScale(tabId, 1, 1);
1370313801
return {
1370413802
dataUrl: compressed,
1370513803
saveDataUrl: rawUrl,
@@ -13710,6 +13808,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1371013808
dataUrl = captured?.dataUrl || null;
1371113809
saveDataUrl = captured?.saveDataUrl || null;
1371213810
description = captured?.description || '';
13811+
coordDownscaled = !!captured?.coordDownscaled;
1371313812
blankFrameRetry = captured?.blankFrameRetry || null;
1371413813
}
1371513814
if (!dataUrl) {
@@ -13768,7 +13867,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1376813867
method: 'vision_describe',
1376913868
description: `[Screenshot described by vision model ${desc.model}]\n${desc.text}`,
1377013869
page: probe || undefined,
13771-
coordAligned,
13870+
coordAligned: coordAligned && !coordDownscaled,
1377213871
blankFrameRetry: blankFrameRetry || undefined,
1377313872
savedFile: savedFile || undefined,
1377413873
};
@@ -13787,7 +13886,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1378713886
method: 'image_attach',
1378813887
description,
1378913888
page: probe || undefined,
13790-
coordAligned,
13889+
coordAligned: coordAligned && !coordDownscaled,
1379113890
blankFrameRetry: blankFrameRetry || undefined,
1379213891
savedFile: savedFile || undefined,
1379313892
_attachImage: dataUrl,
@@ -13802,7 +13901,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1380213901
description: `Screenshot saved to ${savedFile.filename}. (The active model has no vision, so the image was not shown to the model.)`,
1380313902
savedFile,
1380413903
page: probe || undefined,
13805-
coordAligned,
13904+
coordAligned: coordAligned && !coordDownscaled,
1380613905
blankFrameRetry: blankFrameRetry || undefined,
1380713906
};
1380813907
}
@@ -15094,6 +15193,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1509415193
error: this._normalizedCoordinateRecoveryError(tabId, args),
1509515194
};
1509615195
}
15196+
// from_screenshot: coords were read off the most recent screenshot;
15197+
// when that capture was downscaled for maxImageDimension, convert
15198+
// image pixels → CSS pixels here so the model never does the math.
15199+
const mapped = this._screenshotClickCoords(tabId, args);
15200+
if (mapped?.converted) args = { ...args, x: mapped.x, y: mapped.y };
1509715201
}
1509815202

1509915203
await cdpClient.attach(tabId);

src/chrome/src/agent/tools.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export const AGENT_TOOLS = [
262262
type: 'function',
263263
function: {
264264
name: 'click',
265-
description: 'Click an element. FOUR ways to use it: (1) CSS selector, (2) visible text, (3) element index from get_interactive_elements, (4) x/y coordinates. For text clicks, default matching is EXACT and case-insensitive. You can opt into broader matching with `textMatch: "prefix"` or `textMatch: "contains"`. Note: jQuery/Playwright pseudo-classes like `:contains()` and `:has-text()` are NOT valid CSS and will fail; use the `text` parameter instead. COORDINATES are CSS pixels; if using visual context, only use CSS-pixel-aligned coordinates. Prefer click_ax({ref_id}) whenever possible because it avoids coordinate drift.',
265+
description: 'Click an element. FOUR ways to use it: (1) CSS selector, (2) visible text, (3) element index from get_interactive_elements, (4) x/y coordinates. For text clicks, default matching is EXACT and case-insensitive. You can opt into broader matching with `textMatch: "prefix"` or `textMatch: "contains"`. Note: jQuery/Playwright pseudo-classes like `:contains()` and `:has-text()` are NOT valid CSS and will fail; use the `text` parameter instead. COORDINATES are CSS pixels; if x/y were read off a screenshot image that was reported as downscaled, pass from_screenshot: true and the image pixels are converted to CSS pixels automatically. Prefer click_ax({ref_id}) whenever possible because it avoids coordinate drift.',
266266
parameters: {
267267
type: 'object',
268268
properties: {
@@ -272,6 +272,7 @@ export const AGENT_TOOLS = [
272272
index: { type: 'number', description: 'Index from get_interactive_elements result' },
273273
x: { type: 'number', description: 'X coordinate to click' },
274274
y: { type: 'number', description: 'Y coordinate to click' },
275+
from_screenshot: { type: 'boolean', description: 'Set true when x/y were read off the most recent screenshot image. If that screenshot was downscaled, coordinates are converted from image pixels to CSS pixels automatically; harmless otherwise.' },
275276
},
276277
},
277278
},

0 commit comments

Comments
 (0)