Files
orca/config
Jinwoo Hong d86d5cbbee fix(mobile): settle browser dialogs on the stream that reports them, map taps through the page scale, and sweep the frame budget on the real encoder (OTA phase C, C6.7) (#21799)
* fix(browser): settle a page's dialog on the stream that reported it (OTA phase C, C6.7)

Chromium hands `Page.javascriptDialogOpening` to one CDP session and takes the
answer only from that session; a client attaching afterwards is told `No dialog
is showing`, and every renderer-bound command it sends first blocks behind the
dialog it was sent to clear. Measured on Chromium 1217, 2026-09-20.

`browser.dialogAccept` and `browser.dialogDismiss` went to the agent-browser
bridge, which is always a later client, so the reply never reached
`Page.handleJavaScriptDialog`: the page stayed blocked and its next dialog
never opened. The screencast is the session that reported the dialog, so it is
the session that answers it. With no stream live on the page the bridge path is
unchanged.

No RPC shape changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the browser dialog card until the page takes the answer (OTA phase C, C6.7)

The card was cleared on the press, before the reply was sent. A page blocked on
a dialog is still blocked until the host settles it, so the pane reported an
answer the page never got and left the user looking at a stream nothing could
move. The host's `dialogClosed` is what says the page took it, and that already
clears the card.

The port-pair case runs the pane against a host that only moves the page when
the dialog is answered: alert, OK, the card stays while the reply is in flight,
then the confirm raises its own card and resolves with the button's value.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): map a tap through the page scale the frame was painted at (OTA phase C, C6.7)

Mobile view emulates a phone viewport, and a page with no `<meta name="viewport">`
lays out at Chromium's 980 px default and is scaled into it. The frame metadata
says so: `deviceWidth` stays the emulated width and `pageScaleFactor` carries the
ratio. The browser's input commands take page CSS pixels, so a tap sent in the
frame's own device space landed at that fraction of the aim — 41% on the phone,
which is how the C6.6 proof found it.

The frame geometry now carries the scale the frame was painted at, and both the
tap map and the finger-sized click radius go through it. Measured on Chromium
1217, 2026-09-20: `scrollOffsetX/Y` must not be added — the frame is the visual
viewport and the commands take viewport-relative coordinates, so a click sent at
`device / scale + scrollOffset` landed a screenful past its target while
`device / scale` hit it. Web view mode reports a scale of one and is unchanged,
and so is a frame whose metadata carries no usable scale.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): sweep the frame budget on the encoder the product runs (OTA phase C, C6.7)

The C6.5 sweep encoded through `canvas.toDataURL` while the pane's frames come
from `Page.startScreencast`, so the certification and the product were measuring
two encoders. The sweep now drives the screencast, over the same 143 viewports
and the same noise, through the same real shell.

The two encoders agree to within a thousandth of a byte per pixel, and the
screencast is the cheaper of them: across the 111 viewports the budget fits it
measured 0.543986 to 0.552964 bytes per pixel, against 0.54470 to 0.55351 from
`toDataURL`. `WORST_CASE_JPEG_BYTES_PER_PIXEL` stays 0.56, now stated as the
screencast maximum plus 1.3%.

So the encoder is not what made the C6.6 device proof drop 1 frame in 41 at
402x593 with the budget on. That frame needs about 0.5649 bytes per pixel, above
everything either sweep has seen, and nothing here reproduces it. The docstring
records that rather than folding it into the constant.

The frame is emulated at one device pixel per CSS pixel and the page carries a
viewport meta: headless Chromium composites at the DIP surface size whatever
`deviceScaleFactor` says, so without both the canvas is scaled into the frame,
the noise averages away and the sweep reads about 0.12 bytes per pixel.

Also records what C6 ruling 1 costs, in the pane's docstring: "never dark" holds
only for a page that produces some frame that fits. With every frame over the
cap the pane sits on its busy spinner over an unpainted viewport, which is the
budget's reason for existing. No code change for that.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): wait on the double buffer's flip, not on a digest (OTA phase C, C6.7)

The render check waited for some painted layer to carry a digest other than the
previous frame's. `applyFrame` writes the next frame's URI onto the hidden layer
as soon as the frame lands and only flips the opacity once the decode resolves,
so that predicate is true before the frame is on screen.

