mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* 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 on6341321f44. 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