Measured with a MutationObserver over the style writes, 2026-09-20: the URI
landed 80.7 ms after the emit and the flip at 85.7 ms, a 5 ms window in which
the wait returns and the visible layer is still the previous frame. The check
usually outran it by the round-trip it spends reading the layers back, which is
why it failed once in CI (#21790, d1d9a59288) with the second frame's digest
equal to the first's and no console errors.

Both cases that used that predicate now wait on the flip itself: the visible
layer must become the other one, which is one opacity write at
`settleBrowserFrameLayer` and the behaviour under test rather than a proxy for
it. Asserting the exact layer rather than any change keeps a pane with nothing
visible from reading as a flip. The digest assertions stay, as the content check
the wait no longer stands in for.

With `settleBrowserFrameLayer` stubbed to skip the flip the case fails on the
wait, so it is still an oracle for the behaviour and not only for the timing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(browser): one dialog reply shape, and no dialog outliving its stream (OTA phase C, C6.7 round 1)

Round 1 findings 2 and 4.

The reply body forked: `{}` when a pane was streaming the page and the
agent-browser payload when one was not, so `orca browser dialog accept --json`
printed a different body depending on whether anyone happened to be watching.
Both paths now answer `{ accepted: boolean }`. The bridge's own body is not
forwarded, because it is whichever JSON that agent-browser version prints, which
is the thing a caller cannot rely on. Pinned by asserting the two paths answer
the same value.

A replacement stream can meet an open dialog: the last subscriber leaving stops
the page's session, and a later subscribe builds a new one, which is what
backgrounding and foregrounding the phone does. Measured on Chromium 1217,
2026-09-20, with the dialog up: detaching the session that reported it does not
complete, `Page.enable` on a fresh session does not complete, and
`Page.handleJavaScriptDialog` on it answers `No dialog is showing`. So carrying
`dialogOpen` across would report a success the page never got, and the
replacement's own start would hang before it could try.

The stream therefore dismisses a dialog still open when it stops, before
`Page.stopScreencast`, so no session is ever replaced with one outstanding.
Dismissing is the conservative answer for every dialog type including
beforeunload, and the automation path has taken it since `cdp-debugger-events.ts`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say when a dialog answer did not land, and drop the sweep's inert emulation claim (OTA phase C, C6.7 round 1)

Round 1 findings 1, 3 and 5.

The sweep's `deviceScaleFactor` is inert and the docstring claimed otherwise.
Headless Chromium composites at the DIP surface size whatever the factor says,
so the sweep returns the same 0.543986 / 0.552964 to six decimals at 1 and at 3;
`Emulation.setDeviceMetricsOverride` is there to size the surface and nothing
else. What actually guards the measurement is the document's viewport meta,
without which the page lays out at 980 px, the canvas is scaled into the frame
and the noise averages away. That is no longer left to a comment: a case now
measures the same frame with the meta removed and reads under 0.3 bytes per
pixel, which is what proves the sweep's 0.5 floor is the thing that would catch
it.

The card cleared only on `dialogClosed`, but the request still suppressed its
error and timed out at 5 s, so a refused or timed-out answer left the modal up
with nothing said and a button that looked dead. A failed answer now marks the
open card, which keeps its buttons live for the retry. The updater is what stops
a late failure reopening a card the page has since closed.

The "never dark" note moves onto the component it describes, from the detached
block it was sitting in between the imports and the props type.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): scale the wheel, answer a dialog once, and capture the sweep after the paint (OTA phase C, C6.7 bots)

Three bot findings on 6341321f44.

The wheel converted screen deltas with the frame fit and the zoom and left the
page scale out, so a flick on a page with no viewport meta delivered about 41%
of the scroll asked for — the same omission the tap had, one call site over.
Both call sites now go through one scale, named for the three factors every
screen-space quantity this pane sends has to carry.

`dialogOpen` stayed true until `Page.javascriptDialogClosed`, so two answers in
flight both sent `Page.handleJavaScriptDialog`. Chromium takes one per dialog:
the second is refused, or settles the page's next dialog unseen if it has
already been raised, and the rejection came back out of the RPC. One settlement
promise per dialog now, handed to duplicate callers, cleared when the dialog
closes or when the command fails — and only by its own dialog's generation, so
a late failure cannot disarm the next dialog's answer. The stop path reuses a
settlement already on its way rather than adding a second command. On the pane
the card's buttons go dead while an answer is in flight, keyed on the token of
the answer that armed them, so a reply landing after the page moved on writes to
neither card.

The sweep painted its noise before `Page.startScreencast` and accepted whatever
frame the deadline left it, so a capture taken before the paint committed could
have been measured as the cost of the canvas. The noise is now painted after the
screencast is running, through the same CDP session and behind two animation
frames, and only frames that arrive after that commit are used; none at all is
an error rather than a fallback. Re-measured across all 111 budgeted viewports:
0.543986 / 0.552964, unchanged to six decimals, so the constant stays where it
is and the capture is guaranteed rather than lucky.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a late dialog failure marks its own card, and quote the real case title (OTA phase C, C6.7 round 2)

Round 2 delta findings a and b.

The mis-stamp itself is already closed by the per-dialog token the bot fold
added: the failure updater skips unless the open card still carries the token of
the answer that armed it, and a dialog raised after that carries none. What was
missing is the sequence that proves it. The case answers the alert, lets the
page settle it and raise the confirm without the pane hearing the reply, then
times that reply out: the confirm keeps its own card and its live buttons. With
the token check reduced to the null guard it was reviewed with, the confirm is
stamped with the alert's failure.

The sweep docstring quoted a case title that does not exist, which is a pointer
that reads as a citation and resolves to nothing. It now quotes the real one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the browser pane the one dialog-state type (OTA phase C, C6.7 bots)

pullfrog: the pane declared its own `BrowserDialogState` without `error` or
`pending`, and only structural typing let the hooks' wider setter flow into it,
so the fields the card renders were undeclared on the pane's own state. The pane
now imports the exported type and the local declaration is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 11:33:12 -04:00
..