From 1400a6e00ad8429faf4c8d0822d6dcc46e07c2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 30 Sep 2026 12:07:14 +0100 Subject: [PATCH 1/2] fix(preview): bound automation and recover paused host rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Co-authored by GPT-6 in Codex via T3 Code --- .agents/skills/test-t3-app/SKILL.md | 2 + BRANCH_DETAILS.md | 153 ++ .../src/app/DesktopAppIdentity.test.ts | 25 + apps/desktop/src/app/DesktopAppIdentity.ts | 4 + apps/desktop/src/app/DesktopClerk.test.ts | 1 + apps/desktop/src/app/DesktopConfig.ts | 1 + .../src/app/DesktopEnvironment.test.ts | 2 + apps/desktop/src/app/DesktopEnvironment.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/preview.ts | 51 +- apps/desktop/src/ipc/methods/window.test.ts | 22 + apps/desktop/src/ipc/methods/window.ts | 24 + apps/desktop/src/preload.ts | 32 +- apps/desktop/src/preview/Manager.test.ts | 1416 ++++++++++++++++- apps/desktop/src/preview/Manager.ts | 1124 +++++++++++-- apps/server/src/mcp/McpHttpServer.test.ts | 137 +- apps/server/src/mcp/McpHttpServer.ts | 83 +- apps/server/src/mcp/McpInvocationContext.ts | 2 + .../src/mcp/PreviewAutomationBroker.test.ts | 639 +++++++- .../server/src/mcp/PreviewAutomationBroker.ts | 439 +++-- .../src/mcp/toolkits/preview/handlers.test.ts | 131 +- .../src/mcp/toolkits/preview/handlers.ts | 49 +- .../src/mcp/toolkits/preview/tools.test.ts | 40 +- apps/server/src/mcp/toolkits/preview/tools.ts | 82 +- apps/server/src/preview/Manager.test.ts | 23 + apps/server/src/preview/Manager.ts | 8 +- apps/web/src/browser/HostedBrowserWebview.tsx | 68 +- apps/web/src/browser/browserRecording.test.ts | 534 ++++++- apps/web/src/browser/browserRecording.ts | 412 ++++- .../browser/browserRecordingUpload.test.ts | 111 ++ .../web/src/browser/browserRecordingUpload.ts | 25 +- .../src/browser/browserSurfaceStore.test.ts | 112 +- apps/web/src/browser/browserSurfaceStore.ts | 82 + .../src/browser/browserTargetResolver.test.ts | 34 + apps/web/src/browser/browserTargetResolver.ts | 3 +- .../browser/hostedBrowserWebviewStyle.test.ts | 85 + .../src/browser/hostedBrowserWebviewStyle.ts | 47 +- .../src/browser/recordingCompositor.test.ts | 45 + apps/web/src/browser/recordingCompositor.ts | 6 + apps/web/src/components/RightPanelTabs.tsx | 12 +- .../auth/PairingRouteSurface.logic.test.ts | 57 + .../auth/PairingRouteSurface.logic.ts | 27 + .../components/auth/PairingRouteSurface.tsx | 71 +- .../preview/PreviewAutomationHosts.tsx | 471 ++++-- .../preview/closePreviewAutomationTab.test.ts | 78 + .../preview/closePreviewAutomationTab.ts | 34 + .../preview/previewAutomationClientId.test.ts | 88 + .../preview/previewAutomationClientId.ts | 81 +- .../preview/previewAutomationErrors.ts | 69 + .../previewAutomationHostBudget.test.ts | 136 -- .../preview/previewAutomationHostBudget.ts | 42 - .../previewAutomationOpenReadiness.test.ts | 159 +- .../preview/previewAutomationOpenReadiness.ts | 40 + .../previewAutomationOverlayReadiness.test.ts | 168 ++ .../previewAutomationOverlayReadiness.ts | 51 + .../previewAutomationPresentation.test.ts | 647 ++++++++ .../preview/previewAutomationPresentation.ts | 296 ++++ ...wAutomationPresentationSuppression.test.ts | 56 + ...reviewAutomationPresentationSuppression.ts | 42 + .../previewAutomationRequestConsumer.test.ts | 242 +++ .../previewAutomationRequestConsumer.ts | 116 +- .../preview/previewAutomationTarget.test.ts | 27 + .../preview/previewAutomationTarget.ts | 13 + .../preview/previewHostRendering.test.ts | 69 + .../preview/previewHostRendering.ts | 45 + .../previewNavigationReadiness.test.ts | 55 +- .../preview/previewNavigationReadiness.ts | 4 +- .../preview/previewViewportReadiness.test.ts | 85 +- .../preview/previewViewportReadiness.ts | 41 + apps/web/vite.config.ts | 48 +- .../src/rpc/multiplexProtocol.ts | 42 + .../client-runtime/src/rpc/session.test.ts | 67 + packages/client-runtime/src/rpc/session.ts | 3 +- packages/contracts/src/ipc.test.ts | 68 +- packages/contracts/src/ipc.ts | 64 +- packages/contracts/src/preview.test.ts | 51 +- packages/contracts/src/preview.ts | 10 + packages/contracts/src/previewAutomation.ts | 170 +- packages/shared/src/devProxy.ts | 54 + scripts/dev-runner.test.ts | 12 +- scripts/dev-runner.ts | 31 +- 82 files changed, 9110 insertions(+), 892 deletions(-) create mode 100644 BRANCH_DETAILS.md create mode 100644 apps/web/src/browser/browserRecordingUpload.test.ts create mode 100644 apps/web/src/components/auth/PairingRouteSurface.logic.test.ts create mode 100644 apps/web/src/components/auth/PairingRouteSurface.logic.ts create mode 100644 apps/web/src/components/preview/closePreviewAutomationTab.test.ts create mode 100644 apps/web/src/components/preview/closePreviewAutomationTab.ts create mode 100644 apps/web/src/components/preview/previewAutomationClientId.test.ts delete mode 100644 apps/web/src/components/preview/previewAutomationHostBudget.test.ts delete mode 100644 apps/web/src/components/preview/previewAutomationHostBudget.ts create mode 100644 apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts create mode 100644 apps/web/src/components/preview/previewAutomationOverlayReadiness.ts create mode 100644 apps/web/src/components/preview/previewAutomationPresentation.test.ts create mode 100644 apps/web/src/components/preview/previewAutomationPresentation.ts create mode 100644 apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts create mode 100644 apps/web/src/components/preview/previewAutomationPresentationSuppression.ts create mode 100644 apps/web/src/components/preview/previewHostRendering.test.ts create mode 100644 apps/web/src/components/preview/previewHostRendering.ts create mode 100644 packages/client-runtime/src/rpc/multiplexProtocol.ts diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15a..a77fe2b97c4d 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -39,3 +39,5 @@ using retained terminal sessions or captured PIDs. When sharing is requested, start with `vp run dev --share` and give the user a fresh complete pairing URL that you have not consumed. Keep other credentials out of screenshots, commits, and replies. + +Retain the `tabId` of each tab created for this test. At final teardown, close only those owned tabs with `preview_close`, passing each `tabId` explicitly, before stopping the test environment. Confirm each close returns `tabId: null`. diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 000000000000..7f3b94aac849 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,153 @@ +# Preview Automation Reliability + +Product-native preview automation is bounded and recoverable across the web host, MCP server, and Electron CDP controller. The branch-specific layer covers operation deadlines, control-session recovery, profile-preserving automation tab creation, one-shot automation snapshot presentation, and degraded semantic snapshots. + +## Upstream ownership boundary + +Upstream commit `f4c394323` (`Add background preview capture and picture-in-picture support`) owns: + +- keeping inactive preview guests mounted and CSS-visible outside the human-visible panel; +- the `webContents.capturePage()` frame loop used by picture-in-picture; +- inline preview mini-player presentation, fitted-source layout, corner radii, and crash recovery; and +- background-only preview creation through `open: false`. + +Upstream commit `32af2f002` (`fix(preview): stabilize PiP viewport identity`) owns epoch-scoped runtime guest identity and keeps PiP, recording, renderer surfaces, and Electron tabs aligned on that identity. + +Upstream commit `fe281c540` (`fix(desktop): throttle hidden preview rendering`) owns disabling `BrowserWindow` background throttling while the shared recording/PiP frame-capture lifecycle has active consumers, then restoring throttling after its final consumer stops. Upstream commit `3a02c9cf1` (`feat(desktop): mute a browser tab`) owns tab mute and audibility state across `packages/contracts/src/ipc.ts`, `apps/desktop/src/preload.ts`, and `apps/desktop/src/preview/Manager.ts`. + +Upstream commit `ef7014d851` (`fix(preview): restore recording and macOS rendering after Electron 43`) owns the renderer `getDisplayMedia()` and `MediaRecorder` recording path, the main-process display-media grant that binds each request to its exact guest, serialized grant handoff for concurrent starts, and keeping inactive macOS guests paintable. The branch composes its recording deadlines, cleanup bounds, retryable finalization, and artifact idempotency with that path; it does not retain Electron's removed `getMediaSourceId()` capture route. Recording source warm-up uses the same serialized native-capture queue as snapshots, annotations, and picture-in-picture frames, so a warm-up that outlives its bounded caller cannot overlap later native captures and those callers fail fast until the exact queue tail settles. Upstream commit `134d51096e` (`feat(desktop): browser profiles for the preview browser`) owns preview profile persistence, profile-specific Electron sessions, and selecting a profile when a tab is created. The branch carries profile identity through its automation lifecycle without adding another profile model. + +Upstream commit `9e37f0c291` (`fix(preview): transfer recordings to the agent environment`) owns sending the encoded recording through the environment's attachment-upload path. `apps/web/src/browser/browserRecording.ts` extends the branch's retryable finalization through that transfer, while `apps/web/src/browser/browserRecordingUpload.ts` uses the host deadline that already reserves response grace. The branch keeps the existing attachment transport and desktop artifact model. + +Upstream commit `061543e9e5` (`fix(mcp): keep preview snapshots usable by the agent and let it save them`) owns bounded snapshot text, the `includeImage` and `save` options, and object-wrapped `preview_evaluate` results. `apps/server/src/mcp/McpHttpServer.ts` preserves those options when screenshots are nullable: unavailable capture returns semantic metadata with `screenshot: null`, no image, and no saved path even with `save: true`. `apps/server/src/mcp/toolkits/preview/handlers.ts` keeps output selection and saving on the MCP side and carries the caller's evaluation timeout to the broker. + +Upstream commit `39449e53e3` (`feat(desktop): import browser cookies into a profile`) owns browser discovery and importing cookies into the Electron session selected by a preview profile. `apps/web/src/components/preview/PreviewAutomationHosts.tsx` uses `previewAutomationNewTabDefaults` from `previewAutomationOpenReadiness.ts` to send the configured `profileId` with every automation-created tab. The selected profile therefore reaches the existing renderer and desktop session-partition path, so a newly created automation replacement tab sees cookies imported into that profile. The branch does not add a second import or session-partition model. + +Upstream commit `949feb61` (`feat(web): configurable browser defaults`) owns the persisted viewport, zoom, appearance, and automatic floating-preview defaults, plus applying those defaults when a browser tab is created. Upstream commit `cd096b9ad` (`feat(server): users can withhold browser access from agents`) owns whether preview tools and instructions are exposed to a provider session. The branch composes with those settings; it does not maintain another browser-default or access-control layer. + +Upstream commit `39abb9d1d6` (`fix(connect): refresh authorization without disconnecting`) owns prepare-before-release connection replacement in `packages/client-runtime/src/connection/supervisor.ts` before a DPoP credential expires. `apps/server/src/mcp/PreviewAutomationBroker.ts` keeps explicit preview-host assignment keyed by stable `hostId`, independently of the replaced renderer connection id, and retains the provider session's selected tab when that host reconnects. The branch does not force a disconnect to refresh routing state. + +Upstream commits `7235701de0` (`fix(server): release preview hosts after unanswered requests`) and `5378f87f99` (`fix(preview): recover host registration after request timeouts`) own eviction of unanswered preview hosts and normal completion of their RPC streams so responsive renderers can re-register. The branch preserves an explicit stable-host assignment and its selected tab across that eviction. Calls remain unavailable while that host is absent, even when another host is healthy; re-registration restores the same tab without replaying the timed-out action or accepting a late response from the retired connection. The branch also separates the caller's deadline from eviction: `PreviewAutomationBroker.awaitResponse` fails the caller with `PreviewAutomationTimeoutError` at the request deadline but keeps the connection registered for `PREVIEW_AUTOMATION_EVICTION_GRACE_MS` (3 seconds), which comfortably covers the renderer's 250 ms response grace plus transit and a busy renderer. Any answer inside that window, including the renderer's typed timeout, keeps the host; the answer itself is discarded, never replayed, and never becomes the session's current tab. Only silence through the grace completes the stream and evicts the host. + +Upstream commit `eb77683e55` (`fix(server): prevent duplicate desktop clients after restart`) owns atomically replacing stale `desktop-bootstrap` auth sessions. That replacement changes the authenticated renderer connection, not the physical preview-host identity. `apps/server/src/mcp/PreviewAutomationBroker.ts` already rebinds an explicit assignment when the same stable `hostId` reconnects and discards the superseded broker queue, so the branch does not add another auth-session lifecycle or special replacement path. + +Upstream commit `a87f691bd` (`fix(preview): open local environment ports on localhost`) owns normalizing local IPv4 and IPv6 environment-port targets to `localhost` in `apps/web/src/browser/browserTargetResolver.ts` for Chromium dual-stack reachability. Upstream commit `3b5d476eb` (`fix(desktop): stop overwriting a custom dock icon on launch`) owns preserving packaged macOS dock icons in `apps/desktop/src/app/DesktopAppIdentity.ts` by assigning the runtime PNG icon only to unpackaged apps. The branch composes its explicit development listener/proxy policy and isolated desktop user-data path with those behaviors; it does not override either upstream policy. + +Upstream commit `12e8997e58` (`fix(web): keep agent browser preview visible`) owns automatically showing the floating preview after agent browser operations, except when an explicit background open suppresses presentation for that runtime tab, and carries browser-surface `zIndex` into the retained guest renderer so browser content stays above overlapping sheets. The branch composes that policy with its request deadlines, epoch-scoped runtime identity, stable narrow render-state subscriptions, and one-shot background snapshot presentation. It revalidates the runtime guest and remaining budget before the upstream auto-show mutation, and preserves explicit `open: false` or `show: false` as authoritative. + +Upstream commit `19c97ea56d` (`fix(web): unlock composer when preview capture fails`) owns exact annotation-pick session settlement, atomic replacement of overlapping picks, and a five-second bound around annotation screenshot capture. The branch keeps its serialized native `capturePage()` queue around that bound so concurrent native captures cannot overlap. If a caller times out while Electron's capture remains pending, later native captures for that guest fail fast until the exact pending capture settles; semantic automation and annotation submission remain available without repeatedly spending their deadlines behind the same native call. Upstream commit `db8d60f486` (`fix(web): render transparent previews on white`) owns the white retained-webview background. Upstream commit `098bf53297` (`fix(web): preserve explicit preview navigation URLs`) owns preserving explicit localhost URLs and remapping only discovered environment-port targets; remote-environment automation therefore uses `environment-port` when it wants environment-relative routing. + +Upstream commit `6319a97148` (`fix(desktop): preview CDP sessions no longer hard-crash the app`) owns pinning Electron's `Debugger` wrapper for the full CDP session and detaching through that pinned wrapper after the guest is destroyed. `apps/desktop/src/preview/Manager.ts` composes that pinned wrapper with the branch's deadline-triggered reset, exact-session teardown, and stale-session retry rules; the branch does not maintain a separate debugger-lifetime safeguard. Upstream commit `42bdea1c9c` (`fix(web): stabilize right panel transitions`) owns suppressing right-panel width transitions during resize and maximize changes. The branch's visibility and viewport readiness logic observes the resulting committed geometry without adding another transition policy. + +Upstream commit `de1b798c6e` (`fix(preview): bound automation waits and screenshot captures`) owns transient native screenshot retries and an initial bound on host readiness. The branch retains its end-to-end remaining-budget propagation and semantic-only snapshot fallback. Its request-consumer and overlay-readiness helpers own deadline enforcement without a separate host-budget policy. Manual screenshot retries use the branch's serialized native-capture queue and stop immediately when that exact guest is retired, replaced, or blocked by an unsettled timed-out capture. + +Upstream commit `8bbe2bf660` (`feat(web): float device streams over chat`) owns the floating player source union for browser tabs and device streams. The branch uses the browser-specific selector and source constructor in `HostedBrowserWebview.tsx`, `PreviewAutomationHosts.tsx`, and `previewAutomationPresentation.ts`. Its visibility checks, explicit background suppression, and selection recovery therefore continue to target browser tabs without treating an unrelated device stream as that browser's presentation. + +Upstream commit `e816064945` (`fix(desktop): keep preview keystrokes out of the composer`) owns keyboard target resolution and native Windows guest input routing. The branch passes its control-epoch check through the bounded control-session callback to that keyboard path, alongside the operation deadline and exact-session reset. Keyboard focus timeout errors carry the branch-required operation metadata, and a timed-out operation still releases control so later input or evaluation can recover. + +Upstream commit `6c69534a58` (`fix(preview): render website favicons for browser tool activity`) owns website tool icons, their response schemas, and capturing the operation's tab before a concurrent request changes the current target. The branch preserves that presentation metadata while carrying caller timeouts for input and evaluation operations. Optional favicon status lookup consumes only the original operation's remaining deadline, capped at 500 milliseconds, and is skipped when no time remains. An unavailable or timed-out metadata lookup preserves the completed operation result. The broker and handler share the same default operation timeout so omitted caller timeouts have the same bound. + +Upstream commit `55c24273a0` (`fix(desktop): align preview recording cursors and show input feedback`) owns recording input decorations and the renderer compositor. The branch includes compositor initialization in the recording startup deadline and aborts pending initialization at expiry, releasing its input listener, frame callback, and canvas capture tracks before playback settles. Late initialization results cannot revive that compositor or disturb a newer recording. Desktop recording-controller notifications remain synchronous with state changes while renderer state listeners remain outside bounded finalization. + +Upstream commit `894d33419d` (`fix(preview): use the visible browser for new agent sessions`) owns preferring the visible target tab's host when a provider session has no assignment. The branch retains explicit stable-host selection and reconnect recovery ahead of that implicit routing policy. Upstream commit `4f27a84631` (`fix(mcp): preview snapshots fit in the agent's tool output again`) owns compact saved-image responses and bounded snapshot text; the branch preserves semantic-only output when no screenshot exists. + +Upstream's `frameCaptureSessionsRef` in `apps/desktop/src/preview/Manager.ts` continues to own sustained recording/PiP capture. Bounded automation operations temporarily disable host-window background throttling and share that registry's synchronization, so concurrent actions and recording/PiP shutdown cannot suspend each other. Native input and screenshots wait for the host to commit a frame after waking; screenshot preparation stays inside the screenshot budget so semantic snapshots and evaluation remain available when rendering cannot resume. The last operation restores throttling unless a sustained capture still needs it. This does not show or focus a window, focus a guest, or start another capture loop. The renderer's one-shot snapshot staging remains necessary to expose an inactive guest's pixels without changing human selection. + +Expected behavior: + +- MCP native caller metadata separates host and current-tab selection within an inherited credential. It is a bounded namespace, never a T3 thread selector: every tab stays under the credential-owned main conversation, with creator and host attribution. New contexts open their own tabs; human selection and sibling defaults cannot retarget them. Explicit `tabId` remains the deliberate sharing mechanism. Providers that omit native caller metadata retain one shared default context per credential. This path has no Magi or projected-subagent dependency. +- The WebSocket client delivers independent RPC requests concurrently while retaining per-request ordering and ACK backpressure. A paused subscription cannot block preview requests, responses, stream completion, or reconnect registration behind its receive queue. Connection failure interrupts pending delivery; timed-out browser actions are never replayed. +- Every Electron automation operation has a bounded control-session lifetime. The desktop manager reserves response grace inside the requested timeout without making the execution budget shrink when the caller increases a short timeout, always finalizes controller and action-timeline state, and detaches a timed-out debugger session while still holding an acquired control permit when a CDP command may be pending. Finalization commits the controller reset before responding but delivers that state notification separately under manager scope, so a stalled listener cannot withhold the bounded response. Timeout errors retain the failed operation or capture stage, so caller-visible diagnostics and action-timeline entries identify the work that stalled. Session removal and debugger teardown are atomic with respect to new session acquisition and bound to the exact acquired session, so late interruption or snapshot cleanup cannot detach a healthy replacement. Operations already queued on the retired semaphore detect that stale session and retry against its replacement. A request that times out while queued behind another action does not detach that action's shared debugger session. +- Click, type, press, scroll, evaluate, and wait operations carry their caller-supplied timeout through the MCP broker, clamp it to the remaining renderer host budget after readiness, and enter the desktop control-session boundary with that remaining value. Color-scheme changes and recording startup likewise receive the remaining deadline after overlay readiness; bounded and unbounded appearance mutations serialize per tab so live CDP state and persisted tab state cannot diverge, a timed-out color-scheme command does not persist a late preference, and timed-out recording startup observes cleanup only inside its reserved response grace while allowing an already-started cleanup to finish under manager scope. Timed appearance persistence re-reads current tab state after CDP settles and retries against the current guest if replacement rejects the stale guest's command. Recording stop bounds desktop capture shutdown, MediaRecorder settlement, blob conversion, and artifact persistence to the remaining deadline, and the waits it hands to desktop IPC are additionally clamped to `DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS` because the MCP stop budget (`PREVIEW_RECORDING_STOP_TIMEOUT_MS`, 120 seconds) exceeds what those IPC payloads accept; desktop recording-stop cleanup also runs under manager scope so an uninterruptible capture finalizer cannot withhold the timeout response. Renderer and desktop-originated deadline failures retain captured chunks and the recording slot so finalization can be retried instead of silently losing the artifact. An in-flight artifact-save promise is shared with that retry, and every retry reuses a validated desktop artifact idempotency key, preventing duplicate files whether the renderer deadline wins before desktop IPC settles or the desktop reports a timeout after writing. Operations without a caller timeout use the remaining bounded request budget rather than restarting the desktop default after renderer readiness work. +- `stopBrowserRecordingForUpload` in `apps/web/src/browser/browserRecording.ts` retains the saved blob, artifact, and recording slot until a transfer result reaches a caller within its deadline. A failed upload can be retried without saving the file again. A retry joins an unsettled upload; if that transfer rejects because its original request deadline expired, a caller with remaining budget starts or joins one fresh transfer using its own deadline. If an upload succeeds after its caller times out, the next stop request receives that same attachment result. Native media tracks and browser-surface activity are released before upload, so retaining retry state does not keep capture running. A local Stop, including tab disposal through `desktopTabLifetime.ts`, can release retained transfer state and return the saved desktop artifact. If the server accepts an upload whose response is lost, a retry can leave an unclaimed pending copy when best-effort removal fails or races the server commit. Pending copies become eligible for cleanup after 24 hours; startup and later upload issuance trigger sweeps, so that age is not a guaranteed deletion deadline. `PreviewAutomationHosts.tsx` supplies the remaining stop budget and the shared operation deadline; `browserRecordingUpload.ts` uses that deadline without reserving response grace again. +- Snapshot collection captures every screenshot with `webContents.capturePage` on the desktop's serialized native-capture queue: `stayHidden: true` for a background guest, normal visible-page capture for the foreground. CDP `Page.captureScreenshot` is not used. For an unselected tab, the renderer stages the still-mounted guest at effectively transparent opacity for two compositor frames, but only for the snapshot itself, and the desktop manager never focuses the guest or calls `Page.bringToFront`; either activation call can make Electron promote the native guest over the host window and keep the T3 interface covered after staging ends. The screenshot wait is clamped to the remaining control-session deadline with settlement grace reserved, so a tight caller deadline still returns semantic data instead of being preempted by the outer session timeout. Every returned PNG, including resized output, is validated and bounded. When no PNG exists the snapshot returns `screenshot: null` together with `screenshotUnavailable: { stage, message, hostRendering? }` from `packages/contracts/src/previewAutomation.ts`: `capture` when Electron rejected the native capture (carrying Electron's own message, for example `UnknownVizError`), `encode` when the native image was empty or resizing produced an invalid PNG, `interrupted` when the bounded wait expired while the native capture was still pending, `pending-capture` when the serialized queue refused the capture because an earlier interrupted capture had not settled, and `budget` when the operation deadline left no time to try. `captureAutomationPage` fails with `PreviewAutomationScreenshotError`, whose message carries the bounded native cause so its span exit in `desktop.trace.ndjson` says what Electron reported; `PreviewOperationError` messages stay free of cause text because their causes can carry page-derived CDP detail. If Electron's native capture outlives its timed-out caller, later captures fail fast with `pending-capture` while the same queue tail is pending and recover automatically once it settles. Re-registering a live guest restores capture with a new queue generation while preserving the pending native tail and its interruption gate; retired-generation captures remain invalid. The semantic page state, interactive elements, accessibility tree, diagnostics, and action timeline still return instead of failing the complete snapshot. +- The desktop manager keeps the display awake while one-shot automation is live. `apps/desktop/src/preview/Manager.ts` holds at most one Electron `powerSaveBlocker` of type `prevent-display-sleep`: it starts on the first automation request (status, any control-session action, recording start or stop), which is how every automation flow begins, and every later request refreshes an activity timestamp. A tab counts as an automation tab only after an automation request targets it, so a tab a human opened and drove never holds the block; the desktop does not receive the server's `automationOrigin`, and this definition needs no extra plumbing. The block is released after `AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS` (five minutes) without automation traffic, when the last automation tab closes, or on manager shutdown, whichever comes first; start and release are idempotent and logged at debug level with their reason. `preview_status` reports `displaySleepBlocked` next to `hostRendering`. This is separate from upstream's recording/PiP frame-capture lifecycle and its background-throttling policy, which it does not touch. +- `apps/web/src/components/preview/previewHostRendering.ts` reports a hidden host window as paused immediately; otherwise it probes whether the host window is painting by racing one `requestAnimationFrame` against a 500 ms bound; there is no continuous timer, and the losing side is cancelled. `preview_status` reports the result as `hostRendering: "active" | "paused"` only when the request budget leaves at least twice the probe, so the 500 ms favicon status lookup behind every action never pays for it, and `preview_snapshot` attaches it to `screenshotUnavailable` after the presentation lease is released. The MCP snapshot response states the stage, the host's message, and the liveness result in its text, so an agent can tell that the host renderer is not painting (display off, hidden window) rather than that capture is broken. +- Desktop preview guests following the system color scheme create their CDP debugger session lazily, with initialization included in the automation operation deadline. This prevents an offscreen Chromium guest from leaving `Runtime.enable` pending while holding the synchronized session lock, which previously made every later evaluation or snapshot against that tab time out even after it became presentable. `apps/web/src/browser/desktopTabLifetime.ts` passes the upstream browser appearance default through `DesktopPreviewCreateTabInputSchema` in `packages/contracts/src/ipc.ts`; `apps/desktop/src/preview/Manager.ts` normalizes that value. Its `reconcileRegisteredGuestState` reasserts current zoom and native mute state after attachment without opening a CDP session; a guest disappearing during the best-effort mute reassert does not fail registration. Only a non-system color-scheme override starts the separately bounded `restoreControlSession` recovery path after webview registration, while tabs following the system scheme stay detached until the next automation operation. Detached DevTools also restores a non-system override through that bounded path. +- Building on upstream's retained hidden guest, automation background snapshot presentation is reference-counted independently from the normal surface lease and composes with upstream's fitted-source content and corner-radius presentation. `PreviewAutomationHosts.tsx` passes the epoch-scoped runtime tab id into `previewAutomationPresentation.ts`; every surface lookup, staging marker, readiness check, diagnostic read, lease, and desktop capture targets that exact runtime guest, while selection and errors retain the stable server tab id. The presentation helper API has no state-derived or server-id compatibility fallback. Only a one-shot automation snapshot acquires this lease; upstream recording and picture-in-picture continue to use their shared frame-capture lifecycle, while navigation, color-scheme changes, evaluation, waits, and input operations do not acquire an automation presentation lease. Staging always restores the offscreen position and does not change the human-selected surface. The caller's wait, including compositor-frame staging and desktop IPC, is bounded by the operation's remaining response budget and reports a typed timeout if it stalls; once desktop capture starts, the lease itself remains held until that capture settles. If the server epoch replaces the runtime guest while staging is pending, the snapshot fails immediately with `PreviewAutomationTargetUnavailableError` instead of waiting on the stale staging marker. If the user foregrounds the target in either surface while staging is pending, that visible presentation satisfies readiness. A never-presented tab does not depend on another browser surface having supplied a panel rectangle: automation staging falls back to a deterministic rectangle fitted inside the renderer viewport. +- A background snapshot that times out before desktop capture begins releases its presentation lease even when Chromium has paused compositor-frame callbacks. Once desktop capture starts, a timed-out snapshot retains its presentation lease until that capture settles, so response timeouts cannot tear down compositor staging beneath an in-flight capture. The desktop snapshot receives the operation's remaining timeout and bounds its control session accordingly. +- The shared preview contract treats snapshot screenshots as nullable. MCP snapshot responses preserve structured semantic content and explicitly report `screenshot: null` when capture is unavailable. `includeImage: false` suppresses image content without changing page metadata; `save: true` writes a file and returns `screenshotPath` only when a PNG exists. Tool descriptions promise a PNG only when capture is available. In `packages/contracts/src/ipc.ts`, the branch's desktop snapshot schema and upstream's create-tab defaults coexist: snapshot calls default an omitted `background` flag to `false`, while tab creation carries viewport, zoom, and color-scheme defaults through the preload and desktop manager. +- The renderer automation consumer reserves response grace before the broker deadline and converts a stalled host operation into a typed `PreviewAutomationTimeoutError` instead of leaving the broker to surface a generic execution failure. Short caller-supplied timeouts retain their full execution budget, and the transition into grace reservation remains monotonic as requested timeouts increase. Requests that ask to open the inline preview use the request's remaining bounded visibility budget rather than a fixed two-second ceiling. Best-effort presentation settling uses a non-throwing remaining-budget read and clamps its 500-millisecond ceiling to that budget. Overlay status calls are themselves bounded by the remaining deadline and revalidate runtime guest identity after awaiting the desktop bridge, while overlay, navigation, and visibility polling clamp each sleep to the remaining deadline. Stable-presentation dwell also contracts to fit short deadlines instead of requiring an impossible fixed 100 milliseconds. Reused empty or failed tabs acknowledge without waiting for a browser surface those states intentionally hide. Visibility timeouts separately report the inline preview's selected tab, the right panel's active surface and open state, the active presentation kind, whether the requested browser surface was registered, and whether it had a presentation rectangle. +- `PreviewAutomationHosts.tsx` resolves `browserDefaults.ts` once at the start of each automation-open request, before taking the session snapshot that pins a reused runtime guest. That single settings snapshot supplies the new-tab viewport, selected profile, and automatic floating-preview preference, so creation and presentation cannot observe different settings during one request, while cold settings hydration cannot mix a pre-await session snapshot with a post-await server epoch. `previewAutomationNewTabDefaults` carries both `viewport` and `profileId` into `previewEnvironment.open`; every fresh automation tab therefore uses the profile-specific Electron partition that receives imported cookies. Explicit `open` or its deprecated `show` alias remains authoritative; when both are omitted, `autoShowFloatingPreview` decides presentation. The resulting `shouldPresentPreview` value is passed unchanged to `previewAutomationOpenReadiness.ts`, so a reused rendered tab left in the background does not wait for visibility while an explicitly shown tab does. After settings and session synchronization, the host rechecks its remaining deadline immediately before tab creation and every later irreversible open-side mutation, so an already expired request cannot create, resize, reveal, or navigate a preview. A newly created tab applies its server snapshot and assigned tab id, uses the configured viewport or the branch's deterministic 1280×800 fallback when the snapshot remains `fill`, initiates any requested selection, and acknowledges server-side creation without depending on cold React panel rendering, Electron overlay registration, or page readiness. Its initial URL continues loading exactly once in that same tab; later wait, snapshot, or interaction operations own attachment and page readiness. Reopening an existing shown tab selects both the preview-state tab and its matching inline mini-player surface, then waits for stable presentation. The visibility wait reasserts that selection across same-server route hydration or session reconciliation only when neither the right panel nor the mini-player already selects the tab, so a panel that is still becoming visible keeps its browser-surface lease. A server-epoch change aborts the pending open with `PreviewAutomationTargetUnavailableError`; the old request never adopts the replacement runtime guest. +- Before a non-open automation operation drives its ready target, upstream's automatic-presentation policy may reveal the runtime tab when the setting is enabled. An explicit background open records suppression for that runtime tab, while a later explicit shown open clears it. Suppression is keyed by scoped thread and epoch-specific runtime tab, pruned against authoritative sessions after reconciliation, and removed after a successful automation close. The branch rechecks both epoch-scoped identity and the remaining deadline before applying automatic presentation, so a late operation cannot reveal a replacement guest or mutate presentation after its response budget expires. +- `preview_close` connects the MCP automation lifecycle to the existing authoritative preview-session close and Electron tab disposal paths. It closes an explicit tab or the provider session's assigned tab, removes visible and background presentation state, destroys the local runtime, returns `tabId: null` so the broker releases its assignment, and remains idempotent when cleanup is retried after either side already closed. `$test-t3-app` retains every collaborative preview tab id it creates and closes those owned tabs during final teardown before stopping the test environment. This bounds retained Electron guests and reduces exposure to the crash-prone hidden screenshot bridge; it does not couple close behavior to that bridge implementation or claim to fix the underlying Electron crash. +- The standard dev runner and Vite import `DEV_LOOPBACK_HOST` from `packages/shared/src/devProxy.ts`, keeping local navigation, direct backend URLs, the default Vite listener, and the default backend proxy on `127.0.0.1`. Browser modes use a single origin: they leave client HTTP/WebSocket URLs and generic `HOST` unset so remote sharing and origin-derived HMR keep working. Explicit wildcard IPv4 and IPv6 backend binds proxy through the matching loopback family, while concrete LAN or IPv6 bind addresses remain concrete so Vite reaches the interface where the backend actually listens. Desktop mode pins `HOST` and its renderer/backend URLs to the shared IPv4 loopback; server-only mode keeps direct HTTP/WebSocket URLs there too. The same shared module owns `resolveDevProxyTarget` and the browser-facing `DEV_BROWSER_LOOPBACK_HOST`; `browserTargetResolver.ts` uses that second constant for upstream's local environment-port normalization. Its cross-layer regression composes the branch's default `127.0.0.1` proxy target with the upstream resolver and requires the resulting guest URL to use `localhost`. +- Retained browser guests subscribe only to render state for their own epoch-scoped runtime tab, plus their thread's active panel and mini-player selection. Presentation or background-capture updates for another tab do not rerender every mounted `HostedBrowserWebview`; selection changes override a stale surface-visible flag so background staging remains nearly transparent and exposes its readiness marker, staging derives its viewport-fitted rectangle from the target tab's own stable rectangle, and a tab foregrounded while its capture lease remains held keeps the real panel or mini-player rectangle instead of adopting that staging rectangle. Only the active guest is exposed through the host accessibility tree; a nearly transparent background-capture guest stays `aria-hidden` without affecting CDP accessibility-tree collection against the guest target. +- The stable retained-surface selector also carries upstream's presentation `zIndex` into each `HostedBrowserWebview`. Visible guests follow the owning panel or sheet stacking order, while the branch's background-capture surface keeps its fixed isolated stacking value and remains non-interactive and accessibility-hidden. +- Rendered viewport readiness revalidates the epoch-scoped runtime guest after each awaited guest measurement and clamps every polling sleep to the remaining resize deadline, preserving typed target-replacement and viewport-timeout failures for short budgets. The serialized viewport mutation also rechecks that deadline after acquiring its mutation queue, before sending the server resize. +- The primary pairing route watches for later URL-fragment changes while it remains mounted. Navigating an already-loaded `/pair` document to `/pair#token=...` claims each new token once, removes the secret fragment, and runs the normal pairing exchange without requiring a reload or a second desktop window. Multiple tokens received while an exchange is pending are serialized, and the submitting state remains active until every queued exchange settles. +- `dev:desktop` derives `T3CODE_DESKTOP_USER_DATA_DIR=/userdata/electron` whenever the runner has an explicit base directory. Desktop configuration resolves that override to an absolute path, and app identity uses it before legacy migration or the normal Electron user-data default. This keeps an isolated worktree dev desktop from reusing an installed or earlier development profile whose incompatible IndexedDB schema can prevent the renderer from starting. Packaged/default startup remains unchanged when no override is supplied, and packaged macOS startup preserves the bundle or user-customized dock icon while unpackaged development still assigns the runtime PNG icon. +- Each Electron profile persists one stable preview automation `hostId` and registers it with the renderer machine label, platform, per-environment connection id, and supported operations. Physical host label and platform detection run in Electron's main process and cross the existing synchronous IPC boundary; the sandboxed preload stays free of Node built-in imports so source-built and packaged desktop bridges initialize consistently. `preview_list_hosts` returns only hosts connected to the caller's environment, and `preview_select_host` binds an agent's unassigned preview context without activating or changing either desktop window. A live assignment cannot be silently moved to another host; an unavailable explicit host fails closed instead of falling back. Implicit first-use routing prefers the visible target tab owner, then another target tab owner, then focus ordering among hosts supporting the operation when no host was explicitly selected. Explicit assignments survive transport replacement by stable host identity and retain the agent's tab selection, while a disconnected selected host stays unavailable until that renderer reconnects or another agent selects a host with its own unassigned context. + +Current limitations: + +- The Windows Electron 44.4.2 dev-shell reproduction distinguishes a paused host compositor from page or backend failure: hiding the owned window caused native capture to return `UnknownVizError` and CDP clicks to acknowledge success without dispatching page input. Scoped host unthrottling plus a committed host frame corrects that state without activating the window. Physical display sleep and GPU/device suspension have not been revalidated; if no frame arrives, input fails within its operation deadline and snapshots retain their semantic fallback. A previously interrupted native capture still blocks later native captures until its exact promise settles. Probe host liveness without attaching a debugger to the host window, since that can change its painting behavior. +- The isolated Windows Electron pass covers background snapshots and browser control. Full Electron recording transfer was not rerun for the caller-context and transport changes. + +Primary files: + +- `apps/desktop/src/app/DesktopAppIdentity.ts` +- `apps/desktop/src/app/DesktopConfig.ts` +- `apps/desktop/src/app/DesktopEnvironment.ts` +- `apps/desktop/src/preview/Manager.ts` +- `apps/desktop/src/ipc/channels.ts` +- `apps/desktop/src/ipc/DesktopIpcHandlers.ts` +- `apps/desktop/src/ipc/methods/window.ts` +- `apps/desktop/src/ipc/methods/preview.ts` +- `apps/desktop/src/preload.ts` +- `apps/server/src/mcp/McpHttpServer.ts` +- `apps/server/src/mcp/PreviewAutomationBroker.ts` +- `apps/server/src/mcp/toolkits/preview/tools.ts` +- `apps/server/src/mcp/toolkits/preview/handlers.ts` +- `apps/web/src/browser/HostedBrowserWebview.tsx` +- `apps/web/src/browser/browserRecording.ts` +- `apps/web/src/browser/browserRecordingUpload.ts` +- `apps/web/src/browser/recordingCompositor.ts` +- `apps/web/src/browser/browserDefaults.ts` +- `apps/web/src/browser/browserTargetResolver.ts` +- `apps/web/src/browser/browserSurfaceStore.ts` +- `apps/web/src/browser/desktopTabLifetime.ts` +- `apps/web/src/browser/hostedBrowserWebviewStyle.ts` +- `apps/web/src/components/auth/PairingRouteSurface.logic.ts` +- `apps/web/src/components/auth/PairingRouteSurface.tsx` +- `apps/web/src/components/preview/PreviewAutomationHosts.tsx` +- `apps/web/src/components/preview/closePreviewAutomationTab.ts` +- `apps/web/src/components/preview/previewAutomationClientId.ts` +- `apps/web/src/components/preview/previewAutomationPresentation.ts` +- `apps/web/src/components/preview/previewAutomationOpenReadiness.ts` +- `apps/web/src/components/preview/previewAutomationOverlayReadiness.ts` +- `apps/web/src/components/preview/previewAutomationErrors.ts` +- `apps/web/src/components/preview/previewAutomationRequestConsumer.ts` +- `apps/web/src/components/preview/previewHostRendering.ts` +- `apps/web/vite.config.ts` +- `packages/contracts/src/previewAutomation.ts` +- `packages/contracts/src/ipc.ts` +- `packages/shared/src/devProxy.ts` +- `.agents/skills/test-t3-app/SKILL.md` +- `scripts/dev-runner.ts` + +Focused regression coverage lives in `scripts/dev-runner.test.ts`, `apps/desktop/src/app/DesktopAppIdentity.test.ts`, `apps/desktop/src/app/DesktopEnvironment.test.ts`, `apps/desktop/src/ipc/methods/window.test.ts`, `apps/desktop/src/preview/Manager.test.ts`, `apps/server/src/mcp/McpHttpServer.test.ts`, `apps/server/src/mcp/PreviewAutomationBroker.test.ts`, `apps/server/src/mcp/toolkits/preview/tools.test.ts`, `apps/server/src/mcp/toolkits/preview/handlers.test.ts`, `apps/web/src/browser/browserRecording.test.ts`, `apps/web/src/browser/browserRecordingUpload.test.ts`, `apps/web/src/browser/recordingCompositor.test.ts`, `apps/web/src/browser/browserSurfaceStore.test.ts`, `apps/web/src/browser/browserTargetResolver.test.ts`, `apps/web/src/browser/hostedBrowserWebviewStyle.test.ts`, `apps/web/src/browser/browserViewportActions.test.ts`, `apps/web/src/browser/browserViewportLayout.test.ts`, `apps/web/src/browser/previewRuntimeTabId.test.ts`, `apps/web/src/components/auth/PairingRouteSurface.logic.test.ts`, `apps/web/src/components/preview/closePreviewAutomationTab.test.ts`, `apps/web/src/components/preview/previewAutomationClientId.test.ts`, `apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts`, `apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts`, `apps/web/src/components/preview/previewAutomationPresentation.test.ts`, `apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts`, `apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts`, `apps/web/src/components/preview/previewHostRendering.test.ts`, `apps/web/src/components/preview/previewNavigationReadiness.test.ts`, `apps/web/src/components/preview/previewViewportReadiness.test.ts`, `apps/web/src/components/preview/previewViewportRollback.test.ts`, `packages/contracts/src/ipc.test.ts`, and `packages/contracts/src/preview.test.ts`. + +```sh +vp test run scripts/dev-runner.test.ts apps/desktop/src/app/DesktopAppIdentity.test.ts apps/desktop/src/app/DesktopEnvironment.test.ts apps/desktop/src/ipc/methods/window.test.ts apps/desktop/src/preview/Manager.test.ts apps/server/src/mcp/McpHttpServer.test.ts apps/server/src/mcp/PreviewAutomationBroker.test.ts apps/server/src/mcp/toolkits/preview/tools.test.ts apps/server/src/mcp/toolkits/preview/handlers.test.ts apps/web/src/browser/browserRecording.test.ts apps/web/src/browser/browserRecordingUpload.test.ts apps/web/src/browser/recordingCompositor.test.ts apps/web/src/components/preview/PreviewAutomationHosts.test.tsx apps/web/src/browser/browserSurfaceStore.test.ts apps/web/src/browser/browserTargetResolver.test.ts apps/web/src/browser/hostedBrowserWebviewStyle.test.ts apps/web/src/browser/browserViewportActions.test.ts apps/web/src/browser/browserViewportLayout.test.ts apps/web/src/browser/previewRuntimeTabId.test.ts apps/web/src/components/auth/PairingRouteSurface.logic.test.ts apps/web/src/components/preview/closePreviewAutomationTab.test.ts apps/web/src/components/preview/previewAutomationClientId.test.ts apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts apps/web/src/components/preview/previewAutomationPresentation.test.ts apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts apps/web/src/components/preview/previewHostRendering.test.ts apps/web/src/components/preview/previewNavigationReadiness.test.ts apps/web/src/components/preview/previewViewportReadiness.test.ts apps/web/src/components/preview/previewViewportRollback.test.ts packages/contracts/src/ipc.test.ts packages/contracts/src/preview.test.ts apps/server/src/auth/EnvironmentAuth.test.ts apps/server/src/provider/Layers/ClaudeAdapter.test.ts apps/server/src/pullRequest/PullRequestService.test.ts apps/mobile/src/features/terminal/terminalInput.test.ts apps/mobile/src/features/terminal/terminalPaste.test.ts apps/mobile/src/features/terminal/terminalMenu.test.ts +``` + +Current verification: + +- The upstream base is `d2c9281b8112dc3b2991642c4bdb985e4b08b9bb`. +- Capture re-registration coverage passes all 117 manager tests, including interruption before and after re-registration, native-tail settlement recovery, and generation replacement during the retry delay. Desktop typechecking passes. The earlier integrated Electron results below remain the runtime evidence; this follow-up changes only the manager's capture lifecycle. +- Desktop, web, server, and contract typechecks pass. The focused list above passes 883 tests with one skipped across 39 files, including the screenshot-unavailable stages in the desktop manager, the broker's eviction grace with late typed-timeout and late-success answers, the MCP snapshot reason text, and the host frame-liveness probe. Recording, compositor, and upload coverage passes, including bounded compositor initialization and late disposal that leaves a newer recording active. +- Targeted lint over the touched files has no errors and only pre-existing warnings; formatting checks pass. +- Display-wake pass on the same isolated stack, driven through its own `/mcp` tools with no session input: with the block held, `preview_status` reported `hostRendering: "active"` and `displaySleepBlocked: true` at 2, 4, 6, and 8 minutes idle (the unblocked host had read `paused` and returned an interrupted screenshot at about 5 minutes), and `preview_snapshot` at 8 minutes idle returned a 1280×800 PNG in 82 ms. After 5.5 minutes with no automation traffic the desktop trace recorded one `PreviewManager.releaseDisplayWake` span (inactivity); the next status request restarted the block, and closing the last automation tab recorded a second release. Desktop, web, server, and contract typechecks pass; the manager suite passes 109 tests including the display-wake cases. +- Isolated Windows Electron pass through the main runner's `dev:desktop` (worktree `.t3` state, own Electron profile, `T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT` for the driver), driving the isolated server's own `/mcp` endpoint with the credential a live Claude turn received: `preview_status` returned `hostRendering: "active"` in 17 ms once the display was awake and `"paused"` while it was idle or the window was minimized; a normal `preview_snapshot` returned a 1280×800 PNG in 82 ms; the idle display produced the `interrupted` reason with `hostRendering: "paused"` in the tool text; and a `preview_evaluate` with `timeoutMs: 1000` fired 838 ms into a 2.5 s renderer main-thread stall failed the caller with `Preview automation evaluate timed out after 1000ms.` at 1016 ms while `preview_list_hosts` still listed the same assigned host 140 ms later, the next `preview_evaluate` succeeded on it, and the server trace showed no `closeConnection` or `disconnect` span and no new `acquireConnection`. +- Caller-context and transport verification passes 204 focused tests, with server, web, client-runtime, and contract typechecks passing. +- Isolated Windows Electron verification passes independent caller tab creation, navigation, input, implicit-target preservation, and background snapshots. A real WebSocket experiment inside Electron reproduces cross-request blocking with the original transport and verifies independent delivery with the fix, preserving all 18 stream values in order. The Windows screenshot-save assertion decodes JSON before comparing the path. The original production subscriber slowdown remains unproven; the corrected transport failure is independently reproduced. Shared client transport applies to web, desktop, and mobile; native mobile UI was not exercised for this change. + +## Development Ports + +Preferred ports when explicitly selecting offset 11: + +- Web: `5744` +- Server/WebSocket: `13784` +- This explicit offset is optional. Use `T3CODE_PORT_OFFSET=11 vp run dev` only when you need to prefer this documented pair instead of the worktree-path-derived starting offset. +- If either preferred port is unavailable, the runner can advance. Use the `serverPort` and `webPort` values from the printed `[dev-runner]` line for testing; if a test requires the documented pair exactly, free the conflicting ports and restart. diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 295a07355acf..ce27d9018830 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -145,6 +145,31 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { + it.effect("uses an explicit desktop user-data directory without probing legacy state", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/tmp/t3-desktop-profile"); + }), + { + environment: { + env: { + T3CODE_DESKTOP_USER_DATA_DIR: " /tmp/t3-desktop-profile ", + }, + }, + legacyPathProbeError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + description: "legacy path must not be probed", + pathOrDescriptor: "/Users/alice/Library/Application Support/T3 Code (Alpha)", + }), + }, + ), + ); + it.effect("keeps using the legacy userData path when it already exists", () => withIdentity( Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 36b3079ad2c4..32f01d97d924 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -48,6 +48,10 @@ const normalizeCommitHash = (value: string): Option.Option => { export const resolveUserDataPath = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + if (Option.isSome(environment.desktopUserDataDir)) { + return environment.desktopUserDataDir.value; + } + const legacyPath = environment.path.join( environment.appDataDirectory, environment.legacyUserDataDirName, diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index ba3a2cba04e1..4cd761cb2929 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -49,6 +49,7 @@ const makeDesktopClerkLayer = ( stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", + desktopUserDataDir: Option.none(), userDataDirName: isDevelopment ? "t3code-dev" : "t3code", legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", path: { join: (...parts: ReadonlyArray) => parts.join("/") }, diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 773ea24509cf..5e67a48a05b9 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -39,6 +39,7 @@ export const DesktopConfig = Config.all({ xdgDataHome: trimmedString("XDG_DATA_HOME"), t3Home: trimmedString("T3CODE_HOME"), devServerUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option), + desktopUserDataDir: trimmedString("T3CODE_DESKTOP_USER_DATA_DIR"), appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), configuredBackendPort: Config.Port("T3CODE_PORT").pipe(Config.option), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index b5aabf253d0f..84b1686cf387 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -46,6 +46,7 @@ describe("DesktopEnvironment", () => { {}, { T3CODE_HOME: " /tmp/t3 ", + T3CODE_DESKTOP_USER_DATA_DIR: " /tmp/t3/userdata/electron ", T3CODE_COMMIT_HASH: " 0123456789abcdef ", T3CODE_PORT: "4949", VITE_DEV_SERVER_URL: "http://localhost:5173", @@ -84,6 +85,7 @@ describe("DesktopEnvironment", () => { Option.map(environment.devServerUrl, (url) => url.href), Option.some("http://localhost:5173/"), ); + assert.deepEqual(environment.desktopUserDataDir, Option.some("/tmp/t3/userdata/electron")); assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("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/remote/server.mjs")); assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index beb09bd50a46..51dd09cdd66a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -70,6 +70,7 @@ export class DesktopEnvironment extends Context.Service< readonly compileCachePath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; + readonly desktopUserDataDir: Option.Option; readonly devRemoteT3ServerEntryPath: Option.Option; readonly configuredBackendPort: Option.Option; readonly commitHashOverride: Option.Option; @@ -228,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( ? path.join(resourcesPath, "app-update.yml") : path.join(input.appPath, "dev-app-update.yml"), devServerUrl, + desktopUserDataDir: Option.map(config.desktopUserDataDir, path.resolve), devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, configuredBackendPort: config.configuredBackendPort, commitHashOverride: config.commitHashOverride, diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 4b43cd0eee96..19e0c7597a46 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getPreviewAutomationHostMetadata, getSystemLocale, getWindowFullscreenState, openExternal, @@ -81,6 +82,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.complete); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getPreviewAutomationHostMetadata); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 151cd633d04e..af38d4e3a61d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -24,6 +24,8 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_PREVIEW_AUTOMATION_HOST_METADATA_CHANNEL = + "desktop:get-preview-automation-host-metadata"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 36cf7c9abe15..5fe88ebf7e2a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,12 +5,15 @@ import { DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationSnapshotInputSchema, DesktopPreviewAutomationStatusSchema, DesktopPreviewAutomationTypeInputSchema, DesktopPreviewAutomationWaitForInputSchema, DesktopPreviewConfigInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, + DesktopPreviewRecordingStartInputSchema, + DesktopPreviewRecordingStopInputSchema, DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, @@ -161,9 +164,13 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, payload: DesktopPreviewSetColorSchemeInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) { + handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ + tabId, + colorScheme, + timeoutMs, + }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.setColorScheme(tabId, colorScheme); + yield* manager.setColorScheme(tabId, colorScheme, timeoutMs); }), }); export const setAudioMuted = DesktopIpc.makeIpcMethod({ @@ -187,9 +194,9 @@ export const cancelPickElement = tabMethod( ); export const startRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - payload: DesktopPreviewTabInputSchema, + payload: DesktopPreviewRecordingStartInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { + handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId, timeoutMs }) { const manager = yield* PreviewManager.PreviewManager; const store = yield* DesktopClientSettings.DesktopClientSettings; const settings = yield* store.get; @@ -197,14 +204,18 @@ export const startRecording = DesktopIpc.makeIpcMethod({ showKeyPresses: value.browserRecordingShowKeyPresses, showMousePresses: value.browserRecordingShowMousePresses, })); - yield* manager.startRecording(tabId, Option.getOrUndefined(options)); + yield* manager.startRecording(tabId, Option.getOrUndefined(options), timeoutMs); + }), +}); +export const stopRecording = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, + payload: DesktopPreviewRecordingStopInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.stopRecording")(function* ({ tabId, timeoutMs }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.stopRecording(tabId, timeoutMs); }), }); -export const stopRecording = tabMethod( - IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, - "desktop.ipc.preview.stopRecording", - (manager, tabId) => manager.stopRecording(tabId), -); export const openPictureInPicture = tabMethod( IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, "desktop.ipc.preview.openPictureInPicture", @@ -404,11 +415,15 @@ export const automationStatus = DesktopIpc.makeIpcMethod({ export const automationSnapshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, - payload: DesktopPreviewTabInputSchema, + payload: DesktopPreviewAutomationSnapshotInputSchema, result: PreviewAutomationSnapshot, - handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) { + handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ + tabId, + background, + timeoutMs, + }) { const manager = yield* PreviewManager.PreviewManager; - return yield* manager.automationSnapshot(tabId); + return yield* manager.automationSnapshot(tabId, background, timeoutMs); }), }); @@ -476,9 +491,15 @@ export const saveRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, payload: DesktopPreviewRecordingSaveInputSchema, result: DesktopPreviewRecordingArtifactSchema, - handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) { + handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ + tabId, + mimeType, + data, + idempotencyKey, + timeoutMs, + }) { const manager = yield* PreviewManager.PreviewManager; - return yield* manager.saveRecording(tabId, mimeType, data); + return yield* manager.saveRecording(tabId, mimeType, data, idempotencyKey, timeoutMs); }), }); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 0b43fa9e6ef9..1551a6c02ab9 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -27,12 +28,33 @@ import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, + getPreviewAutomationHostMetadata, getWindowFullscreenState, pasteAsText, pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; +describe("getPreviewAutomationHostMetadata", () => { + for (const [hostPlatform, previewPlatform] of [ + ["darwin", "macos"], + ["win32", "windows"], + ["linux", "linux"], + ["freebsd", "unknown"], + ] as const) { + it.effect(`maps ${hostPlatform} renderer identity metadata`, () => + Effect.gen(function* () { + const result = yield* getPreviewAutomationHostMetadata.handler(); + + assert.deepEqual(result, { + label: NodeOS.hostname(), + platform: previewPlatform, + }); + }).pipe(Effect.provideService(HostProcessPlatform, hostPlatform)), + ); + } +}); + const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", args: ["-d", "Ubuntu", "--", "node", "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/app/bin.mjs"], diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07ab1..62201a478c65 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -56,6 +56,11 @@ const ContextMenuInput = Schema.Struct({ position: Schema.optionalKey(ContextMenuPosition), }); +const PreviewAutomationHostMetadata = Schema.Struct({ + label: Schema.String, + platform: Schema.Literals(["macos", "windows", "linux", "unknown"]), +}); + function toWebSocketBaseUrl(httpBaseUrl: URL): string { const url = new URL(httpBaseUrl.href); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; @@ -71,6 +76,25 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getPreviewAutomationHostMetadata = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_PREVIEW_AUTOMATION_HOST_METADATA_CHANNEL, + result: PreviewAutomationHostMetadata, + handler: Effect.fn("desktop.ipc.window.getPreviewAutomationHostMetadata")(function* () { + const hostPlatform = yield* HostProcess.HostProcessPlatform; + return { + label: NodeOS.hostname(), + platform: + hostPlatform === "darwin" + ? "macos" + : hostPlatform === "win32" + ? "windows" + : hostPlatform === "linux" + ? "linux" + : "unknown", + } as const; + }), +}); + export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, result: Schema.String, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e84a5821e9ff..0457612a7350 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -63,6 +63,10 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { } contextBridge.exposeInMainWorld("desktopBridge", { + getPreviewAutomationHostMetadata: () => + ipcRenderer.sendSync(IpcChannels.GET_PREVIEW_AUTOMATION_HOST_METADATA_CHANNEL) as ReturnType< + NonNullable + >, getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); if (typeof result !== "object" || result === null) { @@ -304,8 +308,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), - setColorScheme: (tabId, colorScheme) => - ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), + setColorScheme: (tabId, colorScheme, timeoutMs) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { + tabId, + colorScheme, + timeoutMs, + }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), openDevTools: (tabId) => @@ -346,15 +354,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); }, - startScreencast: (tabId) => - ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), - stopScreencast: (tabId) => - ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }), - save: (tabId, mimeType, data) => + startScreencast: (tabId, timeoutMs) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId, timeoutMs }), + stopScreencast: (tabId, timeoutMs) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId, timeoutMs }), + save: (tabId, mimeType, data, idempotencyKey, timeoutMs) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, { tabId, mimeType, data, + idempotencyKey, + timeoutMs, }), onFrame: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { @@ -369,8 +379,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { automation: { status: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), - snapshot: (tabId) => - ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + snapshot: (tabId, background = false, timeoutMs) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { + tabId, + background, + timeoutMs, + }), click: (tabId, input) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), type: (tabId, input) => diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 304efeafefe9..a7eee790093e 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -41,6 +41,24 @@ describe("fitPictureInPictureContentSize", () => { }); }); +describe("automationExecutionBudget", () => { + it("keeps short execution budgets monotonic while reserving response grace", () => { + expect(PreviewManager.automationExecutionBudget(100)).toBe(100); + expect(PreviewManager.automationExecutionBudget(500)).toBe(500); + expect(PreviewManager.automationExecutionBudget(501)).toBe(500); + expect(PreviewManager.automationExecutionBudget(750)).toBe(500); + expect(PreviewManager.automationExecutionBudget(751)).toBe(501); + expect(PreviewManager.automationExecutionBudget(1_000)).toBe(750); + + const budgets = Array.from({ length: 1_001 }, (_, timeoutMs) => + PreviewManager.automationExecutionBudget(timeoutMs), + ); + expect(budgets.every((budget, index) => index === 0 || budget >= budgets[index - 1]!)).toBe( + true, + ); + }); +}); + describe("recordingFileExtension", () => { it("derives the artifact extension from the recorder's actual mime type", () => { expect(PreviewManager.recordingFileExtension("video/mp4;codecs=avc1.640028")).toBe("mp4"); @@ -186,12 +204,15 @@ const { fromId, getFocusedWebContents, mkdir, + powerSaveBlockerStart, + powerSaveBlockerStop, + powerSaveBlockerIsStarted, showItemInFolder, webviewSend, writeFile, writeClipboard, } = vi.hoisted(() => ({ - browserWindowConstructor: vi.fn(), + browserWindowConstructor: vi.fn<(...args: Array) => unknown>(), clipboardItemConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean; readonly toPNG: () => Buffer } => ({ isEmpty: () => false, @@ -200,6 +221,9 @@ const { fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), + powerSaveBlockerStart: vi.fn((_type: string) => 7), + powerSaveBlockerStop: vi.fn((_id: number) => true), + powerSaveBlockerIsStarted: vi.fn((_id: number) => true), showItemInFolder: vi.fn(), webviewSend: vi.fn(), writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), @@ -219,6 +243,11 @@ vi.mock("electron", () => ({ nativeImage: { createFromPath, }, + powerSaveBlocker: { + start: powerSaveBlockerStart, + stop: powerSaveBlockerStop, + isStarted: powerSaveBlockerIsStarted, + }, shell: { showItemInFolder, }, @@ -284,10 +313,17 @@ const withManager = ( }).pipe(Effect.provide(layer), Effect.scoped); interface TestCapturedPreviewImage { - readonly toJPEG: () => Buffer; + readonly toJPEG?: () => Buffer; readonly getSize: () => { readonly width: number; readonly height: number }; } +type TestPreviewDebuggerOverrides = Partial< + Pick< + Electron.WebContents["debugger"], + "isAttached" | "attach" | "detach" | "sendCommand" | "on" | "off" + > +>; + type TestDisplayMediaHandler = ( request: { readonly frame: { readonly frameTreeNodeId: number } | null }, callback: (streams: { video?: unknown }) => void, @@ -324,22 +360,50 @@ const makeTestHostWebContents = (): TestHostWebContents => { }; }; +interface TestPreviewWebContentsOptions { + readonly capturePage?: () => Promise; + readonly executeJavaScript?: Electron.WebContents["executeJavaScript"]; + readonly hostWebContents?: TestHostWebContents; + readonly id?: number; + readonly getURL?: Electron.WebContents["getURL"]; + readonly isDevToolsOpened?: Electron.WebContents["isDevToolsOpened"]; + readonly debugger?: TestPreviewDebuggerOverrides; +} + const makeTestPreviewWebContents = ( - capturePage: () => Promise, - id = 42, - hostWebContents: TestHostWebContents = makeTestHostWebContents(), -) => { + optionsOrCapturePage: + | TestPreviewWebContentsOptions + | (() => Promise) = {}, + legacyId = 42, + legacyHostWebContents: TestHostWebContents = makeTestHostWebContents(), +): TestPreviewWebContents => { + const options: TestPreviewWebContentsOptions = + typeof optionsOrCapturePage === "function" + ? { + capturePage: optionsOrCapturePage, + hostWebContents: legacyHostWebContents, + id: legacyId, + } + : optionsOrCapturePage; + const capturePage = + options.capturePage ?? + (async () => { + throw new Error("Unexpected preview capture"); + }); + const id = options.id ?? 42; const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); return { id, mainFrame: { routingId: id }, - hostWebContents, - executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), + hostWebContents: options.hostWebContents ?? legacyHostWebContents, + executeJavaScript: + options.executeJavaScript ?? vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", - getURL: () => "https://example.com", + getURL: options.getURL ?? (() => "https://example.com"), getTitle: () => "Example", isLoading: () => false, + isDevToolsOpened: options.isDevToolsOpened ?? (() => false), getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -358,6 +422,7 @@ const makeTestPreviewWebContents = ( sendCommand: vi.fn(async () => undefined), on: vi.fn(), off: vi.fn(), + ...options.debugger, }, capturePage, } as unknown as TestPreviewWebContents; @@ -553,6 +618,9 @@ describe("PreviewManager", () => { writeClipboard.mockClear(); createFromPath.mockClear(); webviewSend.mockClear(); + powerSaveBlockerStart.mockClear(); + powerSaveBlockerStop.mockClear(); + powerSaveBlockerIsStarted.mockClear(); }); effectIt.effect("keeps preview shortcuts out of the host window", () => @@ -673,6 +741,7 @@ describe("PreviewManager", () => { url: null, title: null, loading: false, + displaySleepBlocked: true, }); yield* manager.createTab("tab_1"); @@ -684,12 +753,436 @@ describe("PreviewManager", () => { url: null, title: null, loading: false, + displaySleepBlocked: true, }); expect(fromId).not.toHaveBeenCalled(); }), ), ); + effectIt.effect( + "captures foreground and background snapshots while failing fast behind a timed-out native capture", + () => + withManager((manager) => + Effect.gen(function* () { + const png = Buffer.from("automation-preview-png"); + let hostThrottled = true; + const host = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling: (value: boolean) => (hostThrottled = value), + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: host, + } as never); + const image = { + isEmpty: () => false, + getSize: () => ({ width: 640, height: 360 }), + resize: vi.fn(), + toPNG: () => png, + }; + let attached = false; + let captureMode: "available" | "empty" | "failure" | "timeout" = "available"; + let resolveNativeCapture: ((captured: typeof image) => void) | undefined; + let accessibilityGate: Promise | null = null; + const controlEvents: Array = []; + const attach = vi.fn(() => { + attached = true; + controlEvents.push("attach"); + }); + const detach = vi.fn(() => { + attached = false; + controlEvents.push("detach"); + }); + const capturePage = vi.fn( + async (_rect?: Electron.Rectangle, _options?: { readonly stayHidden?: boolean }) => { + expect(hostThrottled).toBe(false); + if (captureMode === "failure") throw new Error("UnknownVizError"); + if (captureMode === "empty") return { ...image, isEmpty: () => true }; + if (captureMode === "timeout") { + return await new Promise((resolve) => { + resolveNativeCapture = resolve; + }); + } + return image; + }, + ); + const sendCommand = vi.fn(async (method: string): Promise => { + if (method === "Runtime.evaluate") { + controlEvents.push("evaluate"); + return { + result: { + value: { + url: "https://example.com/", + title: "Example", + loading: false, + visibleText: "Example body", + interactiveElements: [], + }, + }, + }; + } + if (method === "Accessibility.getFullAXTree") { + if (accessibilityGate !== null) await accessibilityGate; + return { nodes: [] }; + } + return undefined; + }); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + capturePage, + hostWebContents: host, + id: 42, + getURL: () => "https://example.com/", + isDevToolsOpened: () => false, + debugger: { + isAttached: () => attached, + attach, + detach, + sendCommand, + }, + }), + ); + yield* manager.createTab("tab_snapshot"); + yield* manager.registerWebview("tab_snapshot", 42); + yield* Effect.yieldNow; + + const foreground = yield* manager.automationSnapshot("tab_snapshot"); + expect(hostThrottled).toBe(true); + expect(foreground).toMatchObject({ + url: "https://example.com/", + title: "Example", + visibleText: "Example body", + accessibilityTree: { nodes: [] }, + screenshot: { + mimeType: "image/png", + data: png.toString("base64"), + width: 640, + height: 360, + }, + }); + expect(capturePage).toHaveBeenCalledWith(undefined, { stayHidden: false }); + expect(sendCommand).not.toHaveBeenCalledWith("Page.captureScreenshot", expect.anything()); + expect(sendCommand).not.toHaveBeenCalledWith("Page.bringToFront", undefined); + + capturePage.mockClear(); + const background = yield* manager.automationSnapshot("tab_snapshot", true); + expect(hostThrottled).toBe(true); + expect(background.screenshot).toMatchObject({ width: 640, height: 360 }); + expect(capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true }); + expect(browserWindowConstructor).not.toHaveBeenCalled(); + + expect(background.screenshotUnavailable).toBeUndefined(); + + capturePage.mockClear(); + captureMode = "failure"; + expect(yield* manager.automationSnapshot("tab_snapshot", true)).toMatchObject({ + screenshot: null, + screenshotUnavailable: { stage: "capture", message: "UnknownVizError" }, + }); + expect(hostThrottled).toBe(true); + expect(capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true }); + + capturePage.mockClear(); + captureMode = "empty"; + expect(yield* manager.automationSnapshot("tab_snapshot")).toMatchObject({ + screenshot: null, + screenshotUnavailable: { + stage: "encode", + message: "Screenshot capture returned an invalid PNG", + }, + }); + expect(capturePage).toHaveBeenCalledOnce(); + expect(detach).not.toHaveBeenCalled(); + + capturePage.mockClear(); + captureMode = "timeout"; + const timedOutCapture = yield* manager + .automationSnapshot("tab_snapshot", true, 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* settle(() => capturePage.mock.calls.length === 1); + yield* TestClock.adjust(725); + expect(yield* Fiber.join(timedOutCapture)).toMatchObject({ + screenshot: null, + screenshotUnavailable: { + stage: "interrupted", + message: expect.stringContaining("did not settle within 725ms"), + }, + }); + expect( + yield* manager.automationEvaluate("tab_snapshot", { + expression: "document.title", + }), + ).toMatchObject({ title: "Example" }); + expect(detach).not.toHaveBeenCalled(); + + const queuedCapture = yield* manager + .automationSnapshot("tab_snapshot", true, 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(capturePage).toHaveBeenCalledOnce(); + expect(yield* Fiber.join(queuedCapture)).toMatchObject({ + screenshot: null, + screenshotUnavailable: { + stage: "pending-capture", + message: "Preview capture is unavailable while a timed-out capture is pending", + }, + }); + expect(capturePage).toHaveBeenCalledOnce(); + + captureMode = "available"; + resolveNativeCapture?.(image); + yield* Effect.promise( + () => new Promise((resolve) => globalThis.setImmediate(resolve)), + ); + const recovered = yield* manager.automationSnapshot("tab_snapshot", true); + expect(recovered.screenshot).toMatchObject({ width: 640, height: 360 }); + expect(recovered.screenshotUnavailable).toBeUndefined(); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(attach).toHaveBeenCalledOnce(); + + capturePage.mockClear(); + let releaseAccessibility!: () => void; + accessibilityGate = new Promise((resolve) => { + releaseAccessibility = resolve; + }); + const skippedCapture = yield* manager + .automationSnapshot("tab_snapshot", false, 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + yield* TestClock.adjust(730); + releaseAccessibility(); + accessibilityGate = null; + + expect(yield* Fiber.join(skippedCapture)).toMatchObject({ + screenshot: null, + screenshotUnavailable: { stage: "budget" }, + }); + expect(capturePage).not.toHaveBeenCalled(); + expect(detach).not.toHaveBeenCalled(); + expect(attach).toHaveBeenCalledOnce(); + + expect((yield* manager.automationSnapshot("tab_snapshot")).screenshot).toMatchObject({ + width: 640, + height: 360, + }); + expect(attach).toHaveBeenCalledOnce(); + expect(controlEvents).not.toContain("detach"); + }), + ), + ); + + effectIt.effect("bounds debugger initialization and recovers the control session", () => + withManager((manager) => + Effect.gen(function* () { + let attached = false; + let firstInitialization = true; + let hostThrottled = true; + const host = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling: (value: boolean) => (hostThrottled = value), + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: host, + } as never); + const attach = vi.fn(() => { + attached = true; + }); + const detach = vi.fn(() => { + attached = false; + }); + const sendCommand = vi.fn(async (method: string): Promise => { + if (method === "Runtime.enable" && firstInitialization) { + expect(hostThrottled).toBe(false); + firstInitialization = false; + return await new Promise(() => undefined); + } + return method === "Runtime.evaluate" ? { result: { value: "recovered" } } : undefined; + }); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + id: 43, + hostWebContents: host, + getURL: () => "https://example.com/", + debugger: { + isAttached: () => attached, + attach, + detach, + sendCommand, + }, + }), + ); + + yield* manager.createTab("tab_timeout"); + yield* manager.registerWebview("tab_timeout", 43); + yield* Effect.yieldNow; + + const evaluation = yield* manager + .automationEvaluate("tab_timeout", { + expression: "document.title", + timeoutMs: 1_000, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + yield* TestClock.adjust(750); + const timedOut = yield* Effect.exit(Fiber.join(evaluation)); + expect(hostThrottled).toBe(true); + + expect(Exit.isFailure(timedOut)).toBe(true); + if (Exit.isFailure(timedOut)) { + const error = Option.getOrThrow(Cause.findErrorOption(timedOut.cause)); + expect(error).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + operation: "evaluate", + tabId: "tab_timeout", + timeoutMs: 1_000, + }); + expect((error as Error).message).toBe( + "Preview automation evaluate timed out after 1000ms in tab tab_timeout", + ); + } + expect(detach).toHaveBeenCalledOnce(); + + expect( + yield* manager.automationEvaluate("tab_timeout", { + expression: "document.title", + }), + ).toBe("recovered"); + expect(attach).toHaveBeenCalledTimes(2); + }), + ), + ); + + effectIt.effect("does not let a stalled state listener withhold a timeout response", () => + withManager((manager) => + Effect.gen(function* () { + let attached = false; + let evaluationStarted = false; + const sendCommand = vi.fn(async (method: string): Promise => { + if (method === "Runtime.evaluate") { + evaluationStarted = true; + return await new Promise(() => undefined); + } + return undefined; + }); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + id: 44, + debugger: { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: vi.fn(() => { + attached = false; + }), + sendCommand, + }, + }), + ); + + yield* manager.createTab("tab_listener_timeout"); + yield* manager.registerWebview("tab_listener_timeout", 44); + let finalDeliveryStarted = false; + const releaseFinalDelivery = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + state.controller === "none" + ? Effect.sync(() => { + finalDeliveryStarted = true; + }).pipe(Effect.andThen(Deferred.await(releaseFinalDelivery))) + : Effect.void, + ); + + yield* Effect.gen(function* () { + const evaluation = yield* manager + .automationEvaluate("tab_listener_timeout", { + expression: "document.title", + timeoutMs: 1_000, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(evaluationStarted).toBe(true); + yield* TestClock.adjust(750); + yield* Effect.yieldNow; + + const result = evaluation.pollUnsafe(); + yield* Deferred.succeed(releaseFinalDelivery, undefined); + expect(result).toMatchObject({ _tag: "Failure" }); + yield* Effect.yieldNow; + expect(finalDeliveryStarted).toBe(true); + }).pipe(Effect.ensuring(Deferred.succeed(releaseFinalDelivery, undefined))); + }), + ), + ); + + effectIt.effect("does not let a queued timeout detach the active control session", () => + withManager((manager) => + Effect.gen(function* () { + let attached = false; + let stallNextEvaluation = true; + const attach = vi.fn(() => { + attached = true; + }); + const detach = vi.fn(() => { + attached = false; + }); + const sendCommand = vi.fn(async (method: string): Promise => { + if (method === "Runtime.evaluate" && stallNextEvaluation) { + stallNextEvaluation = false; + return await new Promise(() => undefined); + } + return method === "Runtime.evaluate" ? { result: { value: "recovered" } } : undefined; + }); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + id: 44, + getURL: () => "https://example.com/", + debugger: { + isAttached: () => attached, + attach, + detach, + sendCommand, + }, + }), + ); + + yield* manager.createTab("tab_queued_timeout"); + yield* manager.registerWebview("tab_queued_timeout", 44); + let latestController: string | undefined; + yield* manager.subscribeStateChanges((tabId, state) => + Effect.sync(() => { + if (tabId === "tab_queued_timeout") latestController = state.controller; + }), + ); + const active = yield* manager + .automationEvaluate("tab_queued_timeout", { expression: "document.title" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + const queued = yield* manager + .automationWaitFor("tab_queued_timeout", { text: "ready", timeoutMs: 1_000 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + yield* TestClock.adjust(750); + expect(Exit.isFailure(yield* Effect.exit(Fiber.join(queued)))).toBe(true); + expect(detach).not.toHaveBeenCalled(); + expect(latestController).toBe("agent"); + + yield* TestClock.adjust(14_000); + expect(Exit.isFailure(yield* Effect.exit(Fiber.join(active)))).toBe(true); + expect(detach).toHaveBeenCalledOnce(); + expect(latestController).toBe("none"); + + expect( + yield* manager.automationEvaluate("tab_queued_timeout", { + expression: "document.title", + }), + ).toBe("recovered"); + }), + ), + ); + effectIt.effect("rejects a destroyed webview during registration", () => withManager((manager) => Effect.gen(function* () { @@ -826,6 +1319,7 @@ describe("PreviewManager", () => { url: "http://localhost:3200/", title: "", loading: true, + displaySleepBlocked: true, }); yield* manager.registerWebview("tab_pending", 42); @@ -1659,28 +2153,298 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_scheme", 43); yield* Effect.yieldNow; - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + yield* manager.setColorScheme("tab_scheme", "system"); + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "" }], + }); + expect(states.at(-1)?.colorScheme).toBe("system"); + }), + ), + ); + + effectIt.effect("keeps a replacement control session when an old restoration times out", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + const makeGuest = (on: ReturnType) => { + let attached = false; + const detach = vi.fn(() => { + attached = false; + }); + const sendCommand = vi.fn(async () => undefined); + const wc = makeTestPreviewWebContents({ + id: 42, + debugger: { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach, + sendCommand, + }, + }); + Object.assign(wc, { on }); + return { wc, detach, sendCommand, isAttached: () => attached }; + }; + const previous = makeGuest( + vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + }), + ); + fromId.mockReturnValue(previous.wc); + yield* manager.createTab("restore_replacement"); + yield* manager.registerWebview("restore_replacement", 42); + yield* manager.setColorScheme("restore_replacement", "dark"); + previous.sendCommand.mockClear(); + previous.sendCommand.mockImplementationOnce(() => new Promise(() => undefined)); + listeners.get("devtools-closed")?.(); + yield* settle(() => previous.sendCommand.mock.calls.length > 0); + expect(previous.sendCommand).toHaveBeenCalledOnce(); + const replacement = makeGuest(vi.fn()); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("restore_replacement", 42); + yield* settle(() => replacement.sendCommand.mock.calls.length > 0); + expect(replacement.isAttached()).toBe(true); + + yield* TestClock.adjust(14_750); + + expect(replacement.detach).not.toHaveBeenCalled(); + expect(replacement.isAttached()).toBe(true); + }), + ), + ); + + effectIt.effect("does not persist a color-scheme mutation after its deadline", () => + withManager((manager) => + Effect.gen(function* () { + let attached = false; + const attach = vi.fn(() => { + attached = true; + }); + const detach = vi.fn(() => { + attached = false; + }); + const sendCommand = vi.fn(() => new Promise(() => undefined)); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + id: 42, + debugger: { + isAttached: () => attached, + attach, + detach, + sendCommand, + }, + }), + ); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme_timeout"); + yield* manager.registerWebview("tab_scheme_timeout", 42); + yield* Effect.yieldNow; + + const mutation = yield* manager + .setColorScheme("tab_scheme_timeout", "dark", 100) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + yield* TestClock.adjust(100); + const result = yield* Effect.exit(Fiber.join(mutation)); + + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Option.getOrThrow(Cause.findErrorOption(result.cause))).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + operation: "set-color-scheme", + tabId: "tab_scheme_timeout", + timeoutMs: 100, + }); + } + expect(states.at(-1)?.colorScheme).toBe("system"); + expect(detach).toHaveBeenCalledOnce(); + }), + ), + ); + + effectIt.effect("serializes bounded and unbounded color-scheme mutations", () => + withManager((manager) => + Effect.gen(function* () { + let attached = false; + let darkCommands = 0; + let finishBoundedDark: (() => void) | undefined; + const sendCommand = vi.fn( + async (method: string, parameters?: { features?: ReadonlyArray<{ value: string }> }) => { + if (method !== "Emulation.setEmulatedMedia") return undefined; + if (parameters?.features?.[0]?.value !== "dark") return undefined; + darkCommands += 1; + if (darkCommands !== 2) return undefined; + await new Promise((resolve) => { + finishBoundedDark = resolve; + }); + return undefined; + }, + ); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + id: 42, + debugger: { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: vi.fn(() => { + attached = false; + }), + sendCommand, + }, + }), + ); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme_reread"); + yield* manager.registerWebview("tab_scheme_reread", 42); + yield* Effect.yieldNow; + yield* manager.setColorScheme("tab_scheme_reread", "dark"); + + const boundedDark = yield* manager + .setColorScheme("tab_scheme_reread", "dark", 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + const unboundedLight = yield* manager + .setColorScheme("tab_scheme_reread", "light") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(states.at(-1)?.colorScheme).toBe("dark"); + expect( + sendCommand.mock.calls.some( + ([method, parameters]) => + method === "Emulation.setEmulatedMedia" && + parameters?.features?.[0]?.value === "light", + ), + ).toBe(false); + + finishBoundedDark?.(); + yield* Fiber.join(boundedDark); + yield* Fiber.join(unboundedLight); + + expect(states.at(-1)?.colorScheme).toBe("light"); + }), + ), + ); + + effectIt.effect("retries a bounded color scheme when replacement rejects the old command", () => + withManager((manager) => + Effect.gen(function* () { + let rejectFirstGuest: ((cause: unknown) => void) | undefined; + const firstSendCommand = vi.fn( + async (method: string, parameters?: { features?: ReadonlyArray<{ value: string }> }) => { + if ( + method === "Emulation.setEmulatedMedia" && + parameters?.features?.[0]?.value === "dark" + ) { + await new Promise((_resolve, reject) => { + rejectFirstGuest = reject; + }); + } + return undefined; + }, + ); + const replacementSendCommand = vi.fn(async () => undefined); + const makeWebContents = ( + id: number, + sendCommand: typeof firstSendCommand | typeof replacementSendCommand, + ) => { + let attached = false; + return makeTestPreviewWebContents({ + id, + debugger: { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: vi.fn(() => { + attached = false; + if (id === 42) rejectFirstGuest?.(new Error("old guest detached")); + }), + sendCommand, + }, + }); + }; + const first = makeWebContents(42, firstSendCommand); + const replacement = makeWebContents(43, replacementSendCommand); + fromId.mockImplementation((id) => (id === 42 ? first : id === 43 ? replacement : null)); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme_replacement"); + yield* manager.registerWebview("tab_scheme_replacement", 42); + yield* Effect.yieldNow; + + const mutation = yield* manager + .setColorScheme("tab_scheme_replacement", "dark", 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + yield* manager.registerWebview("tab_scheme_replacement", 43); + yield* Effect.yieldNow; + yield* Fiber.join(mutation); + + expect(replacementSendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "dark" }], }); expect(states.at(-1)?.colorScheme).toBe("dark"); + }), + ), + ); - yield* manager.setColorScheme("tab_scheme", "system"); + effectIt.effect("uses an idempotent artifact path for recording save retries", () => + withManager((manager) => + Effect.gen(function* () { + const data = new Uint8Array([1, 2, 3]); + const first = yield* manager.saveRecording( + "tab_recording_save", + "video/webm", + data, + "8edc2f33-7bb4-4a30-97e8-e78f1d84513a", + ); + const retry = yield* manager.saveRecording( + "tab_recording_save", + "video/webm", + data, + "8edc2f33-7bb4-4a30-97e8-e78f1d84513a", + ); - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "" }], - }); - expect(states.at(-1)?.colorScheme).toBe("system"); + expect(retry.id).toBe(first.id); + expect(retry.path).toBe(first.path); + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile.mock.calls[1]?.[0]).toBe(writeFile.mock.calls[0]?.[0]); }), ), ); const makeAudioWebContents = (id: number) => { const listeners = new Map void>(); + const attach = vi.fn(); const setAudioMuted = vi.fn(); let audible = false; let audibleAfterFirstRead = false; let audibleReads = 0; return { + attach, setAudioMuted, emitAudioState: (next: boolean) => { audible = next; @@ -1724,7 +2488,7 @@ describe("PreviewManager", () => { setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, - attach: vi.fn(), + attach, sendCommand: vi.fn(async () => undefined), on: vi.fn(), off: vi.fn(), @@ -1750,6 +2514,7 @@ describe("PreviewManager", () => { yield* Effect.yieldNow; expect(states.at(-1)?.audioMuted).toBe(false); + expect(first.attach).not.toHaveBeenCalled(); yield* manager.setAudioMuted("tab_audio", true); @@ -1763,6 +2528,7 @@ describe("PreviewManager", () => { expect(replacement.setAudioMuted).toHaveBeenCalledWith(true); expect(states.at(-1)?.audioMuted).toBe(true); + expect(replacement.attach).not.toHaveBeenCalled(); yield* manager.setAudioMuted("tab_audio", false); @@ -1963,8 +2729,8 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("close-race-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - const firstWebContents = makeTestPreviewWebContents(capturePage, 42); - const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); + const firstWebContents = makeTestPreviewWebContents({ capturePage, id: 42 }); + const replacementWebContents = makeTestPreviewWebContents({ capturePage, id: 43 }); const replacementListenerSpies = replacementWebContents as unknown as { readonly on: ReturnType; readonly off: ReturnType; @@ -2223,6 +2989,342 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("restores captures when a guest re-registers after replacement fails", () => + withManager((manager) => + Effect.gen(function* () { + const png = Buffer.from("re-registered-preview"); + const capturePage = vi.fn(async () => ({ + toPNG: () => png, + getSize: () => ({ width: 640, height: 360 }), + })); + const original = makeTestPreviewWebContents({ id: 42, capturePage }); + const replacement = Object.assign(makeTestPreviewWebContents({ id: 43 }), { + setZoomFactor: () => { + throw new Error("replacement is unavailable"); + }, + }); + fromId.mockImplementation((id) => (id === 42 ? original : replacement)); + yield* manager.createTab("tab_reregister_capture"); + yield* manager.registerWebview("tab_reregister_capture", 42); + yield* manager.captureScreenshot("tab_reregister_capture"); + + const failed = yield* Effect.exit(manager.registerWebview("tab_reregister_capture", 43)); + expect(Exit.isFailure(failed)).toBe(true); + yield* manager.registerWebview("tab_reregister_capture", 42); + + expect(yield* manager.captureScreenshot("tab_reregister_capture")).toMatchObject({ + sizeBytes: png.byteLength, + }); + expect(capturePage).toHaveBeenCalledTimes(2); + }), + ), + ); + + for (const interruption of ["none", "before", "after"] as const) { + const interrupt = interruption !== "none"; + effectIt.effect( + `preserves capture ownership with interruption ${interruption} re-registration`, + () => + withManager((manager) => + Effect.gen(function* () { + const image = { + toPNG: vi.fn(() => Buffer.from("current-preview")), + getSize: () => ({ width: 640, height: 360 }), + }; + let resolveCapture!: (value: typeof image) => void; + const pendingCapture = new Promise((resolve) => { + resolveCapture = resolve; + }); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const capturePage = vi + .fn(async () => image) + .mockImplementationOnce(() => { + markStarted(); + return pendingCapture; + }); + const original = makeTestPreviewWebContents({ id: 42, capturePage }); + const replacement = Object.assign(makeTestPreviewWebContents({ id: 43 }), { + setZoomFactor: () => { + throw new Error("replacement is unavailable"); + }, + }); + fromId.mockImplementation((id) => (id === 42 ? original : replacement)); + yield* manager.createTab("tab_pending_reregister"); + yield* manager.registerWebview("tab_pending_reregister", 42); + const oldCapture = yield* manager + .captureScreenshot("tab_pending_reregister") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started); + if (interruption === "before") yield* Fiber.interrupt(oldCapture); + + expect( + Exit.isFailure( + yield* Effect.exit(manager.registerWebview("tab_pending_reregister", 43)), + ), + ).toBe(true); + yield* manager.registerWebview("tab_pending_reregister", 42); + if (interruption === "after") yield* Fiber.interrupt(oldCapture); + + const newCapture = yield* manager + .captureScreenshot("tab_pending_reregister") + .pipe(Effect.forkChild({ startImmediately: true })); + if (interrupt) { + const blocked = yield* Fiber.await(newCapture); + expect(Exit.isFailure(blocked)).toBe(true); + if (Exit.isFailure(blocked)) { + expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ + cause: { _tag: "PreviewCaptureGateClosedError" }, + }); + } + } + expect(capturePage).toHaveBeenCalledOnce(); + expect(image.toPNG).not.toHaveBeenCalled(); + resolveCapture(image); + expect(Exit.isFailure(yield* Fiber.await(oldCapture))).toBe(true); + if (interrupt) { + yield* Effect.promise( + () => new Promise((resolve) => globalThis.setImmediate(resolve)), + ); + yield* manager.captureScreenshot("tab_pending_reregister"); + } else { + yield* Fiber.join(newCapture); + } + expect(capturePage).toHaveBeenCalledTimes(2); + expect(image.toPNG).toHaveBeenCalledOnce(); + }), + ), + ); + } + + effectIt.effect("rejects an in-flight capture after an exact guest replacement", () => + withManager((manager) => + Effect.gen(function* () { + const staleImage = { + getSize: () => ({ width: 640, height: 360 }), + toPNG: vi.fn(() => Buffer.from("stale-preview-png")), + }; + const replacementImage = { + getSize: () => ({ width: 640, height: 360 }), + toPNG: vi.fn(() => Buffer.from("replacement-preview-png")), + }; + let resolveStaleCapture: ((image: typeof staleImage) => void) | undefined; + const staleCapture = new Promise((resolve) => { + resolveStaleCapture = resolve; + }); + const staleCapturePage = vi.fn(() => staleCapture); + const replacementCapturePage = vi.fn(async () => replacementImage); + const staleWebContents = makeTestPreviewWebContents({ + capturePage: staleCapturePage, + id: 42, + }); + const replacementWebContents = makeTestPreviewWebContents({ + capturePage: replacementCapturePage, + id: 42, + }); + let currentWebContents = staleWebContents; + fromId.mockImplementation(() => currentWebContents); + + yield* manager.createTab("tab_replaced_capture"); + yield* manager.registerWebview("tab_replaced_capture", 42); + const capture = yield* manager + .captureScreenshot("tab_replaced_capture") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* settle(() => staleCapturePage.mock.calls.length === 1); + + currentWebContents = replacementWebContents; + yield* manager.registerWebview("tab_replaced_capture", 42); + resolveStaleCapture?.(staleImage); + + const staleExit = yield* Fiber.await(capture); + expect(Exit.isFailure(staleExit)).toBe(true); + if (Exit.isFailure(staleExit)) { + expect(Option.getOrThrow(Cause.findErrorOption(staleExit.cause))).toMatchObject({ + _tag: "PreviewOperationError", + operation: "captureScreenshot.capturePage", + tabId: "tab_replaced_capture", + webContentsId: 42, + }); + } + expect(staleImage.toPNG).not.toHaveBeenCalled(); + + const recovered = yield* manager.captureScreenshot("tab_replaced_capture"); + expect(recovered.sizeBytes).toBe(Buffer.byteLength("replacement-preview-png")); + expect(replacementCapturePage).toHaveBeenCalledOnce(); + expect(replacementImage.toPNG).toHaveBeenCalledOnce(); + }), + ), + ); + + effectIt.effect("bounds a paused host frame without losing semantic automation", () => + withManager((manager) => + Effect.gen(function* () { + let hostThrottled = true; + const host = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling: (value: boolean) => (hostThrottled = value), + executeJavaScript: vi.fn(() => new Promise(() => undefined)), + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: host, + } as never); + const capturePage = vi.fn(); + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { result: { value: { title: "Semantic result", interactiveElements: [] } } }; + } + return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined; + }); + fromId.mockReturnValue( + makeTestPreviewWebContents({ + hostWebContents: host, + capturePage, + debugger: { sendCommand }, + }), + ); + yield* manager.createTab("tab_paused_host"); + yield* manager.registerWebview("tab_paused_host", 42); + const snapshot = yield* manager + .automationSnapshot("tab_paused_host", true, 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(750); + expect(yield* Fiber.join(snapshot)).toMatchObject({ + title: "Semantic result", + screenshot: null, + screenshotUnavailable: { stage: "interrupted" }, + }); + expect(capturePage).not.toHaveBeenCalled(); + expect(hostThrottled).toBe(true); + expect( + yield* manager.automationEvaluate("tab_paused_host", { expression: "document.title" }), + ).toMatchObject({ title: "Semantic result" }); + + const click = yield* manager + .automationClick("tab_paused_host", { x: 20, y: 20, timeoutMs: 1_000 }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(750); + const clickResult = yield* Fiber.join(click); + expect(Exit.isFailure(clickResult)).toBe(true); + expect( + sendCommand.mock.calls.some(([method]) => method === "Input.dispatchMouseEvent"), + ).toBe(false); + expect(hostThrottled).toBe(true); + }), + ), + ); + + effectIt.effect("keeps the host rendering until concurrent automation has finished", () => + withManager((manager) => + Effect.gen(function* () { + let hostThrottled = true; + const host = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling: (value: boolean) => (hostThrottled = value), + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: host, + } as never); + const firstStarted = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const firstResult = Promise.withResolvers(); + const secondResult = Promise.withResolvers(); + const guests = [firstResult, secondResult].map((result, index) => + makeTestPreviewWebContents({ + id: 41 + index, + hostWebContents: host, + debugger: { + sendCommand: vi.fn(async (method: string) => { + if (method !== "Runtime.evaluate") return undefined; + expect(hostThrottled).toBe(false); + (index === 0 ? firstStarted : secondStarted).resolve(); + return { result: { value: await result.promise } }; + }), + }, + }), + ); + fromId.mockImplementation((id) => guests.find((guest) => guest.id === id) ?? null); + for (const guest of guests) { + yield* manager.createTab(`tab_${guest.id}`); + yield* manager.registerWebview(`tab_${guest.id}`, guest.id); + } + const first = yield* manager + .automationEvaluate("tab_41", { expression: "document.title" }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => firstStarted.promise); + const second = yield* manager + .automationEvaluate("tab_42", { expression: "document.title" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => secondStarted.promise); + firstResult.reject(new Error("Guest closed during evaluation")); + expect(Exit.isFailure(yield* Fiber.join(first))).toBe(true); + expect(hostThrottled).toBe(false); + secondResult.resolve("finished"); + expect(yield* Fiber.join(second)).toBe("finished"); + expect(hostThrottled).toBe(true); + }), + ), + ); + + effectIt.effect( + "coordinates automation rendering with recording in either completion order", + () => + withManager((manager) => + Effect.gen(function* () { + let hostThrottled = true; + const host = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling: (value: boolean) => (hostThrottled = value), + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: host, + } as never); + let started = Promise.withResolvers(); + let result = Promise.withResolvers(); + const guest = makeTestPreviewWebContents({ + hostWebContents: host, + debugger: { + sendCommand: vi.fn(async (method: string) => { + if (method !== "Runtime.evaluate") return undefined; + started.resolve(); + return { result: { value: await result.promise } }; + }), + }, + }); + fromId.mockReturnValue(guest); + yield* manager.createTab("tab_rendering"); + yield* manager.registerWebview("tab_rendering", 42); + + for (const recordingFinishesFirst of [true, false]) { + started = Promise.withResolvers(); + result = Promise.withResolvers(); + const evaluation = yield* manager + .automationEvaluate("tab_rendering", { expression: "document.title" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* manager.startRecording("tab_rendering"); + host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); + if (recordingFinishesFirst) { + yield* manager.stopRecording("tab_rendering"); + expect(hostThrottled).toBe(false); + result.resolve("finished"); + yield* Fiber.join(evaluation); + } else { + result.resolve("finished"); + yield* Fiber.join(evaluation); + expect(hostThrottled).toBe(false); + yield* manager.stopRecording("tab_rendering"); + } + expect(hostThrottled).toBe(true); + } + }), + ), + ); + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { @@ -2272,6 +3374,120 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "blocks overlapping capture while timed-out recording warmup remains pending", + () => + withManager((manager) => + Effect.gen(function* () { + type CapturedImage = TestCapturedPreviewImage & { + readonly isEmpty: () => boolean; + readonly toPNG: () => Buffer; + }; + const png = Buffer.from("recording-frame"); + const image: CapturedImage = { + isEmpty: () => false, + toJPEG: () => png, + toPNG: () => png, + getSize: () => ({ width: 1280, height: 720 }), + }; + let resolveWarmup!: (image: CapturedImage) => void; + const capturePage = vi.fn(async () => image); + capturePage.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWarmup = resolve; + }), + ); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); + + yield* manager.createTab("tab_recording_start_cleanup_timeout"); + yield* manager.registerWebview("tab_recording_start_cleanup_timeout", 42); + + const start = yield* manager + .startRecording("tab_recording_start_cleanup_timeout", undefined, 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledOnce(); + + yield* TestClock.adjust(650); + yield* TestClock.adjust(250); + const exit = yield* Fiber.await(start); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + operation: "start-recording", + tabId: "tab_recording_start_cleanup_timeout", + timeoutMs: 1_000, + }); + } + expect(capturePage).toHaveBeenCalledOnce(); + + const blockedCapture = yield* Effect.exit( + manager.captureScreenshot("tab_recording_start_cleanup_timeout"), + ); + expect(Exit.isFailure(blockedCapture)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + + resolveWarmup(image); + yield* Effect.promise( + () => new Promise((resolve) => globalThis.setImmediate(resolve)), + ); + const recovered = yield* manager.captureScreenshot("tab_recording_start_cleanup_timeout"); + expect(recovered.sizeBytes).toBe(png.byteLength); + expect(capturePage).toHaveBeenCalledTimes(2); + }), + ), + ); + + effectIt.effect("bounds recording stop while a recording start holds the lifecycle lock", () => + withManager((manager) => + Effect.gen(function* () { + let markMeasurementStarted!: () => void; + const measurementStarted = new Promise((resolve) => { + markMeasurementStarted = resolve; + }); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const hostWebContents = makeTestHostWebContents(); + hostWebContents.executeJavaScript.mockImplementation(() => { + markMeasurementStarted(); + return new Promise(() => undefined); + }); + const webContents = makeTestPreviewWebContents({ capturePage, hostWebContents }); + fromId.mockReturnValue(webContents); + + yield* manager.createTab("tab_recording_stop_cleanup_timeout"); + yield* manager.registerWebview("tab_recording_stop_cleanup_timeout", 42); + const start = yield* manager + .startRecording("tab_recording_stop_cleanup_timeout") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => measurementStarted); + + const stop = yield* manager + .stopRecording("tab_recording_stop_cleanup_timeout", 1_000) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(750); + const exit = yield* Fiber.await(stop); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + operation: "stop-recording", + tabId: "tab_recording_stop_cleanup_timeout", + timeoutMs: 1_000, + }); + } + + yield* Fiber.interrupt(start); + }), + ), + ); + effectIt.effect("does not commit failed starts and retries throttle restoration", () => withManager((manager) => Effect.gen(function* () { @@ -2280,7 +3496,7 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); yield* manager.createTab("tab_capture_throttling_failure"); yield* manager.registerWebview("tab_capture_throttling_failure", 42); @@ -2373,7 +3589,7 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); yield* manager.createTab("tab_capture_replacement_failure"); yield* manager.registerWebview("tab_capture_replacement_failure", 42); @@ -2404,7 +3620,7 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); yield* manager.createTab("tab_replaced_window_close"); yield* manager.registerWebview("tab_replaced_window_close", 42); @@ -2574,6 +3790,44 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("stops capture retries when the guest re-registers during the retry delay", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toPNG: () => Buffer.from("current-preview"), + getSize: () => ({ width: 640, height: 360 }), + })); + const original = makeTestPreviewWebContents({ id: 42, capturePage }); + const replacement = Object.assign(makeTestPreviewWebContents({ id: 43 }), { + setZoomFactor: () => { + throw new Error("replacement is unavailable"); + }, + }); + fromId.mockImplementation((id) => (id === 42 ? original : replacement)); + yield* manager.createTab("tab_retry_reregister"); + yield* manager.registerWebview("tab_retry_reregister", 42); + + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const capture = yield* manager + .captureScreenshot("tab_retry_reregister") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(60); + expect(capturePage).toHaveBeenCalledOnce(); + expect( + Exit.isFailure(yield* Effect.exit(manager.registerWebview("tab_retry_reregister", 43))), + ).toBe(true); + yield* manager.registerWebview("tab_retry_reregister", 42); + yield* TestClock.adjust(120); + + expect(Exit.isFailure(yield* Fiber.await(capture))).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + yield* manager.captureScreenshot("tab_retry_reregister"); + expect(capturePage).toHaveBeenCalledTimes(2); + }), + ), + ); + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { @@ -2636,7 +3890,7 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); - const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe( + const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1", false, 1_000)).pipe( Effect.forkChild({ startImmediately: true }), ); yield* TestClock.adjust(100); @@ -2648,17 +3902,10 @@ describe("PreviewManager", () => { yield* TestClock.adjust(4_000); const exit = yield* Fiber.join(snapshot); - expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(3); - if (Exit.isSuccess(exit)) return; - const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ - _tag: "PreviewOperationError", - operation: "automationSnapshot.capturePage", - tabId: "tab_1", - webContentsId: 42, - cause: { _tag: "TimeoutError" }, - }); + expect(Exit.isSuccess(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + if (Exit.isFailure(exit)) return; + expect(exit.value.screenshot).toBeNull(); expect(yield* Fiber.join(evaluate)).toBe(42); }), ), @@ -3447,7 +4694,7 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("empty-preview-frame"), getSize: () => ({ width: 0, height: 0 }), }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); browserWindowConstructor.mockImplementation(function () { return pictureInPictureWindow; @@ -3477,7 +4724,7 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("closing-preview-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); pictureInPictureWindow.showInactive.mockImplementationOnce(() => { pictureInPictureWindow.close(); @@ -3511,7 +4758,7 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("serialized-preview-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + fromId.mockReturnValue(makeTestPreviewWebContents({ capturePage })); const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow( () => new Promise(() => { @@ -3593,8 +4840,14 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("replacement-preview-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - const initialWebContents = makeTestPreviewWebContents(initialCapturePage, 42); - const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); + const initialWebContents = makeTestPreviewWebContents({ + capturePage: initialCapturePage, + id: 42, + }); + const replacementWebContents = makeTestPreviewWebContents({ + capturePage: replacementCapturePage, + id: 43, + }); fromId.mockImplementation((webContentsId?: number) => { if (webContentsId === 42) return initialWebContents; if (webContentsId === 43) return replacementWebContents; @@ -3867,10 +5120,10 @@ describe("PreviewManager", () => { expect(secondPick.pollUnsafe()).toBeUndefined(); onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); - yield* TestClock.adjust("6 seconds"); const result = yield* Fiber.join(secondPick); expect(result?.annotation.id).toBe("annotation_2"); expect(result?.submission).toBe("attach"); + expect(result?.screenshotFailed).toBe(true); }), ), ); @@ -4638,3 +5891,88 @@ describe("Preview automation diagnostics", () => { expect("locator" in error).toBe(false); }); }); + +describe("automation display wake", () => { + beforeEach(() => { + powerSaveBlockerStart.mockClear(); + powerSaveBlockerStop.mockClear(); + powerSaveBlockerIsStarted.mockClear(); + powerSaveBlockerIsStarted.mockReturnValue(true); + }); + + effectIt.effect("starts one display-sleep block on the first automation request", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.createTab("tab_wake"); + // Creating and registering a tab is what a human does; no block yet. + expect(powerSaveBlockerStart).not.toHaveBeenCalled(); + + const first = yield* manager.automationStatus("tab_wake"); + expect(first.displaySleepBlocked).toBe(true); + expect(powerSaveBlockerStart).toHaveBeenCalledExactlyOnceWith("prevent-display-sleep"); + + const second = yield* manager.automationStatus("tab_wake"); + expect(second.displaySleepBlocked).toBe(true); + expect(powerSaveBlockerStart).toHaveBeenCalledOnce(); + expect(powerSaveBlockerStop).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("refreshes on activity and releases after the inactivity window", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.createTab("tab_wake"); + yield* manager.automationStatus("tab_wake"); + yield* TestClock.adjust(PreviewManager.AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS - 1_000); + yield* manager.automationStatus("tab_wake"); + yield* TestClock.adjust(PreviewManager.AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS - 1_000); + expect(powerSaveBlockerStop).not.toHaveBeenCalled(); + expect((yield* manager.automationStatus("tab_wake")).displaySleepBlocked).toBe(true); + + yield* TestClock.adjust(PreviewManager.AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS); + expect(powerSaveBlockerStop).toHaveBeenCalledExactlyOnceWith(7); + expect(powerSaveBlockerStart).toHaveBeenCalledOnce(); + + // The next request starts a fresh block. + expect((yield* manager.automationStatus("tab_wake")).displaySleepBlocked).toBe(true); + expect(powerSaveBlockerStart).toHaveBeenCalledTimes(2); + }), + ), + ); + + effectIt.effect("releases when the last automation tab closes, not before", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.createTab("tab_a"); + yield* manager.createTab("tab_b"); + yield* manager.createTab("tab_human"); + yield* manager.automationStatus("tab_a"); + yield* manager.automationStatus("tab_b"); + + yield* manager.closeTab("tab_human"); + yield* manager.closeTab("tab_a"); + expect(powerSaveBlockerStop).not.toHaveBeenCalled(); + + yield* manager.closeTab("tab_b"); + expect(powerSaveBlockerStop).toHaveBeenCalledExactlyOnceWith(7); + // The inactivity watcher was retired with the block; time passing does nothing more. + yield* TestClock.adjust(PreviewManager.AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS * 2); + expect(powerSaveBlockerStop).toHaveBeenCalledOnce(); + }), + ), + ); + + effectIt.effect("releases on manager shutdown", () => + Effect.gen(function* () { + yield* withManager((manager) => + Effect.gen(function* () { + yield* manager.createTab("tab_wake"); + yield* manager.automationStatus("tab_wake"); + expect(powerSaveBlockerStop).not.toHaveBeenCalled(); + }), + ); + expect(powerSaveBlockerStop).toHaveBeenCalledExactlyOnceWith(7); + }), + ); +}); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b97af6f05204..6fdf1a51fe30 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -43,6 +43,7 @@ import { type Session, clipboard, nativeImage, + powerSaveBlocker, shell, webContents, } from "electron"; @@ -139,6 +140,26 @@ const MAX_INTERACTIVE_ELEMENTS = 200; */ const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const DEFAULT_AUTOMATION_TIMEOUT_MS = 15_000; +const AUTOMATION_TIMEOUT_RESPONSE_GRACE_MS = 250; +const AUTOMATION_SCREENSHOT_TIMEOUT_MS = 5_000; +const AUTOMATION_BACKGROUND_SCREENSHOT_TIMEOUT_MS = 3_000; +const AUTOMATION_SCREENSHOT_SETTLEMENT_GRACE_MS = 25; +/** + * How long the display-sleep block outlives the last automation request while + * an automation tab still exists. Long enough to span an agent's think time + * between browser calls, short enough that a forgotten tab does not keep the + * display on all night. + */ +export const AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS = 5 * 60_000; +export const automationExecutionBudget = (timeoutMs: number): number => + Math.min( + timeoutMs, + Math.max( + AUTOMATION_TIMEOUT_RESPONSE_GRACE_MS * 2, + timeoutMs - AUTOMATION_TIMEOUT_RESPONSE_GRACE_MS, + ), + ); /** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); @@ -355,30 +376,9 @@ const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, + capture: Effect.Effect, ): Effect.Effect => - Effect.tryPromise({ - // The unused abort signal is what makes this interruptible, and therefore - // what lets the timeout below fire. Drop the parameter and a stalled - // capture strands the pick session again. - try: (_signal) => - wc.capturePage( - cropRect - ? { - x: cropRect.x, - y: cropRect.y, - width: cropRect.width, - height: cropRect.height, - } - : undefined, - ), - catch: (cause) => - new PreviewOperationError({ - operation: "captureAnnotationScreenshot", - tabId, - webContentsId: wc.id, - cause, - }), - }).pipe( + capture.pipe( Effect.map((image): PreviewAnnotationPayload["screenshot"] => { const size = image.getSize(); return { @@ -490,6 +490,20 @@ interface BrowserControlSession { ) => void; } +interface BrowserCaptureQueue { + tail: Promise; + retired: boolean; + /** + * A capture whose caller already timed out may still be running inside + * Electron. New callers must fail fast until that exact queue tail settles; + * waiting behind it would spend every later deadline on the same wedged + * native operation. + */ + unavailableUntil: Promise | null; +} + +const STALE_BROWSER_CONTROL_SESSION = Symbol("StaleBrowserControlSession"); + interface BrowserDiagnostics { readonly consoleEntries: ReadonlyArray; readonly networkEntries: ReadonlyArray; @@ -661,6 +675,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); + const captureQueues = new WeakMap(); const diagnosticsRef = yield* Ref.make>(new Map()); const expectedAgentInputsRef = yield* Ref.make< ReadonlyMap> @@ -671,6 +686,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function >(new Map()); const actionSequenceRef = yield* Ref.make(0); const pointerSequenceRef = yield* Ref.make(0); + // Sustained recording/PiP capture sessions only. One-shot automation + // snapshots use their renderer presentation lease and never enter this map. const frameCaptureSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); @@ -686,6 +703,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function let pendingRecording: PendingRecording | null = null; const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; + // Guarded by frameCaptureSessionsRef so a recording ending cannot suspend + // the compositor while a one-shot automation operation still needs it. + let automationRenderingUsers = 0; let currentMainWindow: BrowserWindow | undefined; let mainWindowCleanupFiber: Fiber.Fiber | undefined; const tabLifecycleLocks = new Map< @@ -693,6 +713,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { readonly semaphore: Semaphore.Semaphore; users: number } >(); const tabLifecycleGenerations = new Map(); + const colorSchemeMutationLocks = new Map< + string, + { readonly semaphore: Semaphore.Semaphore; users: number } + >(); const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ @@ -707,25 +731,99 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function try: evaluate, catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }); + const capturePage = ( + errorContext: PreviewOperationContext, + wc: Electron.WebContents, + options: { + readonly rect?: Electron.Rectangle; + readonly stayHidden?: boolean; + } = {}, + ): Effect.Effect => + Effect.tryPromise({ + try: (signal) => { + const queue = captureQueues.get(wc) ?? { + tail: Promise.resolve(), + retired: false, + unavailableUntil: null, + }; + captureQueues.set(wc, queue); + if (queue.retired) { + throw new Error("Preview capture target is no longer active"); + } + if (queue.unavailableUntil !== null) { + throw new PreviewCaptureGateClosedError({ webContentsId: wc.id }); + } + const capture = queue.tail.then(() => { + if (signal.aborted) throw signal.reason; + if (queue.retired || wc.isDestroyed() || webContents.fromId(wc.id) !== wc) { + throw new Error("Preview capture target is no longer active"); + } + const nativeCapture = + options.stayHidden === undefined + ? wc.capturePage(options.rect) + : wc.capturePage(options.rect, { stayHidden: options.stayHidden }); + return nativeCapture.then((image) => { + if (queue.retired || wc.isDestroyed() || webContents.fromId(wc.id) !== wc) { + throw new Error("Preview capture target is no longer active"); + } + return image; + }); + }); + // Effect interruption aborts only this caller. Electron's native + // capture does not observe that signal, so retain the exact guest's + // gate until the underlying promise settles. + const settledTail = capture.then( + () => undefined, + () => undefined, + ); + queue.tail = settledTail; + const markUnavailable = () => { + const currentQueue = captureQueues.get(wc); + if (currentQueue && currentQueue.unavailableUntil === null) { + currentQueue.unavailableUntil = settledTail; + } + }; + signal.addEventListener("abort", markUnavailable, { once: true }); + void settledTail.then(() => { + signal.removeEventListener("abort", markUnavailable); + const currentQueue = captureQueues.get(wc); + if (currentQueue?.unavailableUntil === settledTail) { + currentQueue.unavailableUntil = null; + } + }); + return capture; + }, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); + const retireCaptureQueue = (wc: Electron.WebContents): void => { + const queue = captureQueues.get(wc) ?? { + tail: Promise.resolve(), + retired: false, + unavailableUntil: null, + }; + queue.retired = true; + captureQueues.set(wc, queue); + }; const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* ( errorContext: PreviewOperationContext, tabId: string, wc: Electron.WebContents, ) { + const captureQueue = captureQueues.get(wc); const requireCurrentGuest = Effect.gen(function* () { const tabs = yield* SynchronizedRef.get(tabsRef); - if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) { + if ( + wc.isDestroyed() || + tabs.get(tabId)?.webContentsId !== wc.id || + captureQueues.get(wc) !== captureQueue + ) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id }); } }); const capture = Effect.gen(function* () { // Check after the retry delay, and again before accepting its result. yield* requireCurrentGuest; - const image = yield* Effect.tryPromise({ - // An abort-signal parameter makes a stalled promise interruptible. - try: (_signal) => wc.capturePage(), - catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), - }).pipe( + const image = yield* capturePage(errorContext, wc).pipe( Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), Effect.catchTags({ TimeoutError: (cause) => @@ -739,7 +837,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.retry({ times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1, schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS), - while: isPreviewOperationError, + while: (error) => { + const queue = captureQueues.get(wc); + return ( + isPreviewOperationError(error) && + !wc.isDestroyed() && + webContents.fromId(wc.id) === wc && + queue === captureQueue && + !queue?.retired && + queue?.unavailableUntil == null + ); + }, }), ); }); @@ -781,6 +889,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); }); + const withColorSchemeMutationLock = ( + tabId: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend(() => { + const mutation = colorSchemeMutationLocks.get(tabId) ?? { + semaphore: Semaphore.makeUnsafe(1), + users: 0, + }; + mutation.users += 1; + colorSchemeMutationLocks.set(tabId, mutation); + return mutation.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + mutation.users -= 1; + if (mutation.users === 0 && colorSchemeMutationLocks.get(tabId) === mutation) { + colorSchemeMutationLocks.delete(tabId); + } + }), + ), + ); + }); const setWindowBackgroundThrottling = Effect.fnUntraced(function* ( window: BrowserWindow, enabled: boolean, @@ -791,10 +921,51 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); const setFrameCaptureBackgroundThrottling = Effect.fnUntraced(function* (enabled: boolean) { + if (enabled && automationRenderingUsers > 0) return; const mainWindow = yield* Ref.get(mainWindowRef); if (Option.isNone(mainWindow)) return; yield* setWindowBackgroundThrottling(mainWindow.value, enabled); }); + const withAutomationRendering = (effect: Effect.Effect) => + Effect.acquireUseRelease( + SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => + Effect.gen(function* () { + if (automationRenderingUsers === 0 && sessions.size === 0) { + yield* setFrameCaptureBackgroundThrottling(false); + } + automationRenderingUsers += 1; + return [undefined, sessions] as const; + }), + ), + () => effect, + () => + SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => + Effect.gen(function* () { + automationRenderingUsers -= 1; + if (automationRenderingUsers === 0 && sessions.size === 0) { + yield* setFrameCaptureBackgroundThrottling(true).pipe( + Effect.retry({ times: 2 }), + Effect.catch((error) => + Effect.logWarning("Failed to restore preview automation throttling.", { error }), + ), + ); + } + return [undefined, sessions] as const; + }), + ), + ); + const waitForAutomationHostFrame = Effect.gen(function* () { + const mainWindow = yield* Ref.get(mainWindowRef); + if (Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) return; + // Changing throttling is asynchronous in Chromium. Let the host commit its + // staged guest geometry before capturing or hit testing. Callers own the + // deadline; semantic evaluation must remain usable if no frame arrives. + yield* attemptPromise({ operation: "automation.prepareHostFrame" }, () => + mainWindow.value.webContents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))", + ), + ); + }); const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( wc: Electron.WebContents, enabled: boolean, @@ -971,13 +1142,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - const update = Effect.fn("PreviewManager.update")(function* ( + const commitUpdate = Effect.fn("PreviewManager.commitUpdate")(function* ( tabId: string, patch: Partial, - humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; - const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + return yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if (!current) return [Option.none(), tabs] as const; const state: PreviewTabState = { ...current, ...patch, updatedAt }; @@ -988,26 +1158,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); + }); + + const notifyRecordingController = Effect.fn("PreviewManager.notifyRecordingController")( + function* ( + tabId: string, + next: PreviewTabState, + patch: Partial, + humanPoint?: { readonly x: number; readonly y: number }, + ) { + if (patch.controller === undefined || next.webContentsId == null) return; + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + const webContentsId = next.webContentsId; + if (capture?.consumers.has("recording")) { + yield* attempt({ operation: "recording.controller", tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); + }).pipe(Effect.ignore); + } + }, + ); + + const update = Effect.fn("PreviewManager.update")(function* ( + tabId: string, + patch: Partial, + humanPoint?: { readonly x: number; readonly y: number }, + ) { + const next = yield* commitUpdate(tabId, patch); // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible // can commit between the modify above and here, and republishing this // snapshot would roll the UI back to a value that writer will not send // again because it suppresses unchanged audibility. if (Option.isSome(next)) { - if (patch.controller !== undefined && next.value.webContentsId != null) { - const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); - const webContentsId = next.value.webContentsId; - if (capture?.consumers.has("recording")) { - yield* attempt({ operation: "recording.controller", tabId }, () => { - const contents = webContents.fromId(webContentsId); - if (contents && !contents.isDestroyed()) - contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); - }).pipe(Effect.ignore); - } - } + yield* notifyRecordingController(tabId, next.value, patch, humanPoint); yield* emitIfCurrent(tabId, next.value); } }); + const updateWithoutWaitingForListeners = Effect.fn( + "PreviewManager.updateWithoutWaitingForListeners", + )(function* (tabId: string, patch: Partial) { + const next = yield* commitUpdate(tabId, patch); + if (Option.isSome(next)) { + yield* notifyRecordingController(tabId, next.value, patch); + yield* Effect.forkIn(emitIfCurrent(tabId, next.value), parentScope); + } + }); + /** * Pushes a tab's zoom factor onto whichever guest it currently owns, reading * both at call time. Anything that applies zoom after an await goes through @@ -1265,17 +1463,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( webContentsId: number, + expectedControl?: BrowserControlSession, ) { - const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ - sessions.get(webContentsId), - replaceMap(sessions, (copy) => { - copy.delete(webContentsId); - }), - ]); - if (control) { - yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); - return; - } + const detached = yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { + const control = sessions.get(webContentsId); + if (!control || (expectedControl !== undefined && control !== expectedControl)) { + return Effect.succeed([false, sessions] as const); + } + return Scope.close(control.scope, Exit.void).pipe( + Effect.ignore, + Effect.as([ + true, + replaceMap(sessions, (copy) => { + copy.delete(webContentsId); + }), + ] as const), + ); + }); + if (detached || expectedControl !== undefined) return; yield* Ref.update(diagnosticsRef, (diagnostics) => replaceMap(diagnostics, (copy) => { copy.delete(webContentsId); @@ -1464,6 +1669,78 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + // One-shot automation keeps the display awake. An idle Windows display marks + // the T3 window hidden and starves compositor frames, which stalls native + // captures and presentation waits (see BRANCH_DETAILS). This is separate + // from upstream's recording/PiP frame-capture lifecycle and its + // background-throttling policy, and human-driven tabs never hold it: a tab + // counts as an automation tab only after an automation request targets it. + const displayWake = { + blockerId: null as number | null, + automationTabIds: new Set(), + lastActivityAt: 0, + watcher: null as Fiber.Fiber | null, + }; + const releaseDisplayWake = Effect.fn("PreviewManager.releaseDisplayWake")(function* ( + reason: string, + interruptWatcher = true, + ) { + const blockerId = displayWake.blockerId; + if (blockerId === null) return; + displayWake.blockerId = null; + const watcher = displayWake.watcher; + displayWake.watcher = null; + yield* Effect.sync(() => { + if (powerSaveBlocker.isStarted(blockerId)) powerSaveBlocker.stop(blockerId); + }); + yield* Effect.logDebug("Preview automation released the display-sleep block.", { + blockerId, + reason, + }); + if (watcher !== null && interruptWatcher) yield* Fiber.interrupt(watcher); + }); + const watchDisplayWakeInactivity = Effect.gen(function* () { + while (displayWake.blockerId !== null) { + const now = yield* currentMillis; + const idleAt = displayWake.lastActivityAt + AUTOMATION_DISPLAY_WAKE_INACTIVITY_MS; + if (now >= idleAt) { + yield* releaseDisplayWake("inactivity", false); + return; + } + yield* Effect.sleep(idleAt - now); + } + }); + const noteAutomationActivity = Effect.fn("PreviewManager.noteAutomationActivity")(function* ( + tabId: string | null, + reason: string, + ) { + displayWake.lastActivityAt = yield* currentMillis; + if (tabId !== null) displayWake.automationTabIds.add(tabId); + if (displayWake.blockerId !== null) return; + const started = yield* Effect.try(() => powerSaveBlocker.start("prevent-display-sleep")).pipe( + Effect.tapError((cause) => + Effect.logWarning("Preview automation could not start the display-sleep block.", { cause }), + ), + Effect.option, + ); + if (Option.isNone(started)) return; + displayWake.blockerId = started.value; + yield* Effect.logDebug("Preview automation started the display-sleep block.", { + blockerId: started.value, + reason, + tabId, + }); + displayWake.watcher = yield* Effect.forkIn(watchDisplayWakeInactivity, parentScope); + }); + const forgetAutomationTab = Effect.fn("PreviewManager.forgetAutomationTab")(function* ( + tabId: string, + ) { + if (!displayWake.automationTabIds.delete(tabId)) return; + if (displayWake.automationTabIds.size === 0) { + yield* releaseDisplayWake("last-automation-tab-closed"); + } + }); + const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* ( tabId: string, wc: Electron.WebContents, @@ -1471,9 +1748,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function use: ( send: SendCommand, sendCleanup: SendCommand, + operationDeadline: number, + resetControlSession: Effect.Effect, checkControl: Effect.Effect, ) => Effect.Effect, + timeoutMs = DEFAULT_AUTOMATION_TIMEOUT_MS, ) { + const executionBudgetMs = automationExecutionBudget(timeoutMs); + yield* noteAutomationActivity(tabId, action); const sequence = yield* nextCounter(actionSequenceRef); const startedAt = yield* currentIso; const millis = yield* currentMillis; @@ -1485,8 +1767,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* pushAction(tabId, actionEvent); const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; - const control = yield* ensureControlSession(wc); - const execute = Effect.fn("PreviewManager.executeControlAction")(function* () { + const execute = Effect.fn("PreviewManager.executeControlAction")(function* ( + operationDeadline: number, + control: BrowserControlSession, + ) { yield* update(tabId, { controller: "agent" }); const checkControl = Effect.gen(function* () { const currentEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; @@ -1530,8 +1814,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }, ); - return yield* use(send, sendCleanup, checkControl); + const colorScheme = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.colorScheme ?? "system"; + if (colorScheme !== "system") { + yield* send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: colorScheme }], + }); + } + if (action === "click" || action === "press" || action === "scroll" || action === "type") { + yield* waitForAutomationHostFrame; + } + return yield* use( + send, + sendCleanup, + operationDeadline, + detachControlSession(wc.id, control), + checkControl, + ); }); + let permitAcquired = false; const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( exit: Exit.Exit, ) { @@ -1561,10 +1861,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function error: errorMessage, }); } - const tabs = yield* SynchronizedRef.get(tabsRef); - if (tabs.has(tabId)) yield* update(tabId, { controller: "none" }); + if (permitAcquired) { + const tabs = yield* SynchronizedRef.get(tabsRef); + // Commit the controller reset before returning, but do not let an + // arbitrary state listener withhold a bounded automation response. + if (tabs.has(tabId)) { + yield* updateWithoutWaitingForListeners(tabId, { controller: "none" }); + } + } }); - return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize))); + const boundedExecution = Effect.gen(function* () { + const operationDeadline = (yield* currentMillis) + executionBudgetMs; + while (true) { + // Session initialization itself sends CDP commands. Keep it inside the + // operation deadline so an offscreen or suspended guest cannot retain + // the synchronized session lock indefinitely and poison later actions. + const control = yield* ensureControlSession(wc); + const result = yield* control.semaphore.withPermit( + Effect.gen(function* () { + const currentControl = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); + if (currentControl !== control) return STALE_BROWSER_CONTROL_SESSION; + permitAcquired = true; + return yield* execute(operationDeadline, control).pipe( + Effect.onInterrupt(() => detachControlSession(wc.id, control)), + ); + }), + ); + if (result !== STALE_BROWSER_CONTROL_SESSION) return result; + } + }).pipe( + // CDP acknowledges native input even when a hidden host drops it, and + // capturePage can fail with UnknownVizError while that host is throttled. + // Keep its compositor running only for this bounded operation, without + // showing the window, focusing a guest, or starting a frame-capture loop. + withAutomationRendering, + Effect.timeoutOption(executionBudgetMs), + Effect.flatMap((result) => + Option.isNone(result) + ? Effect.fail(new PreviewAutomationTimeoutError({ operation: action, tabId, timeoutMs })) + : Effect.succeed(result.value), + ), + ); + return yield* boundedExecution.pipe(Effect.onExit(finalize)); }); const evaluateWithDebugger = ( @@ -1659,6 +1997,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ]); if (managed) { + retireCaptureQueue(managed.webContents); managed.cancelFaviconCapture(); yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); } @@ -2008,6 +2347,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } }; + const devtoolsClosed = (): void => { + runFork( + Effect.gen(function* () { + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if ( + current?.webContentsId !== wc.id || + current.colorScheme === "system" || + wc.isDestroyed() + ) { + return; + } + yield* restoreControlSession(tabId, wc); + }), + ); + }; yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { @@ -2024,6 +2378,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("audio-state-changed", audioStateChanged); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); + wc.off("devtools-closed", devtoolsClosed); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); @@ -2043,6 +2398,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); + wc.on("devtools-closed", devtoolsClosed); wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); @@ -2061,6 +2417,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); }); + const previousQueue = captureQueues.get(wc); + // Re-registration owns a new capture generation, but Electron's pending + // native call must still settle before this guest can capture again. + captureQueues.set(wc, { + tail: previousQueue?.tail ?? Promise.resolve(), + retired: false, + unavailableUntil: previousQueue?.unavailableUntil ?? null, + }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc }); @@ -2079,7 +2443,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => Effect.gen(function* () { - if (sessions.size > 0) { + if (sessions.size > 0 || automationRenderingUsers > 0) { yield* setWindowBackgroundThrottling(window, false); } yield* Ref.set(mainWindowRef, Option.some(window)); @@ -2154,6 +2518,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; + yield* forgetAutomationTab(tabId); clearPendingRecording(tabId); yield* Effect.all( [ @@ -2348,11 +2713,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // A zoom or mute action that landed while this attach was in flight // addressed the guest this one replaced, so settle the new guest on the // committed values. - yield* assertTabZoom(tabId); - // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach - // must not fail the registration it was attaching for. - yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); - runFork(restoreControlSession(tabId, wc)); + yield* reconcileRegisteredGuestState(tabId, wc, registered.colorScheme); // emitIfCurrent, not emit: audio-state-changed can land between the commit // above and here, and republishing this snapshot would roll the UI back to // a superseded audibility that syncTabAudible will not re-send. @@ -2518,9 +2879,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* detachControlSession(wc.id); yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { - wc.once("devtools-closed", () => { - if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc)); - }); wc.openDevTools({ mode: "detach" }); }); }); @@ -2629,7 +2987,29 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const cropRect = normalizeCaptureRect(args[1]); const submission = args[2] === "send" ? "send" : "attach"; runFork( - captureAnnotationScreenshot(tabId, wc, cropRect).pipe( + captureAnnotationScreenshot( + tabId, + wc, + cropRect, + capturePage( + { + operation: "captureAnnotationScreenshot", + tabId, + webContentsId: wc.id, + }, + wc, + cropRect + ? { + rect: { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + }, + } + : {}, + ), + ).pipe( // The renderer cannot tell a dropped crop from a comment-only // pick by the null alone, so a failed or timed-out capture is // flagged on the result. @@ -2760,37 +3140,127 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // session attaches so a concurrent setColorScheme is not overwritten with // a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => - Effect.gen(function* () { - const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (beforeAttach?.webContentsId !== wc.id) return; - const control = yield* ensureControlSession(wc); - const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (afterAttach?.webContentsId !== wc.id) { - yield* detachControlSession(wc.id); - return; - } - if (afterAttach.colorScheme !== "system") { - yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - control.debugger.sendCommand("Emulation.setEmulatedMedia", { - features: [ - { - name: "prefers-color-scheme", - value: afterAttach.colorScheme, - }, - ], - }), - ); - } - }).pipe(Effect.ignore); + Effect.suspend(() => { + let restoredControl: BrowserControlSession | undefined; + return Effect.gen(function* () { + const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (beforeAttach?.webContentsId !== wc.id) return; + const control = yield* ensureControlSession(wc); + restoredControl = control; + const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (afterAttach?.webContentsId !== wc.id) { + yield* detachControlSession(wc.id, control); + return; + } + if (afterAttach.colorScheme !== "system") { + yield* attemptPromise( + { operation: "applyColorScheme", tabId, webContentsId: wc.id }, + () => + control.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + value: afterAttach.colorScheme, + }, + ], + }), + ); + } + }).pipe( + Effect.timeoutOption( + Math.max(1, DEFAULT_AUTOMATION_TIMEOUT_MS - AUTOMATION_TIMEOUT_RESPONSE_GRACE_MS), + ), + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.void + : Effect.logWarning("Timed out restoring the preview control session.", { + tabId, + webContentsId: wc.id, + }).pipe( + Effect.andThen( + restoredControl ? detachControlSession(wc.id, restoredControl) : Effect.void, + ), + ), + ), + Effect.ignore, + ); + }); - const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( + const reconcileRegisteredGuestState = Effect.fn("PreviewManager.reconcileRegisteredGuestState")( + function* ( + tabId: string, + wc: Electron.WebContents, + registeredColorScheme: DesktopPreviewColorScheme, + ) { + yield* assertTabZoom(tabId); + // Mute is native guest state and needs no CDP session. Best-effort here, + // unlike in setAudioMuted: a guest that dies mid-attach must not fail the + // registration it was attaching for. + yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); + // Appearance overrides are CDP state. Keep the common system-scheme path + // detached; non-system restoration is separately bounded and tears down a + // stalled session. + if (registeredColorScheme !== "system") runFork(restoreControlSession(tabId, wc)); + }, + ); + + const setColorSchemeUnlocked = Effect.fn("PreviewManager.setColorSchemeUnlocked")(function* ( tabId: string, colorScheme: DesktopPreviewColorScheme, + deadline?: { readonly expiresAt: number; readonly timeoutMs: number }, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } + if (deadline !== undefined) { + let target = yield* requireWebContents(tabId); + while (true) { + const remainingTimeoutMs = deadline.expiresAt - (yield* currentMillis); + if (remainingTimeoutMs <= 0) { + return yield* new PreviewAutomationTimeoutError({ + operation: "set-color-scheme", + tabId, + timeoutMs: deadline.timeoutMs, + }); + } + const commandExit = yield* withControlSession( + tabId, + target, + "set-color-scheme", + (send) => + send("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + value: colorScheme === "system" ? "" : colorScheme, + }, + ], + }), + remainingTimeoutMs, + ).pipe(Effect.exit); + const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!currentTab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + if (currentTab.webContentsId !== target.id) { + target = yield* requireWebContents(tabId); + continue; + } + if (Exit.isFailure(commandExit)) { + return yield* Effect.failCause(commandExit.cause); + } + if (currentTab.colorScheme !== colorScheme) { + yield* update(tabId, { colorScheme }); + } + const appliedTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!appliedTab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + if (appliedTab.webContentsId === target.id) return; + target = yield* requireWebContents(tabId); + } + } if (tab.colorScheme !== colorScheme) { // Record the choice even when the CDP call below can't run yet (no // webview, DevTools holding the debugger) — it is re-applied on the @@ -2806,6 +3276,29 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + timeoutMs?: number, + ) { + const deadline = + timeoutMs === undefined + ? undefined + : { expiresAt: (yield* currentMillis) + timeoutMs, timeoutMs }; + const mutation = withColorSchemeMutationLock( + tabId, + setColorSchemeUnlocked(tabId, colorScheme, deadline), + ); + if (timeoutMs === undefined) return yield* mutation; + const result = yield* mutation.pipe(Effect.timeoutOption(timeoutMs)); + if (Option.isSome(result)) return; + return yield* new PreviewAutomationTimeoutError({ + operation: "set-color-scheme", + tabId, + timeoutMs, + }); + }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, @@ -2900,13 +3393,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null) return; const wc = yield* requireWebContents(tabId); - const image = yield* attemptPromise( + const image = yield* capturePage( { operation: "frameCapture.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + wc, ); const currentCaptureSession = yield* Effect.all( [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], @@ -3500,7 +3993,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }; - const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( + const startRecordingOperation = Effect.fn("PreviewManager.startRecordingOperation")(function* ( tabId: string, options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, ) { @@ -3526,14 +4019,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), ); - yield* attemptPromise( + yield* capturePage( { operation: "recording.warmSource", tabId, webContentsId: wc.id, }, - () => wc.capturePage().then(() => undefined), - ).pipe(Effect.retry({ times: 1 }), Effect.ignore); + wc, + ).pipe(Effect.asVoid, Effect.retry({ times: 1 }), Effect.ignore); const currentWebContents = yield* requireWebContents(tabId); if (currentWebContents !== wc || wc.isDestroyed()) { return yield* new PreviewWebContentsNotFoundError({ @@ -3570,7 +4063,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { + const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( + tabId: string, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + timeoutMs?: number, + ) { + yield* noteAutomationActivity(tabId, "recordingStart"); + if (timeoutMs === undefined) return yield* startRecordingOperation(tabId, options); + const startFiber = yield* Effect.forkIn(startRecordingOperation(tabId, options), parentScope); + const result = yield* Fiber.await(startFiber).pipe( + Effect.timeoutOption(automationExecutionBudget(timeoutMs)), + ); + if (Option.isSome(result)) { + if (Exit.isFailure(result.value)) return yield* Effect.failCause(result.value.cause); + return result.value.value; + } + const cleanupFiber = yield* Effect.forkIn( + Fiber.interrupt(startFiber).pipe( + Effect.andThen( + withTabLifecycleLock( + tabId, + Effect.suspend(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording"); + }), + ), + ), + Effect.ignore, + ), + parentScope, + ); + const cleanupBudgetMs = timeoutMs - automationExecutionBudget(timeoutMs); + if (cleanupBudgetMs > 0) { + // Observe cleanup only inside the response grace. The cleanup remains + // scoped to the manager if an uninterruptible capture finalizer is still + // settling, while the IPC caller receives its bounded timeout response. + yield* Fiber.await(cleanupFiber).pipe(Effect.timeoutOption(cleanupBudgetMs)); + } + return yield* new PreviewAutomationTimeoutError({ + operation: "start-recording", + tabId, + timeoutMs, + }); + }); + + const stopRecordingOperation = Effect.fn("PreviewManager.stopRecordingOperation")(function* ( + tabId: string, + ) { // Clearing runs under the tab lock so it cannot land before an in-flight start arms. yield* withTabLifecycleLock( tabId, @@ -3581,13 +4120,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( + const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* ( + tabId: string, + timeoutMs?: number, + ) { + yield* noteAutomationActivity(tabId, "recordingStop"); + if (timeoutMs === undefined) return yield* stopRecordingOperation(tabId); + const cleanupFiber = yield* Effect.forkIn(stopRecordingOperation(tabId), parentScope); + const result = yield* Fiber.await(cleanupFiber).pipe( + Effect.timeoutOption(automationExecutionBudget(timeoutMs)), + ); + if (Option.isSome(result)) { + if (Exit.isFailure(result.value)) return yield* Effect.failCause(result.value.cause); + return; + } + return yield* new PreviewAutomationTimeoutError({ + operation: "stop-recording", + tabId, + timeoutMs, + }); + }); + + const performSaveRecording = Effect.fn("PreviewManager.performSaveRecording")(function* ( tabId: string, mimeType: string, data: Uint8Array, + idempotencyKey: string, ) { - const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); - const id = `browser-recording-${millis.toString(36)}`; + const createdAt = yield* currentIso; + const id = `browser-recording-${idempotencyKey}`; const extension = recordingFileExtension(mimeType); const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( @@ -3622,8 +4183,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); + const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( + tabId: string, + mimeType: string, + data: Uint8Array, + idempotencyKey: string, + timeoutMs?: number, + ) { + const save = performSaveRecording(tabId, mimeType, data, idempotencyKey); + if (timeoutMs === undefined) return yield* save; + const result = yield* save.pipe(Effect.timeoutOption(automationExecutionBudget(timeoutMs))); + if (Option.isSome(result)) return result.value; + return yield* new PreviewAutomationTimeoutError({ + operation: "save-recording", + tabId, + timeoutMs, + }); + }); + const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + // Status is the first request every automation flow makes, so it also + // starts the display-sleep block; only a known tab is remembered as one. + yield* noteAutomationActivity(tab ? tabId : null, "status"); + const displaySleepBlocked = displayWake.blockerId !== null; if (!tab || tab.webContentsId == null) { const navStatus = tab?.navStatus; return { @@ -3633,6 +4216,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, loading: navStatus?.kind === "Loading", + displaySleepBlocked, }; } const wc = webContents.fromId(tab.webContentsId); @@ -3644,6 +4228,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function url: null, title: null, loading: false, + displaySleepBlocked, } : { available: true, @@ -3652,11 +4237,123 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function url: wc.getURL() || null, title: wc.getTitle() || null, loading: wc.isLoading(), + displaySleepBlocked, }; }); + const encodeAutomationScreenshot = Effect.fn("PreviewManager.encodeAutomationScreenshot")( + function* ( + tabId: string, + wc: Electron.WebContents, + sourceImage: Electron.NativeImage, + operation: string, + ) { + if (sourceImage.isEmpty()) { + return yield* new PreviewOperationError({ + operation, + tabId, + webContentsId: wc.id, + cause: new Error("Screenshot capture returned an invalid PNG"), + }); + } + const sourceSize = sourceImage.getSize(); + const image = + sourceSize.width > MAX_SCREENSHOT_WIDTH + ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) + : sourceImage; + if (image.isEmpty()) { + return yield* new PreviewOperationError({ + operation, + tabId, + webContentsId: wc.id, + cause: new Error("Screenshot resize returned an invalid PNG"), + }); + } + const size = image.getSize(); + return { + mimeType: "image/png" as const, + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }; + }, + ); + + const captureAutomationPage = Effect.fn("PreviewManager.captureAutomationPage")(function* ( + tabId: string, + wc: Electron.WebContents, + background: boolean, + ) { + const screenshotError = ( + stage: PreviewAutomationScreenshotError["stage"], + error: PreviewManagerError, + ) => + new PreviewAutomationScreenshotError({ + stage, + tabId, + webContentsId: wc.id, + cause: isPreviewOperationError(error) ? error.cause : error, + }); + yield* waitForAutomationHostFrame.pipe( + Effect.mapError((error) => screenshotError("capture", error)), + ); + const sourceImage = yield* capturePage( + { + operation: "automationSnapshot.capturePage", + tabId, + webContentsId: wc.id, + }, + wc, + { stayHidden: background }, + ).pipe( + Effect.mapError((error) => + screenshotError( + isPreviewOperationError(error) && isPreviewCaptureGateClosedError(error.cause) + ? "pending-capture" + : "capture", + error, + ), + ), + ); + return yield* encodeAutomationScreenshot( + tabId, + wc, + sourceImage, + "automationSnapshot.encode", + ).pipe(Effect.mapError((error) => screenshotError("encode", error))); + }); + + /** Explains a missing automation screenshot to the agent without page content. */ + const screenshotUnavailableReason = ( + result: Exit.Exit, unknown>, + boundedScreenshotTimeoutMs: number, + ): NonNullable => { + if (boundedScreenshotTimeoutMs <= 0) { + return { + stage: "budget", + message: "The operation deadline left no time to capture the page.", + }; + } + if (Exit.isSuccess(result)) { + return { + stage: "interrupted", + message: `The native capture did not settle within ${boundedScreenshotTimeoutMs}ms; later captures of this tab fail fast until it does.`, + }; + } + const failure = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + return isPreviewAutomationScreenshotError(failure) + ? { stage: failure.stage, message: failure.causeMessage } + : { stage: "capture", message: boundedCauseMessage(Cause.squash(result.cause)) }; + }; + const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")( - function* (tabId: string, wc: Electron.WebContents, send: SendCommand) { + function* ( + tabId: string, + wc: Electron.WebContents, + send: SendCommand, + background: boolean, + operationDeadline: number, + ) { yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { concurrency: 2, discard: true, @@ -3723,26 +4420,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function })()`, true, ); - const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ + const [accessibility, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - capturePageWithRetry( - { - operation: "automationSnapshot.capturePage", - tabId, - webContentsId: wc.id, - }, - tabId, - wc, - ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), ]); - const sourceSize = sourceImage.getSize(); - const image = - sourceSize.width > MAX_SCREENSHOT_WIDTH - ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) - : sourceImage; - const size = image.getSize(); + const screenshotTimeoutMs = background + ? AUTOMATION_BACKGROUND_SCREENSHOT_TIMEOUT_MS + : AUTOMATION_SCREENSHOT_TIMEOUT_MS; + const boundedScreenshotTimeoutMs = Math.min( + screenshotTimeoutMs, + Math.max( + 0, + operationDeadline - (yield* currentMillis) - AUTOMATION_SCREENSHOT_SETTLEMENT_GRACE_MS, + ), + ); + const screenshotResult = + boundedScreenshotTimeoutMs > 0 + ? yield* captureAutomationPage(tabId, wc, background).pipe( + Effect.timeoutOption(boundedScreenshotTimeoutMs), + Effect.exit, + ) + : Exit.succeed(Option.none>()); + const screenshot: PreviewAutomationSnapshot["screenshot"] = + Exit.isSuccess(screenshotResult) && Option.isSome(screenshotResult.value) + ? screenshotResult.value.value + : null; + const screenshotUnavailable = + screenshot === null + ? screenshotUnavailableReason(screenshotResult, boundedScreenshotTimeoutMs) + : undefined; + if (screenshotUnavailable !== undefined) { + const cause = Exit.isFailure(screenshotResult) + ? screenshotResult.cause + : new PreviewAutomationTimeoutError({ + operation: "snapshot.capture-page", + tabId, + timeoutMs: boundedScreenshotTimeoutMs, + }); + yield* Effect.logWarning("Preview automation screenshot capture was unavailable.", { + tabId, + webContentsId: wc.id, + background, + stage: screenshotUnavailable.stage, + cause, + }); + } const browserDiagnostics = diagnostics.get(wc.id); return { ...page, @@ -3750,22 +4473,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], actionTimeline: [...(timelines.get(tabId) ?? [])], - screenshot: { - mimeType: "image/png" as const, - data: image.toPNG().toString("base64"), - width: size.width, - height: size.height, - }, + screenshot, + ...(screenshotUnavailable === undefined ? {} : { screenshotUnavailable }), }; }, ); const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( tabId: string, + background = false, + timeoutMs = DEFAULT_AUTOMATION_TIMEOUT_MS, ) { const wc = yield* requireWebContents(tabId); - return yield* withControlSession(tabId, wc, "snapshot", (send) => - captureAutomationSnapshot(tabId, wc, send), + if (!background) { + return yield* withControlSession( + tabId, + wc, + "snapshot", + (send, _sendCleanup, operationDeadline) => + captureAutomationSnapshot(tabId, wc, send, false, operationDeadline), + timeoutMs, + ); + } + + // The renderer briefly stages a non-selected guest so Chromium can expose + // its composited pixels. Do not focus the guest or send Page.bringToFront: + // Electron can otherwise promote the native guest surface above the host + // UI while ignoring the staging wrapper's opacity. + return yield* withControlSession( + tabId, + wc, + "snapshot", + (send, _sendCleanup, operationDeadline) => + captureAutomationSnapshot(tabId, wc, send, true, operationDeadline), + timeoutMs, ); }); @@ -3907,8 +4648,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationClickInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "click", (send) => - performAutomationClick(tabId, input, send), + yield* withControlSession( + tabId, + wc, + "click", + (send) => performAutomationClick(tabId, input, send), + input.timeoutMs, ); }); @@ -4033,8 +4778,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationTypeInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "type", (send) => - performAutomationType(tabId, input, send), + yield* withControlSession( + tabId, + wc, + "type", + (send) => performAutomationType(tabId, input, send), + input.timeoutMs, ); }); @@ -4121,7 +4870,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.timeout(5_000), Effect.catchTags({ TimeoutError: () => - Effect.fail(new PreviewAutomationTimeoutError({ tabId, timeoutMs: 5_000 })), + Effect.fail( + new PreviewAutomationTimeoutError({ operation: "press", tabId, timeoutMs: 5_000 }), + ), }), ); }, Effect.scoped); @@ -4377,8 +5128,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationPressInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "press", (send, sendCleanup, checkControl) => - performAutomationPress(tabId, wc, input, send, sendCleanup, checkControl), + yield* withControlSession( + tabId, + wc, + "press", + (send, sendCleanup, _operationDeadline, _resetControlSession, checkControl) => + performAutomationPress(tabId, wc, input, send, sendCleanup, checkControl), + input.timeoutMs, ); }); @@ -4433,8 +5189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationScrollInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "scroll", (send) => - performAutomationScroll(tabId, input, send), + yield* withControlSession( + tabId, + wc, + "scroll", + (send) => performAutomationScroll(tabId, input, send), + input.timeoutMs, ); }); @@ -4469,8 +5229,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationEvaluateInput, ) { const wc = yield* requireWebContents(tabId); - return yield* withControlSession(tabId, wc, "evaluate", (send) => - performAutomationEvaluate(tabId, input, send), + return yield* withControlSession( + tabId, + wc, + "evaluate", + (send) => performAutomationEvaluate(tabId, input, send), + input.timeoutMs, ); }); @@ -4530,6 +5294,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* Effect.sleep(100); } return yield* new PreviewAutomationTimeoutError({ + operation: "waitFor.condition", tabId, timeoutMs, }); @@ -4540,8 +5305,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationWaitForInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "waitFor", (send) => - performAutomationWaitFor(tabId, input, send), + yield* withControlSession( + tabId, + wc, + "waitFor", + (send) => performAutomationWaitFor(tabId, input, send), + input.timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS, ); }); @@ -4591,6 +5360,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ).pipe(Effect.asVoid); const destroy = Effect.fn("PreviewManager.destroy")(function* () { + yield* releaseDisplayWake("shutdown"); const tabs = yield* SynchronizedRef.get(tabsRef); yield* Effect.forEach(tabs.keys(), closeTab, { discard: true }); yield* Effect.all( @@ -4740,6 +5510,64 @@ export class PreviewOperationError extends Schema.TaggedError()( + "PreviewAutomationScreenshotError", + { + stage: Schema.Literals(["capture", "encode", "pending-capture"]), + tabId: Schema.String, + webContentsId: Schema.Number, + cause: Schema.Defect(), + }, +) { + get causeMessage(): string { + return boundedCauseMessage(this.cause); + } + + override get message(): string { + return `Desktop preview screenshot ${this.stage} failed (tab ${this.tabId}, WebContents ${this.webContentsId}): ${this.causeMessage}`; + } +} + +const isPreviewAutomationScreenshotError = Schema.is(PreviewAutomationScreenshotError); + +const MAX_CAUSE_MESSAGE_LENGTH = 300; + +/** One line of the cause's own message, bounded so traces and tool output stay small. */ +const boundedCauseMessage = (cause: unknown): string => { + const raw = + cause instanceof Error + ? cause.message || cause.name + : typeof cause === "string" + ? cause + : String(cause); + const line = raw.replace(/\s+/g, " ").trim() || "unknown cause"; + return line.length > MAX_CAUSE_MESSAGE_LENGTH + ? `${line.slice(0, MAX_CAUSE_MESSAGE_LENGTH - 1)}…` + : line; +}; + +/** + * Raised inside the serialized native-capture queue while an earlier capture + * that outlived its caller is still pending in Electron. + */ +export class PreviewCaptureGateClosedError extends Schema.TaggedError()( + "PreviewCaptureGateClosedError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return "Preview capture is unavailable while a timed-out capture is pending"; + } +} + +const isPreviewCaptureGateClosedError = Schema.is(PreviewCaptureGateClosedError); + export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedError()( "PreviewArtifactPathOutsideDirectoryError", { @@ -4894,12 +5722,13 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedError
()(
   "PreviewAutomationTimeoutError",
   {
+    operation: Schema.String,
     tabId: Schema.String,
     timeoutMs: Schema.Number,
   },
 ) {
   override get message(): string {
-    return `Preview condition did not match within ${this.timeoutMs}ms in tab ${this.tabId}`;
+    return `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms in tab ${this.tabId}`;
   }
 }
 
@@ -4978,6 +5807,7 @@ export class PreviewManager extends Context.Service<
     readonly setColorScheme: (
       tabId: string,
       colorScheme: DesktopPreviewColorScheme,
+      timeoutMs?: number,
     ) => Effect.Effect;
     readonly setAudioMuted: (
       tabId: string,
@@ -5012,18 +5842,26 @@ export class PreviewManager extends Context.Service<
     readonly startRecording: (
       tabId: string,
       options?: RecordingInputOptions,
+      timeoutMs?: number,
+    ) => Effect.Effect;
+    readonly stopRecording: (
+      tabId: string,
+      timeoutMs?: number,
     ) => Effect.Effect;
-    readonly stopRecording: (tabId: string) => Effect.Effect;
     readonly saveRecording: (
       tabId: string,
       mimeType: string,
       data: Uint8Array,
+      idempotencyKey: string,
+      timeoutMs?: number,
     ) => Effect.Effect;
     readonly automationStatus: (
       tabId: string,
     ) => Effect.Effect;
     readonly automationSnapshot: (
       tabId: string,
+      background?: boolean,
+      timeoutMs?: number,
     ) => Effect.Effect;
     readonly automationClick: (
       tabId: string,
diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts
index 5619f19b2549..617ac80751ae 100644
--- a/apps/server/src/mcp/McpHttpServer.test.ts
+++ b/apps/server/src/mcp/McpHttpServer.test.ts
@@ -20,10 +20,24 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
 import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
 
 const environmentId = EnvironmentId.make("environment-mcp-test");
+it("extracts preview caller namespaces without rerouting other tools", () => {
+  const call = (name: string, threadId: unknown) => ({ params: { name, _meta: { threadId } } });
+  expect(McpHttpServer.previewCallerContext(call("preview_open", "native-child"))).toBe(
+    "native-child",
+  );
+  expect(
+    McpHttpServer.previewCallerContext(call("magi_list_context_activities", "native-child")),
+  ).toBeUndefined();
+  expect(McpHttpServer.previewCallerContext({ params: { name: "preview_open" } })).toBeUndefined();
+  for (const id of [42, "", " ", "a".repeat(129), "a\u0000b"]) {
+    expect(McpHttpServer.previewCallerContext(call("preview_open", id))).toBeNull();
+  }
+});
 const threadId = ThreadId.make("thread-mcp-test");
 const tabId = PreviewTabId.make("tab-mcp-test");
 const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
 const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+const noScreenshotTabId = PreviewTabId.make("tab-mcp-no-screenshot");
 const invocation = {
   environmentId,
   threadId,
@@ -393,7 +407,10 @@ it.effect("saves the snapshot PNG on request and reports its path", () =>
       );
       expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png");
       const [, text] = snapshot.content;
-      expect(text?.type === "text" ? text.text : "").toContain(screenshotPath);
+      expect(decodeJsonText(text?.type === "text" ? text.text : "{}")).toHaveProperty(
+        "screenshotPath",
+        screenshotPath,
+      );
 
       const unsaved = yield* callSnapshot({});
       expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath");
@@ -737,10 +754,21 @@ it.effect("registers annotated tools and preserves authenticated request context
       const routedRequests: Array<{
         readonly operation: string;
         readonly tabId?: string | undefined;
+        readonly timeoutMs?: number | undefined;
       }> = [];
       const events = yield* broker.connect({
         clientId: "mcp-test-client",
+        hostId: "mcp-test-host",
         environmentId,
+        label: "Test Desktop",
+        platform: "linux",
+      });
+      const windowsEvents = yield* broker.connect({
+        clientId: "mcp-windows-client",
+        hostId: "mcp-windows-host",
+        environmentId,
+        label: "Windows Desktop",
+        platform: "windows",
       });
       yield* Stream.runForEach(events, (event) => {
         if (event.type === "connected") return Effect.void;
@@ -752,7 +780,19 @@ it.effect("registers annotated tools and preserves authenticated request context
           ok: true,
           result:
             event.request.operation === "snapshot"
-              ? snapshotResult
+              ? {
+                  ...snapshotResult,
+                  ...(event.request.tabId === noScreenshotTabId
+                    ? {
+                        screenshot: null,
+                        screenshotUnavailable: {
+                          stage: "interrupted",
+                          message: "The native capture did not settle within 3000ms",
+                          hostRendering: "paused",
+                        },
+                      }
+                    : { screenshot: snapshotResult.screenshot }),
+                }
               : event.request.operation === "evaluate"
                 ? ["Connect", "Continue"]
                 : event.request.operation === "press"
@@ -767,6 +807,7 @@ it.effect("registers annotated tools and preserves authenticated request context
                     },
         });
       }).pipe(Effect.forkScoped);
+      yield* Stream.runDrain(windowsEvents).pipe(Effect.forkScoped);
       yield* Effect.yieldNow;
 
       const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
@@ -774,6 +815,14 @@ it.effect("registers annotated tools and preserves authenticated request context
       expect(statusTool?.tool.annotations?.idempotentHint).toBe(true);
       expect(statusTool?.tool.annotations?.destructiveHint).toBe(false);
 
+      const listHostsTool = server.tools.find(({ tool }) => tool.name === "preview_list_hosts");
+      expect(listHostsTool?.tool.annotations?.readOnlyHint).toBe(true);
+      expect(listHostsTool?.tool.annotations?.destructiveHint).toBe(false);
+
+      const selectHostTool = server.tools.find(({ tool }) => tool.name === "preview_select_host");
+      expect(selectHostTool?.tool.annotations?.idempotentHint).toBe(true);
+      expect(selectHostTool?.tool.annotations?.destructiveHint).toBe(false);
+
       const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot");
       expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true);
       expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true);
@@ -793,6 +842,46 @@ it.effect("registers annotated tools and preserves authenticated request context
       expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false);
       expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true);
 
+      const closeTool = server.tools.find(({ tool }) => tool.name === "preview_close");
+      expect(closeTool?.tool.annotations?.destructiveHint).toBe(true);
+      expect(closeTool?.tool.annotations?.idempotentHint).toBe(true);
+      expect(closeTool?.tool.annotations?.openWorldHint).toBe(true);
+
+      const availableHosts = yield* server
+        .callTool({ name: "preview_list_hosts", arguments: {} })
+        .pipe(
+          Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+          Effect.provideService(McpSchema.McpServerClient, client),
+        );
+      expect(availableHosts.structuredContent).toMatchObject({
+        hosts: [
+          { hostId: "mcp-test-host", label: "Test Desktop", platform: "linux" },
+          { hostId: "mcp-windows-host", label: "Windows Desktop", platform: "windows" },
+        ],
+        assignedHostId: null,
+      });
+
+      const linuxHosts = yield* server
+        .callTool({ name: "preview_list_hosts", arguments: { platform: "linux" } })
+        .pipe(
+          Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+          Effect.provideService(McpSchema.McpServerClient, client),
+        );
+      expect(linuxHosts.structuredContent).toMatchObject({
+        hosts: [{ hostId: "mcp-test-host", label: "Test Desktop", platform: "linux" }],
+        assignedHostId: null,
+      });
+
+      const selectedHost = yield* server
+        .callTool({ name: "preview_select_host", arguments: { hostId: "mcp-test-host" } })
+        .pipe(
+          Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+          Effect.provideService(McpSchema.McpServerClient, client),
+        );
+      expect(selectedHost.structuredContent).toMatchObject({
+        host: { hostId: "mcp-test-host", label: "Test Desktop", platform: "linux" },
+      });
+
       const status = yield* server
         .callTool({ name: "preview_status", arguments: {} })
         .pipe(
@@ -847,11 +936,35 @@ it.effect("registers annotated tools and preserves authenticated request context
         value: ["Connect", "Continue"],
       });
 
+      const snapshotWithoutImage = yield* server
+        .callTool({ name: "preview_snapshot", arguments: { tabId: noScreenshotTabId, save: true } })
+        .pipe(
+          Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+          Effect.provideService(McpSchema.McpServerClient, client),
+        );
+      expect(snapshotWithoutImage.isError).toBe(false);
+      expect(snapshotWithoutImage.content.some((content) => content.type === "image")).toBe(false);
+      expect(snapshotWithoutImage.structuredContent).toMatchObject({
+        screenshot: null,
+        screenshotUnavailable: { stage: "interrupted", hostRendering: "paused" },
+      });
+      expect(snapshotWithoutImage.structuredContent).not.toHaveProperty("screenshotPath");
+      // The agent is told why the image is missing and that the host is not painting.
+      expect(
+        snapshotWithoutImage.content.some(
+          (content) =>
+            content.type === "text" &&
+            content.text.startsWith("No screenshot (interrupted)") &&
+            content.text.includes("did not settle within 3000ms") &&
+            content.text.includes("not painting frames"),
+        ),
+      ).toBe(true);
+
       const actionRequests = [
         { name: "preview_click", arguments: { x: 10, y: 10 } },
         { name: "preview_type", arguments: { text: "Hello" } },
-        { name: "preview_press", arguments: { key: "Enter" } },
-        { name: "preview_scroll", arguments: { deltaY: 100 } },
+        { name: "preview_press", arguments: { key: "Enter", timeoutMs: 1_234 } },
+        { name: "preview_scroll", arguments: { deltaY: 100, timeoutMs: 1_234 } },
         { name: "preview_wait_for", arguments: { text: "Example" } },
       ];
       for (const request of actionRequests) {
@@ -867,6 +980,22 @@ it.effect("registers annotated tools and preserves authenticated request context
         const text = result.content[0];
         expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon });
       }
+      expect(routedRequests.find(({ operation }) => operation === "press")?.timeoutMs).toBe(1_234);
+      expect(routedRequests.find(({ operation }) => operation === "scroll")?.timeoutMs).toBe(1_234);
+
+      const evaluation = yield* server
+        .callTool({
+          name: "preview_evaluate",
+          arguments: { expression: "document.title", timeoutMs: 1_234 },
+        })
+        .pipe(
+          Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+          Effect.provideService(McpSchema.McpServerClient, client),
+        );
+      expect(evaluation.isError).toBe(false);
+      expect(routedRequests.findLast(({ operation }) => operation === "evaluate")?.timeoutMs).toBe(
+        1_234,
+      );
     }),
   ).pipe(Effect.provide(TestLayer)),
 );
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index 5a807fc328af..2eb335f816f7 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -28,6 +28,7 @@ import {
 import {
   PreviewSnapshotTool,
   PreviewSnapshotToolkit,
+  previewScreenshotUnavailableText,
   PreviewStandardToolkit,
 } from "./toolkits/preview/tools.ts";
 import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
@@ -101,8 +102,26 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
         });
         return unauthorized;
       }
+      const call =
+        request.method === "POST"
+          ? yield* request.json.pipe(Effect.orElseSucceed(() => undefined))
+          : undefined;
+      const caller = previewCallerContext(call);
+      if (caller === null) {
+        return HttpServerResponse.jsonUnsafe(
+          {
+            error: "invalid_preview_caller",
+            message:
+              "Preview caller identity must be a non-empty string of at most 128 characters.",
+          },
+          { status: 400 },
+        );
+      }
       return yield* httpEffect.pipe(
-        Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+        Effect.provideService(McpInvocationContext.McpInvocationContext, {
+          ...invocation,
+          ...(caller === undefined ? {} : { previewContextId: caller }),
+        }),
         Effect.map(normalizeMcpHttpResponse),
       );
     }),
@@ -110,6 +129,30 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
   Effect.withSpan("McpHttpServer.makeAuthMiddleware"),
 );
 
+/** Native child metadata partitions control, but cannot redirect the bearer-owned conversation. */
+export function previewCallerContext(call: unknown): string | null | undefined {
+  if (typeof call !== "object" || call === null || !("params" in call)) return undefined;
+  const params = call.params;
+  if (
+    typeof params !== "object" ||
+    params === null ||
+    !("name" in params) ||
+    typeof params.name !== "string" ||
+    !params.name.startsWith("preview_") ||
+    !("_meta" in params)
+  )
+    return undefined;
+  const meta = params._meta;
+  if (typeof meta !== "object" || meta === null || !("threadId" in meta)) return undefined;
+  const id = meta.threadId;
+  return typeof id === "string" &&
+    id.trim().length > 0 &&
+    id.length <= 128 &&
+    !id.includes("\u0000")
+    ? id
+    : null;
+}
+
 const McpAuthMiddlewareLive = HttpRouter.middleware<{
   provides: McpInvocationContext.McpInvocationContext;
 }>()(makeMcpAuthMiddleware).layer;
@@ -409,17 +452,22 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
             Effect.gen(function* () {
               const snapshot = encodedResult as SnapshotMetadata & {
                 readonly url: string;
-                readonly screenshot: {
+                readonly screenshot: null | {
                   readonly mimeType: "image/png";
                   readonly data: string;
                   readonly width: number;
                   readonly height: number;
                 };
+                readonly screenshotUnavailable?: Parameters<
+                  typeof previewScreenshotUnavailableText
+                >[0];
               };
               const { screenshot, ...page } = snapshot;
-              const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
+              const png = new Uint8Array(Buffer.from(screenshot?.data ?? "", "base64"));
               const screenshotPath =
-                payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined;
+                payload?.save === true && screenshot !== null
+                  ? yield* saveScreenshot(snapshot.url, png)
+                  : undefined;
               if (screenshotPath !== undefined && payload?.includeImage === false) {
                 // The agent only wants a file to show the user. The url keeps the site icon on the tool row.
                 const saved = {
@@ -434,11 +482,14 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
               }
               const metadata = {
                 ...page,
-                screenshot: {
-                  mimeType: screenshot.mimeType,
-                  width: screenshot.width,
-                  height: screenshot.height,
-                },
+                screenshot:
+                  screenshot === null
+                    ? null
+                    : {
+                        mimeType: screenshot.mimeType,
+                        width: screenshot.width,
+                        height: screenshot.height,
+                      },
                 ...(screenshotPath === undefined ? {} : { screenshotPath }),
               };
               const bounded = boundSnapshotMetadata(metadata);
@@ -465,9 +516,17 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
                           text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`,
                         },
                       ]),
-                  ...(payload?.includeImage === false
-                    ? []
-                    : [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]),
+                  // Say why the image is missing so the agent does not retry blindly.
+                  ...(screenshot === null
+                    ? [
+                        {
+                          type: "text" as const,
+                          text: previewScreenshotUnavailableText(snapshot.screenshotUnavailable),
+                        },
+                      ]
+                    : payload?.includeImage === false
+                      ? []
+                      : [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]),
                 ],
               });
             }),
diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts
index 96ddd8f7bfc1..658dd33f6313 100644
--- a/apps/server/src/mcp/McpInvocationContext.ts
+++ b/apps/server/src/mcp/McpInvocationContext.ts
@@ -17,6 +17,8 @@ export interface McpInvocationScope {
   readonly providerInstanceId: ProviderInstanceId;
   readonly capabilities: ReadonlySet;
   readonly issuedAt: number;
+  /** Caller namespace within this credential; never used to select a T3 thread. */
+  readonly previewContextId?: string;
 }
 
 export class McpInvocationContext extends Context.Service<
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts
index 7a8ed739d734..ecca13f553e0 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts
@@ -2,7 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
 import { expect, it } from "@effect/vitest";
 import {
   EnvironmentId,
+  PREVIEW_AUTOMATION_OPERATIONS,
+  PREVIEW_AUTOMATION_V1_OPERATIONS,
   PreviewAutomationClientDisconnectedError,
+  PreviewAutomationHostAssignmentConflictError,
+  PreviewAutomationHostUnavailableError,
   PreviewAutomationInvalidSelectorError,
   PreviewAutomationMalformedResponseError,
   PreviewAutomationNoAvailableHostError,
@@ -64,6 +68,53 @@ const requestsFrom = (
     }),
   );
 
+it.effect("isolates inherited callers' hosts and current tabs inside their main conversation", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const first = { ...scope, previewContextId: "native-child-a" };
+      const second = { ...scope, previewContextId: "native-child-b" };
+      const seen: RoutedRequest[] = [];
+      for (const hostId of ["windows", "mac"]) {
+        const connected = yield* Deferred.make();
+        const stream = yield* broker.connect(makeHost({ clientId: hostId, hostId }));
+        yield* stream.pipe(
+          Stream.tap((event) =>
+            event.type === "connected" ? Deferred.succeed(connected, undefined) : Effect.void,
+          ),
+          requestsFrom,
+          Stream.runForEach((request) => {
+            seen.push(request);
+            return broker.respond({
+              clientId: hostId,
+              connectionId: request.connectionId,
+              requestId: request.requestId,
+              ok: true,
+              result: { tabId: `tab-${hostId}` },
+            });
+          }),
+          Effect.forkScoped,
+        );
+        yield* Deferred.await(connected);
+      }
+      yield* broker.selectHost(first, "windows");
+      yield* broker.selectHost(second, "mac");
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBeNull();
+      expect((yield* broker.listHosts(first)).assignedHostId).toBe("windows");
+      expect((yield* broker.listHosts(second)).assignedHostId).toBe("mac");
+      yield* broker.invoke({ scope: first, operation: "open", input: {} });
+      yield* broker.invoke({ scope: second, operation: "open", input: {} });
+      yield* broker.invoke({ scope: first, operation: "snapshot", input: {} });
+      yield* broker.invoke({ scope: second, operation: "snapshot", input: {} });
+      expect(seen.map((request) => request.threadId)).toEqual(Array(4).fill(scope.threadId));
+      expect(seen[0]?.contextId).not.toBe(seen[1]?.contextId);
+      expect(seen[2]?.contextId).toBe(seen[0]?.contextId);
+      expect(seen[2]?.tabId).toBe("tab-windows");
+      expect(seen[3]?.tabId).toBe("tab-mac");
+    }),
+  ),
+);
+
 it.effect("atomically registers a connected host and correlates its response", () =>
   Effect.scoped(
     Effect.gen(function* () {
@@ -134,6 +185,46 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () =
   ),
 );
 
+it.effect("clears the assigned tab after close returns a null target", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const openedTabId = PreviewTabId.make("tab-to-close");
+      const routedRequests: RoutedRequest[] = [];
+      const requests = requestsFrom(
+        yield* broker.connect(
+          makeHost({
+            supportedOperations: ["open", "close", "snapshot"],
+          }),
+        ),
+      );
+      yield* Stream.runForEach(requests, (request) => {
+        routedRequests.push(request);
+        return broker.respond({
+          clientId: "client-1",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result:
+            request.operation === "open"
+              ? { available: true, tabId: openedTabId }
+              : request.operation === "close"
+                ? { available: true, tabId: null }
+                : { url: "http://localhost:3200" },
+        });
+      }).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      yield* broker.invoke({ scope, operation: "open", input: {} });
+      yield* broker.invoke({ scope, operation: "close", input: {} });
+      yield* broker.invoke({ scope, operation: "snapshot", input: {} });
+
+      expect(routedRequests[1]?.tabId).toBe(openedTabId);
+      expect(routedRequests[2]?.tabId).toBeUndefined();
+    }),
+  ),
+);
+
 it.effect.each([true, false])(
   "keeps an older target stable while a newer explicit tab responds (implicit: %s)",
   (implicit) =>
@@ -526,6 +617,451 @@ it.effect("rejects calls when no connected host exists", () =>
   }),
 );
 
+it.effect("discovers stable host identities with metadata only inside the caller environment", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const macEvents = yield* broker.connect(
+        makeHost({
+          clientId: "mac-connection",
+          hostId: "host-mac",
+          label: "MacBook Pro",
+          platform: "macos",
+          supportedOperations: ["status", "open"],
+        }),
+      );
+      const windowsEvents = yield* broker.connect(
+        makeHost({
+          clientId: "windows-connection",
+          hostId: "host-windows",
+          label: "Windows Desktop",
+          platform: "windows",
+          supportedOperations: ["status"],
+        }),
+      );
+      const foreignEvents = yield* broker.connect(
+        makeHost({
+          clientId: "foreign-connection",
+          hostId: "host-foreign",
+          environmentId: EnvironmentId.make("environment-foreign"),
+          label: "Foreign Mac",
+          platform: "macos",
+        }),
+      );
+      yield* Stream.runDrain(macEvents).pipe(Effect.forkScoped);
+      yield* Stream.runDrain(windowsEvents).pipe(Effect.forkScoped);
+      yield* Stream.runDrain(foreignEvents).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      expect(yield* broker.listHosts(scope)).toEqual({
+        hosts: [
+          {
+            hostId: "host-mac",
+            label: "MacBook Pro",
+            platform: "macos",
+            supportedOperations: ["status", "open"],
+            focused: false,
+          },
+          {
+            hostId: "host-windows",
+            label: "Windows Desktop",
+            platform: "windows",
+            supportedOperations: ["status"],
+            focused: false,
+          },
+        ],
+        assignedHostId: null,
+      });
+    }),
+  ),
+);
+
+it.effect("discovers and selects a legacy host through stable metadata fallbacks", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const clientId = "legacy-client-abcdef01";
+      const events = yield* broker.connect(makeHost({ clientId }));
+      yield* Stream.runDrain(events).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      const expectedHost = {
+        hostId: clientId,
+        label: "Preview host abcdef01",
+        platform: "unknown",
+        supportedOperations: [...PREVIEW_AUTOMATION_V1_OPERATIONS],
+        focused: false,
+      } as const;
+      expect(yield* broker.listHosts(scope)).toEqual({
+        hosts: [expectedHost],
+        assignedHostId: null,
+      });
+      expect(yield* broker.selectHost(scope, clientId)).toEqual({ host: expectedHost });
+    }),
+  ),
+);
+
+it.effect("explicitly selects one host, keeps it sticky, and rejects a conflicting live host", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const macTabId = PreviewTabId.make("tab-on-mac");
+      let macRoutedTabId: PreviewTabId | undefined;
+      let windowsConnectionId = "";
+      const macRequests = requestsFrom(
+        yield* broker.connect(
+          makeHost({ clientId: "mac-connection", hostId: "host-mac", label: "MacBook Pro" }),
+        ),
+      );
+      const windowsRequests = requestsFrom(
+        yield* broker.connect(
+          makeHost({
+            clientId: "windows-connection",
+            hostId: "host-windows",
+            label: "Windows Desktop",
+          }),
+        ),
+        (connectionId) => {
+          windowsConnectionId = connectionId;
+        },
+      );
+      yield* Stream.runForEach(macRequests, (request) => {
+        macRoutedTabId = request.tabId;
+        return broker.respond({
+          clientId: "mac-connection",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: request.operation === "open" ? { host: "mac", tabId: macTabId } : "mac",
+        });
+      }).pipe(Effect.forkScoped);
+      yield* Stream.runForEach(windowsRequests, (request) =>
+        broker.respond({
+          clientId: "windows-connection",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "windows",
+        }),
+      ).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      expect((yield* broker.selectHost(scope, "host-mac")).host.hostId).toBe("host-mac");
+      expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
+        host: "mac",
+        tabId: macTabId,
+      });
+      expect((yield* broker.selectHost(scope, "host-mac")).host.hostId).toBe("host-mac");
+      yield* broker.focusHost({
+        clientId: "windows-connection",
+        environmentId: scope.environmentId,
+        connectionId: windowsConnectionId,
+        focused: true,
+      });
+      expect(yield* broker.invoke({ scope, operation: "snapshot", input: {} })).toBe("mac");
+      expect(macRoutedTabId).toBe(macTabId);
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBe("host-mac");
+
+      const conflict = yield* broker.selectHost(scope, "host-windows").pipe(Effect.flip);
+      expect(conflict).toBeInstanceOf(PreviewAutomationHostAssignmentConflictError);
+      expect(conflict).toMatchObject({
+        assignedHostId: "host-mac",
+        requestedHostId: "host-windows",
+      });
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("mac");
+    }),
+  ),
+);
+
+it.effect("rejects explicit selection of another host after an implicit assignment", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const windowsRequests = requestsFrom(
+        yield* broker.connect(
+          makeHost({
+            clientId: "windows-connection",
+            hostId: "host-windows",
+            label: "Windows Desktop",
+          }),
+        ),
+      );
+      const macRequests = requestsFrom(
+        yield* broker.connect(
+          makeHost({ clientId: "mac-connection", hostId: "host-mac", label: "MacBook Pro" }),
+        ),
+      );
+      yield* Stream.runForEach(windowsRequests, (request) =>
+        broker.respond({
+          clientId: "windows-connection",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "windows",
+        }),
+      ).pipe(Effect.forkScoped);
+      yield* Stream.runForEach(macRequests, (request) =>
+        broker.respond({
+          clientId: "mac-connection",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "mac",
+        }),
+      ).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("mac");
+      const conflict = yield* broker.selectHost(scope, "host-windows").pipe(Effect.flip);
+      expect(conflict).toBeInstanceOf(PreviewAutomationHostAssignmentConflictError);
+      expect(conflict).toMatchObject({
+        assignedHostId: "host-mac",
+        requestedHostId: "host-windows",
+      });
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("mac");
+    }),
+  ),
+);
+
+it.effect("rejects unavailable and foreign explicit hosts without assigning a fallback", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const localEvents = yield* broker.connect(
+        makeHost({ clientId: "local-connection", hostId: "host-local" }),
+      );
+      const foreignEvents = yield* broker.connect(
+        makeHost({
+          clientId: "foreign-connection",
+          hostId: "host-foreign",
+          environmentId: EnvironmentId.make("environment-foreign"),
+        }),
+      );
+      yield* Stream.runDrain(localEvents).pipe(Effect.forkScoped);
+      yield* Stream.runDrain(foreignEvents).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      for (const hostId of ["host-missing", "host-foreign"]) {
+        const error = yield* broker.selectHost(scope, hostId).pipe(Effect.flip);
+        expect(error).toBeInstanceOf(PreviewAutomationHostUnavailableError);
+        expect(error).toMatchObject({ hostId, environmentId: scope.environmentId });
+      }
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBeNull();
+    }),
+  ),
+);
+
+it.effect("keeps an explicit assignment unavailable after disconnect instead of falling back", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const macRequests = requestsFrom(
+        yield* broker.connect(makeHost({ clientId: "mac-connection", hostId: "host-mac" })),
+      );
+      const windowsRequests = requestsFrom(
+        yield* broker.connect(makeHost({ clientId: "windows-connection", hostId: "host-windows" })),
+      );
+      const macConsumer = yield* Stream.runForEach(macRequests, (request) =>
+        broker.respond({
+          clientId: "mac-connection",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "mac",
+        }),
+      ).pipe(Effect.forkScoped);
+      let windowsRequestCount = 0;
+      yield* Stream.runForEach(windowsRequests, (request) => {
+        windowsRequestCount += 1;
+        return broker.respond({
+          clientId: "windows-connection",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "windows",
+        });
+      }).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      yield* broker.selectHost(scope, "host-mac");
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("mac");
+      yield* Fiber.interrupt(macConsumer);
+      yield* Effect.yieldNow;
+
+      const conflict = yield* broker.selectHost(scope, "host-windows").pipe(Effect.flip);
+      expect(conflict).toBeInstanceOf(PreviewAutomationHostAssignmentConflictError);
+      expect(conflict).toMatchObject({
+        assignedHostId: "host-mac",
+        requestedHostId: "host-windows",
+      });
+      const error = yield* broker
+        .invoke({ scope, operation: "status", input: {} })
+        .pipe(Effect.flip);
+      expect(error).toBeInstanceOf(PreviewAutomationHostUnavailableError);
+      expect(error).toMatchObject({ hostId: "host-mac", operation: "status" });
+      expect(windowsRequestCount).toBe(0);
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBe("host-mac");
+    }),
+  ),
+);
+
+it.effect("restores an explicit host and its tab after that physical renderer reconnects", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const tabId = PreviewTabId.make("tab-on-reconnecting-mac");
+      const firstRequests = requestsFrom(
+        yield* broker.connect(makeHost({ clientId: "mac-connection-1", hostId: "host-mac" })),
+      );
+      yield* Stream.runForEach(firstRequests, (request) =>
+        broker.respond({
+          clientId: "mac-connection-1",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: { host: "mac", tabId },
+        }),
+      ).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      yield* broker.selectHost(scope, "host-mac");
+      yield* broker.invoke({ scope, operation: "open", input: {} });
+
+      let reconnectedTabId: PreviewTabId | undefined;
+      const replacementRequests = requestsFrom(
+        yield* broker.connect(makeHost({ clientId: "mac-connection-2", hostId: "host-mac" })),
+      );
+      yield* Stream.runForEach(replacementRequests, (request) => {
+        reconnectedTabId = request.tabId;
+        return broker.respond({
+          clientId: "mac-connection-2",
+          connectionId: request.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "reconnected",
+        });
+      }).pipe(Effect.forkScoped);
+      yield* Effect.yieldNow;
+
+      expect(yield* broker.invoke({ scope, operation: "snapshot", input: {} })).toBe(
+        "reconnected",
+      );
+      expect(reconnectedTabId).toBe(tabId);
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBe("host-mac");
+    }),
+  ),
+);
+
+it.effect("restores an explicitly selected host and tab after timeout-driven re-registration", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const hostId = "selected-host";
+      const host = makeHost({ hostId });
+      const tabId = PreviewTabId.make("selected-host-tab");
+      const connected = yield* Deferred.make();
+      const received = yield* Deferred.make();
+      const reconnected = yield* Deferred.make();
+      const group = RpcGroup.make(
+        ...Array.from(WsRpcGroup.requests.values()).filter(
+          (rpc) => rpc._tag === WS_METHODS.previewAutomationConnect,
+        ),
+      );
+      const client = yield* RpcTest.makeClient(group).pipe(
+        Effect.provide(
+          group.toLayer({
+            [WS_METHODS.previewAutomationConnect]: (input) => Stream.unwrap(broker.connect(input)),
+          }),
+        ),
+      );
+      const replacementRequests: RoutedRequest[] = [];
+      const consumer = yield* Stream.runForEach(
+        client[WS_METHODS.previewAutomationConnect](host),
+        (event) => {
+          if (event.type === "connected") return Deferred.succeed(connected, undefined);
+          if (event.request.operation === "open") {
+            return broker.respond({
+              clientId: host.clientId,
+              connectionId: event.connectionId,
+              requestId: event.request.requestId,
+              ok: true,
+              result: { tabId },
+            });
+          }
+          return Deferred.succeed(received, { ...event.request, connectionId: event.connectionId });
+        },
+      ).pipe(Effect.forkScoped);
+      yield* Deferred.await(connected);
+      yield* broker.selectHost(scope, hostId);
+      yield* broker.invoke({ scope, operation: "open", input: {} });
+
+      const otherConnected = yield* Deferred.make();
+      let otherRequestCount = 0;
+      yield* Stream.runForEach(
+        yield* broker.connect(makeHost({ clientId: "other-client", hostId: "other-host" })),
+        (event) => {
+          if (event.type === "connected") return Deferred.succeed(otherConnected, undefined);
+          otherRequestCount += 1;
+          return broker.respond({
+            clientId: "other-client",
+            connectionId: event.connectionId,
+            requestId: event.request.requestId,
+            ok: true,
+            result: "other-host",
+          });
+        },
+      ).pipe(Effect.forkScoped);
+      yield* Deferred.await(otherConnected);
+
+      const timedOut = yield* broker
+        .invoke({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 })
+        .pipe(Effect.flip, Effect.forkScoped);
+      const staleRequest = yield* Deferred.await(received);
+      yield* TestClock.adjust(1_000);
+      expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS);
+      expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true);
+      const unavailable = yield* broker
+        .invoke({ scope, operation: "status", input: {} })
+        .pipe(Effect.flip);
+      expect(unavailable).toBeInstanceOf(PreviewAutomationHostUnavailableError);
+      expect(unavailable).toMatchObject({ hostId, operation: "status" });
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBe(hostId);
+      expect(otherRequestCount).toBe(0);
+
+      yield* Stream.runForEach(client[WS_METHODS.previewAutomationConnect](host), (event) => {
+        if (event.type === "connected") return Deferred.succeed(reconnected, event.connectionId);
+        replacementRequests.push({ ...event.request, connectionId: event.connectionId });
+        return broker.respond({
+          clientId: host.clientId,
+          connectionId: event.connectionId,
+          requestId: event.request.requestId,
+          ok: true,
+          result: "recovered",
+        });
+      }).pipe(Effect.forkScoped);
+      const replacementConnectionId = yield* Deferred.await(reconnected);
+      expect(replacementConnectionId).not.toBe(staleRequest.connectionId);
+      yield* broker.respond({
+        clientId: host.clientId,
+        connectionId: staleRequest.connectionId,
+        requestId: staleRequest.requestId,
+        ok: true,
+        result: { tabId: PreviewTabId.make("stale-response-tab") },
+      });
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("recovered");
+      expect(replacementRequests).toHaveLength(1);
+      expect(replacementRequests[0]).toMatchObject({
+        operation: "status",
+        tabId,
+        connectionId: replacementConnectionId,
+      });
+      expect((yield* broker.listHosts(scope)).assignedHostId).toBe(hostId);
+      expect(otherRequestCount).toBe(0);
+    }),
+  ),
+);
+
 it.effect("does not create host state from focus updates without a live stream", () =>
   Effect.gen(function* () {
     const broker = yield* makeBroker;
@@ -931,7 +1467,10 @@ it.effect("does not move a live legacy assignment to another runtime for resize"
 
       const capableRequests = requestsFrom(
         yield* broker.connect(
-          makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }),
+          makeHost({
+            clientId: "client-capable",
+            supportedOperations: PREVIEW_AUTOMATION_OPERATIONS,
+          }),
         ),
       );
       yield* Stream.runForEach(capableRequests, (request) =>
@@ -1320,6 +1859,9 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"
       yield* Deferred.await(otherReceived);
       yield* TestClock.adjust(1_000);
       expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
+      // The deadline fails only its caller; silence through the grace evicts the host.
+      expect(yield* Deferred.isDone(otherCompleted)).toBe(false);
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS);
       expect(yield* Deferred.isDone(otherCompleted)).toBe(true);
       expect(yield* Fiber.join(other)).toMatchObject({
         _tag: "PreviewAutomationClientDisconnectedError",
@@ -1383,6 +1925,7 @@ it.effect("discards buffered actions before completing an evicted host stream",
       yield* Deferred.await(actionRouted);
       yield* TestClock.adjust(1_000);
       expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS);
       expect(yield* Fiber.join(buffered)).toMatchObject({
         _tag: "PreviewAutomationClientDisconnectedError",
       });
@@ -1445,6 +1988,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver
       yield* Deferred.await(actionRouted);
       yield* TestClock.adjust(1_000);
       expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS);
       expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true);
 
       paused = false;
@@ -1485,3 +2029,96 @@ it.effect("keeps a host that responds with an operation timeout", () =>
     }),
   ),
 );
+
+it.effect("keeps a host whose typed timeout lands after the broker deadline", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const connected = yield* Deferred.make();
+      const received = yield* Deferred.make();
+      const requests: RoutedRequest[] = [];
+      yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => {
+        if (event.type === "connected") return Deferred.succeed(connected, event.connectionId);
+        const request = { ...event.request, connectionId: event.connectionId };
+        requests.push(request);
+        if (request.operation === "snapshot") return Deferred.succeed(received, request);
+        return broker.respond({
+          clientId: "client-1",
+          connectionId: event.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "responsive",
+        });
+      }).pipe(Effect.forkScoped);
+      const connectionId = yield* Deferred.await(connected);
+      const timedOut = yield* broker
+        .invoke({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 })
+        .pipe(Effect.flip, Effect.forkScoped);
+      const slowRequest = yield* Deferred.await(received);
+      yield* TestClock.adjust(1_000);
+      expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
+
+      // A busy renderer's own typed timeout arrives inside the eviction grace.
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS - 1);
+      yield* broker.respond({
+        clientId: "client-1",
+        connectionId,
+        requestId: slowRequest.requestId,
+        ok: false,
+        error: { _tag: "PreviewAutomationTimeoutError", message: "host response budget" },
+      });
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS);
+
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive");
+      expect(requests).toHaveLength(2);
+      expect(requests[1]).toMatchObject({ operation: "status", connectionId });
+    }),
+  ),
+);
+
+it.effect("discards a late answer without replaying it or adopting its tab", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const broker = yield* makeBroker;
+      const connected = yield* Deferred.make();
+      const received = yield* Deferred.make();
+      const requests: RoutedRequest[] = [];
+      yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => {
+        if (event.type === "connected") return Deferred.succeed(connected, event.connectionId);
+        const request = { ...event.request, connectionId: event.connectionId };
+        requests.push(request);
+        if (request.operation === "open") return Deferred.succeed(received, request);
+        return broker.respond({
+          clientId: "client-1",
+          connectionId: event.connectionId,
+          requestId: request.requestId,
+          ok: true,
+          result: "responsive",
+        });
+      }).pipe(Effect.forkScoped);
+      const connectionId = yield* Deferred.await(connected);
+      const timedOut = yield* broker
+        .invoke({ scope, operation: "open", input: {}, timeoutMs: 1_000 })
+        .pipe(Effect.flip, Effect.forkScoped);
+      const slowRequest = yield* Deferred.await(received);
+      yield* TestClock.adjust(1_000);
+      expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
+
+      yield* broker.respond({
+        clientId: "client-1",
+        connectionId,
+        requestId: slowRequest.requestId,
+        ok: true,
+        result: { tabId: PreviewTabId.make("late-tab") },
+      });
+      yield* TestClock.adjust(PreviewAutomationBroker.PREVIEW_AUTOMATION_EVICTION_GRACE_MS);
+
+      // The host stays registered, the open is not replayed, and the late tab
+      // never becomes the session's current tab.
+      expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive");
+      expect(requests.map((request) => request.operation)).toEqual(["open", "status"]);
+      expect(requests[1]).toMatchObject({ connectionId });
+      expect(requests[1]?.tabId).toBeUndefined();
+    }),
+  ),
+);
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts
index 65e3064f49a5..0a09df243957 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.ts
@@ -3,6 +3,8 @@ import {
   PreviewAutomationClientDisconnectedError,
   PreviewAutomationControlInterruptedError,
   PreviewAutomationExecutionError,
+  PreviewAutomationHostAssignmentConflictError,
+  PreviewAutomationHostUnavailableError,
   PreviewAutomationInvalidSelectorError,
   PreviewAutomationMalformedResponseError,
   PreviewAutomationNoAvailableHostError,
@@ -19,6 +21,9 @@ import {
   PreviewAutomationUnsupportedClientError,
   PreviewTabId,
   type PreviewAutomationError,
+  type PreviewAutomationAvailableHost,
+  type PreviewAutomationHostList,
+  type PreviewAutomationHostSelection,
   type PreviewAutomationOperation,
   type PreviewAutomationHost,
   type PreviewAutomationHostFocus,
@@ -30,6 +35,7 @@ import type * as Cause from "effect/Cause";
 import * as Crypto from "effect/Crypto";
 import * as Deferred from "effect/Deferred";
 import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
 import * as Layer from "effect/Layer";
 import * as Option from "effect/Option";
 import * as Queue from "effect/Queue";
@@ -39,6 +45,17 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
 
 import * as McpInvocationContext from "./McpInvocationContext.ts";
 
+export const DEFAULT_PREVIEW_AUTOMATION_TIMEOUT_MS = 15_000;
+
+/**
+ * How long past a request's deadline the broker keeps the host registered
+ * while waiting for any answer, including the renderer's own typed timeout.
+ * The renderer reserves 250 ms of response grace measured from when the
+ * request reaches it, so this must comfortably cover that plus transit and a
+ * busy renderer; only silence beyond it evicts the host.
+ */
+export const PREVIEW_AUTOMATION_EVICTION_GRACE_MS = 3_000;
+
 export interface PreviewAutomationInvokeInput {
   readonly scope: McpInvocationContext.McpInvocationScope;
   readonly operation: PreviewAutomationOperation;
@@ -58,6 +75,16 @@ export class PreviewAutomationBroker extends Context.Service<
       host: PreviewAutomationHost,
     ) => Effect.Effect>;
     readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect;
+    readonly listHosts: (
+      scope: McpInvocationContext.McpInvocationScope,
+    ) => Effect.Effect;
+    readonly selectHost: (
+      scope: McpInvocationContext.McpInvocationScope,
+      hostId: string,
+    ) => Effect.Effect<
+      PreviewAutomationHostSelection,
+      PreviewAutomationHostUnavailableError | PreviewAutomationHostAssignmentConflictError
+    >;
     readonly respond: (
       response: PreviewAutomationResponse,
     ) => Effect.Effect;
@@ -69,8 +96,11 @@ export class PreviewAutomationBroker extends Context.Service<
 
 interface ClientConnection {
   readonly clientId: string;
+  readonly hostId: string;
   readonly connectionId: string;
   readonly environmentId: PreviewAutomationHost["environmentId"];
+  readonly label: string;
+  readonly platform: PreviewAutomationAvailableHost["platform"];
   readonly supportedOperations: ReadonlySet;
   readonly focused: boolean;
   readonly liveTabs: NonNullable;
@@ -85,17 +115,19 @@ interface PendingRequest {
 }
 
 /**
- * A lease pinning one provider session to one desktop runtime. It lives exactly
- * as long as the connection it names: `connectionId`/`queue` identity is what
- * makes a lease valid, so a disconnected or replaced host is dropped on the next
- * lookup. The lease deliberately has no clock of its own — it used to inherit
- * the MCP credential's expiry, which coupled host stickiness to an unrelated
- * auth deadline and could migrate a live session to another runtime mid-flow.
+ * A lease pinning one provider session to one desktop runtime. Implicit leases
+ * live as long as their connection. Explicit leases retain the stable physical
+ * host id across transport replacement and fail closed while that host is
+ * disconnected. Neither lease has a clock of its own because credential expiry
+ * is unrelated to desktop and tab identity.
  */
 interface HostAssignment {
   readonly clientId: ClientConnection["clientId"];
+  readonly hostId: ClientConnection["hostId"];
   readonly connectionId: ClientConnection["connectionId"];
+  readonly environmentId: ClientConnection["environmentId"];
   readonly queue: ClientConnection["queue"];
+  readonly selection: "implicit" | "explicit";
   readonly tabId?: PreviewTabId;
   readonly tabSequence?: number;
 }
@@ -123,6 +155,22 @@ interface BrokerState {
   readonly focusSequence: number;
 }
 
+type HostSelectionOutcome =
+  | { readonly _tag: "Unavailable" }
+  | { readonly _tag: "Conflict"; readonly assignedHostId: string }
+  | { readonly _tag: "Selected"; readonly host: PreviewAutomationAvailableHost };
+
+type HostRouteOutcome =
+  | { readonly _tag: "NoRoute" }
+  | { readonly _tag: "SelectedUnavailable"; readonly hostId: string }
+  | {
+      readonly _tag: "Route";
+      readonly connection: ClientConnection;
+      readonly requestId: string;
+      readonly requestContext: PreviewAutomationRequestErrorContext;
+      readonly requestSequence: number;
+    };
+
 const removeConnectionFromState = (
   current: BrokerState,
   clientId: string,
@@ -134,7 +182,9 @@ const removeConnectionFromState = (
   const disconnected: PendingRequest[] = [];
   if (current.clients.get(clientId)?.queue === queue) clients.delete(clientId);
   for (const [assignmentKey, assignment] of assignments) {
-    if (assignment.queue === queue) assignments.delete(assignmentKey);
+    if (assignment.queue === queue && assignment.selection === "implicit") {
+      assignments.delete(assignmentKey);
+    }
   }
   for (const [requestId, entry] of pending) {
     if (entry.queue !== queue) continue;
@@ -161,7 +211,40 @@ const selectorDiagnosticsFromInput = (
 };
 
 const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>
-  `${scope.environmentId}\u0000${scope.providerSessionId}`;
+  JSON.stringify([scope.environmentId, scope.providerSessionId, scope.previewContextId ?? null]);
+
+const previewContextId = (scope: McpInvocationContext.McpInvocationScope): string =>
+  `${scope.providerSessionId}:${scope.previewContextId ?? ""}`;
+
+const availableHost = (connection: ClientConnection): PreviewAutomationAvailableHost => ({
+  hostId: connection.hostId,
+  label: connection.label,
+  platform: connection.platform,
+  supportedOperations: [...connection.supportedOperations],
+  focused: connection.focused,
+});
+
+const normalizeAssignments = (current: BrokerState): ReadonlyMap => {
+  const assignments = new Map();
+  for (const [assignmentKey, assignment] of current.assignments) {
+    const connection = Array.from(current.clients.values()).find(
+      (candidate) =>
+        candidate.hostId === assignment.hostId &&
+        candidate.environmentId === assignment.environmentId,
+    );
+    if (connection) {
+      assignments.set(assignmentKey, {
+        ...assignment,
+        clientId: connection.clientId,
+        connectionId: connection.connectionId,
+        queue: connection.queue,
+      });
+    } else if (assignment.selection === "explicit") {
+      assignments.set(assignmentKey, assignment);
+    }
+  }
+  return assignments;
+};
 
 const isPreviewTabId = Schema.is(PreviewTabId);
 
@@ -317,6 +400,8 @@ const classifyResponseError = (
 
 export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
   const crypto = yield* Crypto.Crypto;
+  // Eviction watchers outlive the request that armed them and end with the broker.
+  const brokerScope = yield* Effect.scope;
   const state = yield* SynchronizedRef.make({
     clients: new Map(),
     assignments: new Map(),
@@ -373,8 +458,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
     yield* Queue.offer(queue, { type: "connected", connectionId });
     const connection: ClientConnection = {
       clientId,
+      hostId: host.hostId ?? clientId,
       connectionId,
       environmentId: host.environmentId,
+      label: host.label ?? `Preview host ${clientId.slice(-8)}`,
+      platform: host.platform ?? "unknown",
       supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
       focused: false,
       liveTabs: [],
@@ -382,9 +470,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
       queue,
     };
     const registration = yield* SynchronizedRef.modify(state, (current) => {
-      const previousConnection = current.clients.get(clientId);
+      const previousConnection = Array.from(current.clients.values()).find(
+        (candidate) =>
+          candidate.hostId === connection.hostId &&
+          candidate.environmentId === connection.environmentId,
+      );
       const removed = previousConnection
-        ? removeConnectionFromState(current, clientId, previousConnection.queue)
+        ? removeConnectionFromState(current, previousConnection.clientId, previousConnection.queue)
         : { state: current, disconnected: [] };
       const clients = new Map(removed.state.clients);
       const focusSequence = removed.state.focusSequence + 1;
@@ -441,6 +533,104 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
     });
   });
 
+  const listHosts: PreviewAutomationBroker["Service"]["listHosts"] = Effect.fn(
+    "PreviewAutomationBroker.listHosts",
+  )(function* (scope) {
+    return yield* SynchronizedRef.modify(state, (current) => {
+      const assignments = normalizeAssignments(current);
+      const assignment = assignments.get(hostAssignmentKey(scope));
+      const hosts = Array.from(current.clients.values())
+        .filter((host) => host.environmentId === scope.environmentId)
+        .sort(
+          (left, right) =>
+            left.label.localeCompare(right.label) || left.hostId.localeCompare(right.hostId),
+        )
+        .map(availableHost);
+      return [
+        {
+          hosts,
+          assignedHostId: assignment?.hostId ?? null,
+        },
+        { ...current, assignments },
+      ] as const;
+    });
+  });
+
+  const selectHost: PreviewAutomationBroker["Service"]["selectHost"] = Effect.fn(
+    "PreviewAutomationBroker.selectHost",
+  )(function* (scope, hostId) {
+    const outcome = yield* SynchronizedRef.modify(
+      state,
+      (current): readonly [HostSelectionOutcome, BrokerState] => {
+        const assignments = new Map(normalizeAssignments(current));
+        const assignmentKey = hostAssignmentKey(scope);
+        const assigned = assignments.get(assignmentKey);
+        const assignedConnection = assigned
+          ? Array.from(current.clients.values()).find(
+              (candidate) =>
+                candidate.hostId === assigned.hostId &&
+                candidate.environmentId === scope.environmentId,
+            )
+          : undefined;
+        const requested = Array.from(current.clients.values()).find(
+          (candidate) =>
+            candidate.hostId === hostId && candidate.environmentId === scope.environmentId,
+        );
+        if (!requested) {
+          return [{ _tag: "Unavailable" as const }, { ...current, assignments }] as const;
+        }
+        if (
+          assigned &&
+          assigned.hostId !== hostId &&
+          (assigned.selection === "explicit" || assignedConnection !== undefined)
+        ) {
+          return [
+            { _tag: "Conflict" as const, assignedHostId: assigned.hostId },
+            { ...current, assignments },
+          ] as const;
+        }
+        assignments.set(assignmentKey, {
+          clientId: requested.clientId,
+          hostId: requested.hostId,
+          connectionId: requested.connectionId,
+          environmentId: requested.environmentId,
+          queue: requested.queue,
+          selection: "explicit",
+          ...(assigned?.hostId === requested.hostId && assigned.tabId !== undefined
+            ? { tabId: assigned.tabId }
+            : {}),
+          ...(assigned?.hostId === requested.hostId && assigned.tabSequence !== undefined
+            ? { tabSequence: assigned.tabSequence }
+            : {}),
+        });
+        return [
+          { _tag: "Selected" as const, host: availableHost(requested) },
+          { ...current, assignments },
+        ] as const;
+      },
+    );
+    if (outcome._tag === "Unavailable") {
+      return yield* new PreviewAutomationHostUnavailableError({
+        environmentId: scope.environmentId,
+        threadId: scope.threadId,
+        providerSessionId: scope.providerSessionId,
+        providerInstanceId: scope.providerInstanceId,
+        hostId,
+      });
+    }
+    if (outcome._tag === "Conflict") {
+      return yield* new PreviewAutomationHostAssignmentConflictError({
+        environmentId: scope.environmentId,
+        threadId: scope.threadId,
+        providerSessionId: scope.providerSessionId,
+        providerInstanceId: scope.providerInstanceId,
+        requestedHostId: hostId,
+        assignedHostId: outcome.assignedHostId,
+      });
+    }
+    return { host: outcome.host };
+  });
+
   const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn(
     "PreviewAutomationBroker.respond",
   )(function* (response) {
@@ -473,96 +663,129 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
   const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* (
     input: Parameters[0],
   ): Effect.fn.Return {
-    const timeoutMs = input.timeoutMs ?? 15_000;
+    const timeoutMs = input.timeoutMs ?? DEFAULT_PREVIEW_AUTOMATION_TIMEOUT_MS;
     const deferred = yield* Deferred.make();
-    const route = yield* SynchronizedRef.modify(state, (current) => {
-      const assignments = new Map(
-        Array.from(current.assignments).filter(([, assignment]) => {
-          const connection = current.clients.get(assignment.clientId);
-          return (
-            connection?.connectionId === assignment.connectionId &&
-            connection.queue === assignment.queue
+    const route = yield* SynchronizedRef.modify(
+      state,
+      (current): readonly [HostRouteOutcome, BrokerState] => {
+        const assignments = new Map(normalizeAssignments(current));
+        const assignmentKey = hostAssignmentKey(input.scope);
+        const assigned = assignments.get(assignmentKey);
+        const assignedConnection = assigned
+          ? Array.from(current.clients.values()).find(
+              (candidate) =>
+                candidate.hostId === assigned.hostId &&
+                candidate.environmentId === input.scope.environmentId,
+            )
+          : undefined;
+        const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId;
+        // Keep one provider session on one physical desktop runtime so a
+        // multi-step browser interaction cannot jump between independent
+        // Electron cookie/DOM state. A live assignment that predates an
+        // operation is not silently moved to a newer client: the caller gets a
+        // capability failure and can deliberately start a fresh provider
+        // session. An implicit dead lease is pruned above and may fail over. An
+        // explicit lease keeps its stable host id and fails closed until that
+        // physical renderer reconnects or the caller starts a new session.
+        const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>
+          host.liveTabs.some(
+            (tab) =>
+              tab.threadId === input.scope.threadId &&
+              (!visibleOnly || tab.visible === true) &&
+              (input.tabId === undefined || tab.tabId === input.tabId),
           );
-        }),
-      );
-      const assignmentKey = hostAssignmentKey(input.scope);
-      const assigned = assignments.get(assignmentKey);
-      const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined;
-      const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId;
-      // Keep one provider session on one physical desktop runtime so a
-      // multi-step browser interaction cannot jump between independent
-      // Electron cookie/DOM state. A live assignment that predates an
-      // operation is not silently moved to a newer client: the caller gets a
-      // capability failure and can deliberately start a fresh provider
-      // session. A dead lease is pruned above and may fail over.
-      const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>
-        host.liveTabs.some(
-          (tab) =>
-            tab.threadId === input.scope.threadId &&
-            (!visibleOnly || tab.visible === true) &&
-            (input.tabId === undefined || tab.tabId === input.tabId),
-        );
-      const connection =
-        hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
-          ? assignedConnection
-          : hasLiveAssignment
-            ? undefined
-            : Array.from(current.clients.values())
-                .filter(
-                  (host) =>
-                    host.environmentId === input.scope.environmentId &&
-                    supportsOperation(host, input.operation),
-                )
-                .sort(
-                  (left, right) =>
-                    Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||
-                    Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||
-                    Number(right.focused) - Number(left.focused) ||
-                    right.focusOrder - left.focusOrder,
-                )[0];
-      if (!connection) {
-        if (!hasLiveAssignment) assignments.delete(assignmentKey);
-        return [undefined, { ...current, assignments }] as const;
-      }
-      const canReuseAssignedTab =
-        assigned !== undefined &&
-        assigned.connectionId === connection.connectionId &&
-        assigned.queue === connection.queue;
-      assignments.set(assignmentKey, {
-        clientId: connection.clientId,
-        connectionId: connection.connectionId,
-        queue: connection.queue,
-        ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),
-        ...(canReuseAssignedTab && assigned.tabSequence !== undefined
-          ? { tabSequence: assigned.tabSequence }
-          : {}),
-      });
+        const connection =
+          assigned?.selection === "explicit"
+            ? hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
+              ? assignedConnection
+              : undefined
+            : hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
+              ? assignedConnection
+              : hasLiveAssignment
+                ? undefined
+                : Array.from(current.clients.values())
+                    .filter(
+                      (host) =>
+                        host.environmentId === input.scope.environmentId &&
+                        supportsOperation(host, input.operation),
+                    )
+                    .sort(
+                      (left, right) =>
+                        Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||
+                        Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||
+                        Number(right.focused) - Number(left.focused) ||
+                        right.focusOrder - left.focusOrder,
+                    )[0];
+        if (!connection) {
+          if (!hasLiveAssignment && assigned?.selection !== "explicit") {
+            assignments.delete(assignmentKey);
+          }
+          return [
+            assigned?.selection === "explicit"
+              ? { _tag: "SelectedUnavailable" as const, hostId: assigned.hostId }
+              : { _tag: "NoRoute" as const },
+            { ...current, assignments },
+          ] as const;
+        }
+        const canReuseAssignedTab =
+          assigned !== undefined &&
+          assigned.connectionId === connection.connectionId &&
+          assigned.queue === connection.queue;
+        assignments.set(assignmentKey, {
+          clientId: connection.clientId,
+          hostId: connection.hostId,
+          connectionId: connection.connectionId,
+          environmentId: connection.environmentId,
+          queue: connection.queue,
+          selection: assigned?.selection ?? "implicit",
+          ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),
+          ...(canReuseAssignedTab && assigned.tabSequence !== undefined
+            ? { tabSequence: assigned.tabSequence }
+            : {}),
+        });
 
-      const requestSequence = current.requestSequence;
-      const requestId = `preview-${requestSequence}`;
-      const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined);
-      const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
-      const context: PreviewAutomationRequestErrorContext = {
-        operation: input.operation,
+        const requestSequence = current.requestSequence;
+        const requestId = `preview-${requestSequence}`;
+        const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined);
+        const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
+        const context: PreviewAutomationRequestErrorContext = {
+          operation: input.operation,
+          environmentId: input.scope.environmentId,
+          threadId: input.scope.threadId,
+          providerSessionId: input.scope.providerSessionId,
+          providerInstanceId: input.scope.providerInstanceId,
+          clientId: connection.clientId,
+          connectionId: connection.connectionId,
+          requestId,
+          ...(tabId === undefined ? {} : { tabId }),
+          timeoutMs,
+          ...selectorDiagnostics,
+        };
+        const pending = new Map(current.pending);
+        pending.set(requestId, { queue: connection.queue, deferred, context });
+        return [
+          {
+            _tag: "Route" as const,
+            connection,
+            requestId,
+            requestContext: context,
+            requestSequence,
+          },
+          { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
+        ] as const;
+      },
+    );
+    if (route._tag === "SelectedUnavailable") {
+      return yield* new PreviewAutomationHostUnavailableError({
         environmentId: input.scope.environmentId,
         threadId: input.scope.threadId,
         providerSessionId: input.scope.providerSessionId,
         providerInstanceId: input.scope.providerInstanceId,
-        clientId: connection.clientId,
-        connectionId: connection.connectionId,
-        requestId,
-        ...(tabId === undefined ? {} : { tabId }),
-        timeoutMs,
-        ...selectorDiagnostics,
-      };
-      const pending = new Map(current.pending);
-      pending.set(requestId, { queue: connection.queue, deferred, context });
-      return [
-        { connection, requestId, requestContext: context, requestSequence },
-        { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
-      ] as const;
-    });
-    if (!route) {
+        hostId: route.hostId,
+        operation: input.operation,
+      });
+    }
+    if (route._tag === "NoRoute") {
       return yield* new PreviewAutomationNoAvailableHostError({
         operation: input.operation,
         environmentId: input.scope.environmentId,
@@ -579,6 +802,22 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
       pending.delete(requestId);
       return { ...next, pending };
     });
+    // After a timeout the pending entry stays registered so a late answer can
+    // still settle the deferred and be discarded; the watcher then owns removal.
+    let pendingOwner: "caller" | "watcher" = "caller";
+    // A slow answer is not a dead host. Fail the caller at its deadline, but
+    // evict the connection only when nothing at all, not even the renderer's
+    // typed timeout, arrives within the grace. A late answer is discarded: the
+    // caller already failed and no action is replayed.
+    const watchForEviction = Effect.gen(function* () {
+      const late = yield* Deferred.await(deferred).pipe(
+        Effect.timeoutOption(PREVIEW_AUTOMATION_EVICTION_GRACE_MS),
+        Effect.exit,
+      );
+      if (Exit.isSuccess(late) && Option.isNone(late.value)) {
+        yield* disconnect(connection.clientId, connection.queue, true);
+      }
+    }).pipe(Effect.ensuring(removePending));
     const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
       const offered = yield* SynchronizedRef.modifyEffect(state, (current) => {
         // A route can outlive its generation while another request evicts it.
@@ -595,6 +834,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
           request: {
             requestId,
             threadId: input.scope.threadId,
+            contextId: previewContextId(input.scope),
             tabId: requestContext.tabId,
             tabIdExplicit: input.tabId !== undefined,
             operation: input.operation,
@@ -614,15 +854,18 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
       return yield* Option.match(result, {
         onNone: () =>
           Effect.gen(function* () {
-            // An unanswered request invalidates this connection. Do not replay
-            // actions: the client may have applied them before becoming unreachable.
-            yield* disconnect(connection.clientId, connection.queue, true);
+            pendingOwner = "watcher";
+            yield* Effect.forkIn(watchForEviction, brokerScope);
             return yield* new PreviewAutomationTimeoutError(requestContext);
           }),
         onSome: (value) => Effect.succeed(value as A),
       });
     });
-    const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
+    const result = yield* awaitResponse().pipe(
+      Effect.ensuring(
+        Effect.suspend(() => (pendingOwner === "caller" ? removePending : Effect.void)),
+      ),
+    );
     if (input.updateCurrentTab === false) return result;
     const responseTabId = readResultTabId(result);
     const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
@@ -654,7 +897,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
     return result;
   });
 
-  return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
+  return PreviewAutomationBroker.of({ connect, focusHost, listHosts, selectHost, respond, invoke });
 }).pipe(Effect.withSpan("PreviewAutomationBroker.make"));
 
 export const layer = Layer.effect(PreviewAutomationBroker, make);
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts
index a2a88e14fcef..4ad931f5a5ca 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts
@@ -1,6 +1,16 @@
 import { describe, expect, it } from "@effect/vitest";
 import * as NodeServices from "@effect/platform-node/NodeServices";
-import { ThreadId } from "@t3tools/contracts";
+import {
+  EnvironmentId,
+  PreviewTabId,
+  ProviderInstanceId,
+  ThreadId,
+  type PreviewAutomationStreamEvent,
+} from "@t3tools/contracts";
+import * as Fiber from "effect/Fiber";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Layer from "effect/Layer";
@@ -11,7 +21,14 @@ import {
   parseThreadSegmentFromAttachmentId,
 } from "../../../attachmentStore.ts";
 import * as ServerConfig from "../../../config.ts";
-import { claimPreviewRecording, normalizePreviewOpenInput } from "./handlers.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
+import {
+  claimPreviewRecording,
+  normalizePreviewOpenInput,
+  PreviewStandardToolkitHandlersLive,
+} from "./handlers.ts";
+import { PreviewStandardToolkit } from "./tools.ts";
 
 describe("normalizePreviewOpenInput", () => {
   it("leaves an unstated visibility for the client preference to decide", () => {
@@ -153,3 +170,113 @@ describe("claimPreviewRecording", () => {
     ),
   );
 });
+
+const scope = {
+  environmentId: EnvironmentId.make("environment-1"),
+  threadId: ThreadId.make("thread-1"),
+  providerSessionId: "provider-session-1",
+  providerInstanceId: ProviderInstanceId.make("codex"),
+  capabilities: new Set(["preview"] as const),
+  issuedAt: 1,
+};
+const tabId = PreviewTabId.make("tab-1");
+type RequestEvent = Extract;
+
+const makeHarness = Effect.fn("makePreviewToolkitHarness")(function* (settleMs = 0) {
+  const broker = yield* PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));
+  const invocations: Array = [];
+  const toolkitBroker = PreviewAutomationBroker.PreviewAutomationBroker.of({
+    ...broker,
+    invoke: (input: PreviewAutomationBroker.PreviewAutomationInvokeInput) => {
+      invocations.push(input);
+      return broker.invoke(input).pipe(Effect.tap(() => TestClock.adjust(settleMs)));
+    },
+  });
+  const requests = yield* Queue.unbounded();
+  const events = yield* broker.connect({
+    clientId: "client-1",
+    environmentId: scope.environmentId,
+  });
+  yield* Stream.runForEach(events, (event) =>
+    event.type === "request" ? Queue.offer(requests, event) : Effect.void,
+  ).pipe(Effect.forkScoped);
+  const toolkit = yield* PreviewStandardToolkit.pipe(
+    Effect.provide(PreviewStandardToolkitHandlersLive),
+  );
+  const evaluate = (timeoutMs?: number) =>
+    toolkit.handle("preview_evaluate", { expression: "6 * 7", tabId, timeoutMs }).pipe(
+      Stream.unwrap,
+      Stream.runCollect,
+      Effect.map((results) => results.at(-1)!.result),
+      Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
+      Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, toolkitBroker),
+    );
+  const respond = (event: RequestEvent, result: unknown) =>
+    broker.respond({
+      clientId: "client-1",
+      connectionId: event.connectionId,
+      requestId: event.request.requestId,
+      ok: true,
+      result,
+    });
+  return { evaluate, respond, nextRequest: Queue.take(requests), invocations };
+});
+
+it.effect.each([100, 200])(
+  "skips favicon lookup after the deadline with %i ms settlement",
+  (settleMs) =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const harness = yield* makeHarness(settleMs);
+        const call = yield* harness.evaluate(1_000).pipe(Effect.forkScoped);
+        const operation = yield* harness.nextRequest;
+        yield* TestClock.adjust(900);
+        yield* harness.respond(operation, 42);
+        expect(yield* Fiber.join(call)).toEqual({ value: 42 });
+        expect(harness.invocations.map((input) => input.operation)).toEqual(["evaluate"]);
+      }),
+    ),
+);
+
+it.effect.each([
+  { timeoutMs: 1_000, operationMs: 900, metadataMs: 100 },
+  { timeoutMs: undefined, operationMs: 14_900, metadataMs: 100 },
+  { timeoutMs: 2_000, operationMs: 500, metadataMs: 500 },
+])("returns the operation result when favicon lookup exhausts its budget: %j", (testCase) =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const harness = yield* makeHarness();
+      const call = yield* harness.evaluate(testCase.timeoutMs).pipe(Effect.forkScoped);
+      const operation = yield* harness.nextRequest;
+      yield* TestClock.adjust(testCase.operationMs);
+      yield* harness.respond(operation, 42);
+      const metadata = yield* harness.nextRequest;
+      expect(metadata.request).toMatchObject({
+        operation: "status",
+        tabId,
+        timeoutMs: testCase.metadataMs,
+      });
+      yield* TestClock.adjust(testCase.metadataMs);
+      expect(yield* Fiber.join(call)).toEqual({ value: 42 });
+    }),
+  ),
+);
+
+it.effect("retains favicon metadata when the targeted lookup finishes within the deadline", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const harness = yield* makeHarness();
+      const call = yield* harness.evaluate(1_000).pipe(Effect.forkScoped);
+      const operation = yield* harness.nextRequest;
+      yield* TestClock.adjust(900);
+      yield* harness.respond(operation, 42);
+      const metadata = yield* harness.nextRequest;
+      yield* TestClock.adjust(50);
+      yield* harness.respond(metadata, { url: "https://example.com/page" });
+      expect(yield* Fiber.join(call)).toEqual({
+        value: 42,
+        toolIcon: { _tag: "website", pageUrl: "https://example.com/page" },
+      });
+    }),
+  ),
+);
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts
index b70e68862683..04cd30d143c0 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.ts
@@ -1,3 +1,4 @@
+import * as Clock from "effect/Clock";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
 import * as Schema from "effect/Schema";
@@ -6,6 +7,8 @@ import {
   PREVIEW_RECORDING_STOP_TIMEOUT_MS,
   PreviewAutomationRecordingTransferError,
   PreviewAutomationRecordingDesktopUpdateRequiredError,
+  type PreviewAutomationHostList,
+  type PreviewAutomationHostSelection,
   PreviewAutomationRecordingArtifact,
   type ToolActivityIcon,
   type ThreadId,
@@ -62,6 +65,9 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* (
 > {
   const scope = yield* McpInvocationContext.requireMcpCapability("preview");
   const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
+  const deadline =
+    (yield* Clock.currentTimeMillis) +
+    (timeoutMs ?? PreviewAutomationBroker.DEFAULT_PREVIEW_AUTOMATION_TIMEOUT_MS);
   let targetTabId = tabId;
   const result = yield* broker.invoke({
     onTargetTab: (resolvedTabId) => {
@@ -74,6 +80,9 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* (
     ...(tabId === undefined ? {} : { tabId }),
   });
   if (["status", "open", "navigate", "snapshot"].includes(operation)) return { result };
+  // Optional favicon metadata shares the operation deadline, including its default budget.
+  const metadataTimeoutMs = Math.min(500, deadline - (yield* Clock.currentTimeMillis));
+  if (metadataTimeoutMs <= 0) return { result };
   const statusTabId =
     (operation !== "evaluate" && typeof result === "object" && result !== null
       ? (result as { tabId?: PreviewTabId }).tabId
@@ -83,7 +92,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* (
       scope,
       operation: "status",
       input: {},
-      timeoutMs: 500,
+      timeoutMs: metadataTimeoutMs,
       updateCurrentTab: false,
       ...(statusTabId === undefined ? {} : { tabId: statusTabId }),
     })
@@ -188,15 +197,39 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
 });
 
 const handlers = {
+  preview_list_hosts: (input) =>
+    Effect.gen(function* () {
+      const scope = yield* McpInvocationContext.requireMcpCapability("preview");
+      const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
+      const result = yield* broker.listHosts(scope);
+      return input.platform === undefined
+        ? result
+        : { ...result, hosts: result.hosts.filter((host) => host.platform === input.platform) };
+    }) satisfies Effect.Effect<
+      PreviewAutomationHostList,
+      import("@t3tools/contracts").PreviewAutomationError,
+      McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
+    >,
+  preview_select_host: (input) =>
+    Effect.gen(function* () {
+      const scope = yield* McpInvocationContext.requireMcpCapability("preview");
+      const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
+      return yield* broker.selectHost(scope, input.hostId);
+    }) satisfies Effect.Effect<
+      PreviewAutomationHostSelection,
+      import("@t3tools/contracts").PreviewAutomationError,
+      McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
+    >,
   preview_status: (input) => invokeTargeted("status", input ?? {}),
   preview_open: (input) =>
     invokeTargeted("open", normalizePreviewOpenInput(input)),
+  preview_close: (input) => invokeTargeted("close", input ?? {}),
   preview_navigate: (input) =>
     invokeTargeted("navigate", input, input.timeoutMs),
   preview_resize: (input) =>
     invokeTargeted("resize", input, input.timeoutMs),
   preview_set_appearance: (input) =>
-    invokeTargeted("setColorScheme", input),
+    invokeTargeted("setColorScheme", input, input.timeoutMs),
   preview_snapshot: (input) => {
     // Output selection and saving are MCP-only; the browser still produces a complete snapshot.
     const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
@@ -204,10 +237,10 @@ const handlers = {
   },
   preview_click: (input) => invokeTargeted("click", input, input.timeoutMs),
   preview_type: (input) => invokeTargeted("type", input, input.timeoutMs),
-  preview_press: (input) => invokeTargeted("press", input),
-  preview_scroll: (input) => invokeTargeted("scroll", input),
+  preview_press: (input) => invokeTargeted("press", input, input.timeoutMs),
+  preview_scroll: (input) => invokeTargeted("scroll", input, input.timeoutMs),
   preview_evaluate: ({ tabId, ...input }) =>
-    invoke("evaluate", input, undefined, tabId).pipe(
+    invoke("evaluate", input, input.timeoutMs, tabId).pipe(
       Effect.map(({ result, toolIcon }) => ({
         value: result ?? null,
         ...(toolIcon ? { toolIcon } : {}),
@@ -215,7 +248,11 @@ const handlers = {
     ),
   preview_wait_for: (input) => invokeTargeted("waitFor", input, input.timeoutMs),
   preview_recording_start: (input) =>
-    invokeTargeted("recordingStart", input ?? {}),
+    invokeTargeted(
+      "recordingStart",
+      input ?? {},
+      input?.timeoutMs,
+    ),
   preview_recording_stop: (input) =>
     Effect.gen(function* () {
       const scope = yield* McpInvocationContext.requireMcpCapability("preview");
diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts
index 0937fba72c60..b3b1d549edd9 100644
--- a/apps/server/src/mcp/toolkits/preview/tools.test.ts
+++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts
@@ -1,7 +1,7 @@
 import { expect, it } from "@effect/vitest";
 import { Tool } from "effect/unstable/ai";
 
-import { PreviewToolkit } from "./tools.ts";
+import { PreviewToolkit, previewScreenshotUnavailableText } from "./tools.ts";
 
 const schemaHasDescription = (schema: unknown): boolean => {
   if (!schema || typeof schema !== "object") return false;
@@ -43,10 +43,12 @@ it("exports provider-compatible object schemas with described parameters", () =>
     if (tool.name === "preview_navigate") {
       expect(schemaHasMultipleAllOfDescriptions(schema)).toBe(false);
     }
-    expect(
-      schema.properties?.tabId,
-      `${tool.name} must allow an explicit collaborative browser tab target`,
-    ).toBeDefined();
+    if (tool.name !== "preview_list_hosts" && tool.name !== "preview_select_host") {
+      expect(
+        schema.properties?.tabId,
+        `${tool.name} must allow an explicit collaborative browser tab target`,
+      ).toBeDefined();
+    }
     for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) {
       expect(
         schemaHasDescription(fieldSchema),
@@ -75,3 +77,31 @@ it("exports exact object result schemas for preview actions", () => {
     });
   }
 });
+
+it("explains a missing screenshot by stage and host frame liveness", () => {
+  expect(previewScreenshotUnavailableText(undefined)).toBe(
+    "No screenshot: the preview host did not report why capture failed.",
+  );
+  expect(
+    previewScreenshotUnavailableText({
+      stage: "pending-capture",
+      message: "Preview capture is unavailable while a timed-out capture is pending",
+    }),
+  ).toBe(
+    "No screenshot (pending-capture): an earlier interrupted capture is still pending on the host (Preview capture is unavailable while a timed-out capture is pending).",
+  );
+  const paused = previewScreenshotUnavailableText({
+    stage: "interrupted",
+    message: "The native capture did not settle within 3000ms",
+    hostRendering: "paused",
+  });
+  expect(paused).toContain("No screenshot (interrupted)");
+  expect(paused).toContain("not painting frames");
+  expect(
+    previewScreenshotUnavailableText({
+      stage: "capture",
+      message: "UnknownVizError",
+      hostRendering: "active",
+    }),
+  ).toContain("The host window is painting frames.");
+});
diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts
index 1c790e8b643a..2264f51ce9de 100644
--- a/apps/server/src/mcp/toolkits/preview/tools.ts
+++ b/apps/server/src/mcp/toolkits/preview/tools.ts
@@ -3,14 +3,19 @@ import {
   PreviewAutomationClickInput,
   PreviewAutomationError,
   PreviewAutomationEvaluateInput,
+  PreviewAutomationHostList,
+  PreviewAutomationHostListInput,
+  PreviewAutomationHostSelection,
   PreviewAutomationNavigateInput,
   PreviewAutomationOpenInput,
   PreviewAutomationPressInput,
   PreviewAutomationRecordingArtifact,
+  PreviewAutomationRecordingStartInput,
   PreviewAutomationRecordingStatus,
   PreviewAutomationResizeInput,
   PreviewAutomationResizeResult,
   PreviewAutomationScrollInput,
+  PreviewAutomationSelectHostInput,
   PreviewAutomationSetColorSchemeInput,
   PreviewAutomationSetColorSchemeResult,
   PreviewAutomationSnapshot,
@@ -50,6 +55,31 @@ const safeBrowserTool = (tool: T): T =>
 const readonlyBrowserTool = (tool: T): T =>
   safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T;
 
+const PreviewListHostsTool = Tool.make("preview_list_hosts", {
+  description:
+    "List preview automation renderers connected to this caller's environment. Each result has a stable hostId plus a device label and platform; listing hosts does not assign this agent's preview context or disturb any desktop window. Callers with native thread metadata have independent host and current-tab selections; callers without it share their credential's default context.",
+  parameters: PreviewAutomationHostListInput,
+  success: PreviewAutomationHostList,
+  failure: PreviewAutomationError,
+  dependencies,
+})
+  .annotate(Tool.Title, "List preview hosts")
+  .annotate(Tool.Readonly, true)
+  .annotate(Tool.Destructive, false)
+  .annotate(Tool.Idempotent, true);
+
+const PreviewSelectHostTool = Tool.make("preview_select_host", {
+  description:
+    "Bind this agent's unassigned preview context to one available renderer by stable hostId. Selection does not focus or resize a window. Other agents' contexts remain independent, and their tabs stay visible in the main conversation. An assigned context stays on its host across reconnection; selection never migrates existing browser state.",
+  parameters: PreviewAutomationSelectHostInput,
+  success: PreviewAutomationHostSelection,
+  failure: PreviewAutomationError,
+  dependencies,
+})
+  .annotate(Tool.Title, "Select preview host")
+  .annotate(Tool.Destructive, false)
+  .annotate(Tool.Idempotent, true);
+
 const PreviewStatusTool = Tool.make("preview_status", {
   description:
     "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",
@@ -66,7 +96,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
 const PreviewOpenTool = browserTool(
   Tool.make("preview_open", {
     description:
-      "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.",
+      "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Newly created tabs return after server creation while requested presentation and page loading continue; reopening an existing shown tab waits for stable presentation. Wait on the returned tab before interacting while its initial page loads.",
     parameters: PreviewAutomationOpenInput,
     success: PreviewAutomationStatus,
     failure: PreviewAutomationError,
@@ -76,6 +106,19 @@ const PreviewOpenTool = browserTool(
     .annotate(Tool.Destructive, false),
 );
 
+const PreviewCloseTool = browserTool(
+  Tool.make("preview_close", {
+    description:
+      "Close and destroy the collaborative browser tab selected by tabId, or this agent session's current tab when omitted. The operation is idempotent and returns with no tab selected.",
+    parameters: PreviewAutomationTabTargetInput,
+    success: PreviewAutomationStatus,
+    failure: PreviewAutomationError,
+    dependencies,
+  })
+    .annotate(Tool.Title, "Close browser preview")
+    .annotate(Tool.Idempotent, true),
+);
+
 const PreviewNavigateTool = safeBrowserTool(
   Tool.make("preview_navigate", {
     description:
@@ -116,10 +159,37 @@ const PreviewSetAppearanceTool = safeBrowserTool(
     .annotate(Tool.Idempotent, true),
 );
 
+/**
+ * One sentence the agent can act on when a snapshot has no screenshot. Names
+ * the stage the host reported and, when measured, whether the host window is
+ * painting at all, which is the usual cause on an unattended machine.
+ */
+export const previewScreenshotUnavailableText = (
+  reason: PreviewAutomationSnapshot["screenshotUnavailable"],
+): string => {
+  if (reason === undefined) {
+    return "No screenshot: the preview host did not report why capture failed.";
+  }
+  const stage: Record = {
+    capture: "the host's native page capture failed",
+    encode: "the captured image could not be encoded as a PNG",
+    interrupted: "the native page capture did not settle within the screenshot budget",
+    "pending-capture": "an earlier interrupted capture is still pending on the host",
+    budget: "the operation deadline left no time for capture",
+  };
+  const rendering =
+    reason.hostRendering === undefined
+      ? ""
+      : reason.hostRendering === "paused"
+        ? " The host window is not painting frames (idle display, minimized or occluded window); an idle display also stalls captures, presentation waits, and recordings until input resumes."
+        : " The host window is painting frames.";
+  return `No screenshot (${reason.stage}): ${stage[reason.stage]} (${reason.message}).${rendering}`;
+};
+
 export const PreviewSnapshotTool = readonlyBrowserTool(
   Tool.make("preview_snapshot", {
     description:
-      "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
+      "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot when capture is available; when it is not, screenshot is null and screenshotUnavailable says which stage failed and whether the host window is painting. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write an available PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
     parameters: Schema.Struct({
       ...PreviewAutomationTabTargetInput.fields,
       includeImage: Schema.optional(
@@ -223,7 +293,7 @@ const PreviewRecordingStartTool = safeBrowserTool(
   Tool.make("preview_recording_start", {
     description:
       "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.",
-    parameters: PreviewAutomationTabTargetInput,
+    parameters: PreviewAutomationRecordingStartInput,
     success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }),
     failure: PreviewAutomationError,
     dependencies,
@@ -242,8 +312,11 @@ const PreviewRecordingStopTool = safeBrowserTool(
 );
 
 export const PreviewToolkit = Toolkit.make(
+  PreviewListHostsTool,
+  PreviewSelectHostTool,
   PreviewStatusTool,
   PreviewOpenTool,
+  PreviewCloseTool,
   PreviewNavigateTool,
   PreviewResizeTool,
   PreviewSetAppearanceTool,
@@ -259,8 +332,11 @@ export const PreviewToolkit = Toolkit.make(
 );
 
 export const PreviewStandardToolkit = Toolkit.make(
+  PreviewListHostsTool,
+  PreviewSelectHostTool,
   PreviewStatusTool,
   PreviewOpenTool,
+  PreviewCloseTool,
   PreviewNavigateTool,
   PreviewResizeTool,
   PreviewSetAppearanceTool,
diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts
index d1fc142502db..37ff253be921 100644
--- a/apps/server/src/preview/Manager.test.ts
+++ b/apps/server/src/preview/Manager.test.ts
@@ -36,6 +36,29 @@ const collectEvents = Effect.gen(function* () {
 }).pipe(Effect.withSpan("preview.test.collectEvents"));
 
 it.layer(PreviewManager.layer)("PreviewManager", (it) => {
+  it.effect("keeps agent attribution and all agents' tabs in the main conversation", () =>
+    Effect.gen(function* () {
+      const threadId = freshThreadId();
+      const manager = yield* PreviewManager.PreviewManager;
+      const origin = { contextId: "worker-a", hostId: "windows", hostLabel: "Windows" };
+      const first = yield* manager.open({ threadId, automationOrigin: origin });
+      const second = yield* manager.open({
+        threadId,
+        automationOrigin: { ...origin, contextId: "worker-b" },
+      });
+      yield* manager.navigate({ threadId, tabId: first.tabId, url: "https://example.test/" });
+      yield* manager.reportStatus({
+        threadId,
+        tabId: first.tabId,
+        navStatus: { _tag: "Success", url: "https://example.test/", title: "A" },
+        canGoBack: false,
+        canGoForward: false,
+      });
+      const listed = yield* manager.list({ threadId });
+      expect(listed.sessions.map((tab) => tab.tabId)).toEqual([first.tabId, second.tabId]);
+      expect(listed.sessions[0]?.automationOrigin).toEqual(origin);
+    }),
+  );
   it.effect("opens a session and emits opened with normalized URL", () =>
     Effect.gen(function* () {
       const threadId = freshThreadId();
diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts
index a5f5cba9ccd0..a6fe41830ab2 100644
--- a/apps/server/src/preview/Manager.ts
+++ b/apps/server/src/preview/Manager.ts
@@ -227,7 +227,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
       // session is born at the right size; older clients omit it and keep the
       // historical fill-panel behaviour.
       const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT;
-      const snapshot = input.url
+      const initialSnapshot = input.url
         ? buildLoadingSnapshot({
             threadId: input.threadId,
             tabId,
@@ -244,6 +244,10 @@ export const make = Effect.gen(function* PreviewManagerMake() {
             profileId: input.profileId,
             updatedAt,
           });
+      const snapshot = {
+        ...initialSnapshot,
+        ...(input.automationOrigin ? { automationOrigin: input.automationOrigin } : {}),
+      };
       yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
         Effect.gen(function* () {
           const revision = state.revision + 1;
@@ -281,6 +285,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
             session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;
           const resolvedTitle = input.resolvedTitle ?? previousTitle;
           const snapshot: PreviewSessionSnapshot = {
+            ...session.snapshot,
             threadId: session.threadId,
             tabId: session.tabId,
             navStatus: { _tag: "Success", url, title: resolvedTitle },
@@ -317,6 +322,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
       Effect.fn("PreviewManager.reportSessionStatus")(function* (session) {
         const updatedAt = yield* currentIsoTimestamp;
         const snapshot: PreviewSessionSnapshot = {
+          ...session.snapshot,
           threadId: session.threadId,
           tabId: session.tabId,
           navStatus: input.navStatus,
diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
index 35044cd9c169..6e36bad8d74e 100644
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -8,8 +8,13 @@ import { previewBridge } from "~/components/preview/previewBridge";
 import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
 import { useClientSettingsHydrated } from "~/hooks/useSettings";
 import { cn, isMacPlatform } from "~/lib/utils";
+import {
+  selectThreadPreviewMiniPlayerTabId,
+  usePreviewMiniPlayerStore,
+} from "~/previewMiniPlayerStore";
+import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";
 
-import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
+import { selectBrowserSurfaceRenderState, useBrowserSurfaceStore } from "./browserSurfaceStore";
 import { useActiveBrowserRecordingTabIds } from "./browserRecording";
 import {
   browserViewportSettingKey,
@@ -19,7 +24,11 @@ import {
 import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
 import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
 import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
-import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
+import {
+  resolveHostedBrowserWebviewAriaHidden,
+  resolveHostedBrowserWebviewPresentation,
+  resolveHostedBrowserWebviewWrapperStyle,
+} from "./hostedBrowserWebviewStyle";
 import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
 import { useBrowserViewportResize } from "./useBrowserViewportResize";
 import {
@@ -75,20 +84,26 @@ export function HostedBrowserWebview(props: {
   const webviewRef = useRef(null);
   const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE);
   const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
-  const presentation = useBrowserSurfaceStore(
-    useShallow((state) => {
-      const current = state.byTabId[runtimeTabId];
-      return {
-        content: current?.content ?? null,
-        cornerRadius: current?.cornerRadius ?? 0,
-        fitSourceContent: current?.fitSourceContent ?? false,
-        fittedSourceContent: current?.fittedSourceContent ?? null,
-        rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId),
-        visible: current?.visible ?? false,
-        zIndex: current?.zIndex ?? 30,
-      };
-    }),
+  const surface = useBrowserSurfaceStore(
+    useShallow((state) => selectBrowserSurfaceRenderState(state, runtimeTabId)),
+  );
+  const selectedInRightPanel = useRightPanelStore((state) => {
+    const panel = selectThreadRightPanelState(state.byThreadKey, threadRef);
+    return panel.isOpen && panel.activeSurfaceId === `browser:${tabId}`;
+  });
+  const selectedInMiniPlayer = usePreviewMiniPlayerStore(
+    (state) => selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef) === tabId,
   );
+  const presentation = {
+    backgroundCapture: surface.backgroundCapture,
+    content: surface.content,
+    cornerRadius: surface.cornerRadius,
+    fitSourceContent: surface.fitSourceContent,
+    fittedSourceContent: surface.fittedSourceContent,
+    rect: surface.rect,
+    visible: surface.visible,
+    zIndex: surface.zIndex,
+  };
   const backgroundActivity = useBrowserSurfaceStore(
     (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
   );
@@ -179,8 +194,18 @@ export function HostedBrowserWebview(props: {
     };
   }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]);
 
-  const active = presentation.visible && presentation.rect !== null;
-  const lastRect = presentation.rect;
+  const {
+    active,
+    backgroundCapture,
+    rect: presentationRect,
+  } = resolveHostedBrowserWebviewPresentation({
+    backgroundCaptureRequested: presentation.backgroundCapture,
+    rect: presentation.rect,
+    rendererViewport: { width: window.innerWidth, height: window.innerHeight },
+    selected: selectedInRightPanel || selectedInMiniPlayer,
+    surfaceVisible: presentation.visible,
+  });
+  const lastRect = presentationRect;
   const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
   const viewportWidth = viewport._tag === "fill" ? null : viewport.width;
   const viewportHeight = viewport._tag === "fill" ? null : viewport.height;
@@ -207,7 +232,7 @@ export function HostedBrowserWebview(props: {
           width: hiddenContentSize?.width ?? lastRect?.width ?? 1280,
           height: hiddenContentSize?.height ?? lastRect?.height ?? 800,
         };
-  const containerSize = active && lastRect ? lastRect : hiddenSize;
+  const containerSize = (active || backgroundCapture) && lastRect ? lastRect : hiddenSize;
   const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;
   const {
     activeDrag,
@@ -264,9 +289,11 @@ export function HostedBrowserWebview(props: {
 
   if (!clientSettingsHydrated || !config) return null;
 
-  const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
+  const renderingActive =
+    active || backgroundCapture || backgroundActivity || pictureInPicture || recordingActive;
   const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
     active,
+    backgroundCapture,
     renderingActive,
     // Electron 43 can permanently blank a macOS webview after `visibility: hidden`.
     // Inactive macOS guests intentionally remain paintable offscreen; other platforms still
@@ -286,6 +313,7 @@ export function HostedBrowserWebview(props: {
       onScroll={syncContentPresentation}
       data-preview-rendering={renderingActive ? "active" : "suspended"}
       data-preview-viewport={runtimeTabId}
+      data-preview-background-capture={backgroundCapture ? "true" : undefined}
     >
       
{deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( @@ -328,7 +356,7 @@ export function HostedBrowserWebview(props: { ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) : effectiveViewport.height } - aria-hidden={active ? undefined : true} + aria-hidden={resolveHostedBrowserWebviewAriaHidden(active)} className={cn( "absolute flex overflow-hidden bg-white", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index bd38c4f9f69a..25564f2a2e66 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -1,6 +1,9 @@ import { + DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, EnvironmentId, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, + PreviewAutomationRecordingDeadlineExpiredError, ThreadId, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -28,15 +31,23 @@ const { value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, ); }), - save: vi.fn(async (tabId: string) => ({ - id: "recording-test", - tabId, - path: "/tmp/recording-test.webm", - mimeType: "video/webm" as const, - sizeBytes: 0, - createdAt: "2026-06-26T00:00:00.000Z", - })), - startScreencast: vi.fn(async (_tabId: string) => { + save: vi.fn( + async ( + tabId: string, + _mimeType?: string, + _data?: Uint8Array, + _idempotencyKey?: string, + _timeoutMs?: number, + ) => ({ + id: "recording-test", + tabId, + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + }), + ), + startScreencast: vi.fn(async (_tabId: string, _timeoutMs?: number) => { events.push("start-screencast"); }), stopScreencast: vi.fn(async () => undefined), @@ -48,8 +59,9 @@ vi.mock("~/components/preview/previewBridge", () => ({ recording: { onFrame: vi.fn(), save, - startScreencast: async (tabId: string) => { - await startScreencast(tabId); + startScreencast: async (tabId: string, timeoutMs?: number) => { + if (timeoutMs === undefined) await startScreencast(tabId); + else await startScreencast(tabId, timeoutMs); requestDisplayMediaCapture(tabId); }, stopScreencast, @@ -66,6 +78,10 @@ vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => clientSettings, })); +vi.mock("./recordingCompositor", () => ({ + createRecordingCompositor: vi.fn(async () => null), +})); + import { BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, @@ -81,13 +97,23 @@ import { stopBrowserRecordingForUpload, } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { createRecordingCompositor } from "./recordingCompositor"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + class FakeMediaRecorder { static readonly instances: FakeMediaRecorder[] = []; static supportedTypes = new Set(["video/webm;codecs=vp9"]); static outputMimeType: string | undefined; static stopError: unknown; + deferStop = false; static isTypeSupported(type: string): boolean { return this.supportedTypes.has(type); } @@ -119,6 +145,19 @@ class FakeMediaRecorder { stop(): void { if (FakeMediaRecorder.stopError !== undefined) throw FakeMediaRecorder.stopError; this.state = "inactive"; + if (this.deferStop) return; + this.finishStop(); + } + + emitChunk(data: Blob): void { + for (const listener of this.listeners.get("dataavailable") ?? []) { + const event = Object.assign(new Event("dataavailable"), { data }); + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); + } + } + + finishStop(): void { for (const listener of this.listeners.get("stop") ?? []) { if (typeof listener === "function") listener(new Event("stop")); else listener.handleEvent(new Event("stop")); @@ -157,7 +196,11 @@ describe("browser recording", () => { } }); vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } }); - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + useBrowserSurfaceStore.setState({ + activityByTabId: {}, + backgroundCaptureCountByTabId: {}, + byTabId: {}, + }); }); afterEach(() => { @@ -173,6 +216,95 @@ describe("browser recording", () => { expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); + it("waits for the original encoder flush after a stop deadline", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + await startBrowserRecording("flush-retry"); + const recorder = FakeMediaRecorder.instances[0]!; + recorder.deferStop = true; + recorder.emitChunk(new Blob(["first"])); + const first = expect(stopBrowserRecording("flush-retry", 40)).rejects.toMatchObject({ + operation: "stop-deadline", + }); + await vi.advanceTimersByTimeAsync(40); + await first; + const retry = stopBrowserRecording("flush-retry", 100); + await vi.advanceTimersByTimeAsync(1); + expect(save).not.toHaveBeenCalled(); + recorder.emitChunk(new Blob(["last"])); + recorder.finishStop(); + await retry; + expect(new TextDecoder().decode(save.mock.calls[0]![2])).toBe("firstlast"); + }); + + it("releases startup state when settings hydration exceeds its deadline", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const hydration = deferred(); + vi.mocked(ensureClientSettingsHydrated).mockReturnValueOnce(hydration.promise); + const started = expect( + startBrowserRecording("hydration-timeout", null, "hydration-timeout", 40), + ).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError); + await vi.advanceTimersByTimeAsync(40); + await started; + expect(readActiveBrowserRecordingTabIds().has("hydration-timeout")).toBe(false); + expect(useBrowserSurfaceStore.getState().activityByTabId["hydration-timeout"]).toBeUndefined(); + hydration.resolve(); + await vi.advanceTimersByTimeAsync(1); + expect(startScreencast).not.toHaveBeenCalled(); + }); + + it("bounds paint readiness by a short startup deadline", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 1), + ); + const started = expect( + startBrowserRecording("paint-timeout", null, "paint-timeout", 40), + ).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError); + await vi.advanceTimersByTimeAsync(40); + await started; + expect(readActiveBrowserRecordingTabIds().has("paint-timeout")).toBe(false); + expect(startScreencast).not.toHaveBeenCalled(); + }); + + it("cancels a queued grant when its startup deadline expires", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const blocked = deferred(); + startScreencast.mockImplementationOnce(() => blocked.promise); + const first = startBrowserRecording("grant-owner"); + await vi.advanceTimersByTimeAsync(0); + const queued = expect( + startBrowserRecording("grant-timeout", null, "grant-timeout", 40), + ).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError); + await vi.advanceTimersByTimeAsync(40); + await queued; + expect(readActiveBrowserRecordingTabIds().has("grant-timeout")).toBe(false); + blocked.resolve(); + await first; + await stopBrowserRecording("grant-owner"); + expect(startScreencast.mock.calls.map(([tabId]) => tabId)).toEqual(["grant-owner"]); + }); + + it("clamps desktop stop and save waits to the desktop bound for a longer stop budget", async () => { + await startBrowserRecording("recording-tab"); + + await expect( + stopBrowserRecording("recording-tab", PREVIEW_RECORDING_STOP_TIMEOUT_MS), + ).resolves.toMatchObject({ path: "/tmp/recording-test.webm" }); + + expect(stopScreencast).toHaveBeenCalledWith( + "recording-tab", + DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS, + ); + expect(save).toHaveBeenCalledWith( + "recording-tab", + expect.any(String), + expect.any(Uint8Array), + expect.any(String), + DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS, + ); + }); + it("routes gesture-free starts through the desktop capture trigger", async () => { await startBrowserRecording("automation-recording-tab"); @@ -210,17 +342,131 @@ describe("browser recording", () => { expect(transfer).toHaveBeenCalledOnce(); }); - it("keeps the saved desktop file and releases the recording when transfer fails", async () => { + it("retries a failed transfer without saving again or retaining native capture", async () => { await startBrowserRecording("failed-transfer-tab"); + const transfer = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("Connection interrupted")) + .mockResolvedValueOnce("uploaded-recording"); + await expect(stopBrowserRecordingForUpload("failed-transfer-tab", transfer)).rejects.toThrow( + "Connection interrupted", + ); + expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(true); + expect( + useBrowserSurfaceStore.getState().activityByTabId["failed-transfer-tab"], + ).toBeUndefined(); await expect( - stopBrowserRecordingForUpload("failed-transfer-tab", async () => { - throw new Error("Connection interrupted"); - }), - ).rejects.toThrow("Connection interrupted"); + stopBrowserRecordingForUpload("failed-transfer-tab", transfer), + ).resolves.toMatchObject({ + uploadedAttachmentId: "uploaded-recording", + }); expect(save).toHaveBeenCalledOnce(); + expect(stopScreencast).toHaveBeenCalledOnce(); + expect(transfer).toHaveBeenCalledTimes(2); expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(false); - await startBrowserRecording("failed-transfer-tab"); - await stopBrowserRecording("failed-transfer-tab"); + }); + + it("bounds a pending upload and lets a retry join it without uploading twice", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + await startBrowserRecording("pending-transfer-tab"); + const upload = deferred(); + const started = deferred(); + const transfer = vi.fn(() => { + started.resolve(); + return upload.promise; + }); + const firstStop = stopBrowserRecordingForUpload("pending-transfer-tab", transfer, 40); + const rejected = expect(firstStop).rejects.toMatchObject({ operation: "stop-deadline" }); + await started.promise; + await vi.advanceTimersByTimeAsync(40); + await rejected; + expect(readActiveBrowserRecordingTabIds().has("pending-transfer-tab")).toBe(true); + const retry = stopBrowserRecordingForUpload("pending-transfer-tab", transfer, 100); + upload.resolve("uploaded-once"); + await expect(retry).resolves.toMatchObject({ uploadedAttachmentId: "uploaded-once" }); + expect(transfer).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds().has("pending-transfer-tab")).toBe(false); + }); + + it("shares a fresh transfer when concurrent retries join an expired upload", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + await startBrowserRecording("expired-transfer-tab"); + const mint = deferred(); + const started = deferred(); + const firstDeadline = Date.now() + 40; + const firstTransfer = vi.fn(async () => { + started.resolve(); + await mint.promise; + if (Date.now() >= firstDeadline) { + throw new PreviewAutomationRecordingDeadlineExpiredError({ + threadId: ThreadId.make("thread"), + }); + } + return "first-upload"; + }); + const firstStop = stopBrowserRecordingForUpload("expired-transfer-tab", firstTransfer, 40); + const rejected = expect(firstStop).rejects.toMatchObject({ operation: "stop-deadline" }); + await started.promise; + await vi.advanceTimersByTimeAsync(40); + await rejected; + const transfer = vi.fn(async () => "fresh-upload"); + const retries = [ + stopBrowserRecordingForUpload("expired-transfer-tab", transfer, 100), + stopBrowserRecordingForUpload("expired-transfer-tab", transfer, 100), + ]; + // Let both stop calls join the old transfer before its delayed mint completes. + await vi.advanceTimersByTimeAsync(0); + mint.resolve(); + await expect(Promise.all(retries)).resolves.toEqual([ + expect.objectContaining({ uploadedAttachmentId: "fresh-upload" }), + expect.objectContaining({ uploadedAttachmentId: "fresh-upload" }), + ]); + expect(firstTransfer).toHaveBeenCalledOnce(); + expect(transfer).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds().has("expired-transfer-tab")).toBe(false); + }); + + it("keeps a late upload success for the next stop request", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + await startBrowserRecording("late-transfer-tab"); + const upload = deferred(); + const started = deferred(); + const transfer = vi.fn(() => { + started.resolve(); + return upload.promise; + }); + const firstStop = stopBrowserRecordingForUpload("late-transfer-tab", transfer, 40); + const rejected = expect(firstStop).rejects.toMatchObject({ operation: "stop-deadline" }); + await started.promise; + await vi.advanceTimersByTimeAsync(40); + await rejected; + upload.resolve("late-upload"); + await upload.promise; + expect(readActiveBrowserRecordingTabIds().has("late-transfer-tab")).toBe(true); + await expect( + stopBrowserRecordingForUpload("late-transfer-tab", transfer, 100), + ).resolves.toMatchObject({ + uploadedAttachmentId: "late-upload", + }); + expect(transfer).toHaveBeenCalledOnce(); + }); + + it("allows a local stop to release a failed transfer before starting another recording", async () => { + await startBrowserRecording("local-transfer-tab"); + await expect( + stopBrowserRecordingForUpload("local-transfer-tab", async () => { + throw new Error("Offline"); + }), + ).rejects.toThrow("Offline"); + await expect(stopBrowserRecording("local-transfer-tab")).resolves.toMatchObject({ + path: "/tmp/recording-test.webm", + }); + expect(save).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds().has("local-transfer-tab")).toBe(false); + await startBrowserRecording("local-transfer-tab"); + await stopBrowserRecording("local-transfer-tab"); }); it("paints and holds a hidden browser surface for the recording lifetime", async () => { @@ -242,7 +488,7 @@ describe("browser recording", () => { }); it("bounds compositor warmup when animation frames are paused", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); const cancelAnimationFrame = vi.fn(); vi.stubGlobal( "requestAnimationFrame", @@ -363,6 +609,7 @@ describe("browser recording", () => { "recording-tab", "video/webm;codecs=av01", expect.any(Uint8Array), + expect.any(String), ); }); @@ -378,6 +625,7 @@ describe("browser recording", () => { "recording-tab", "video/platform-default", expect.any(Uint8Array), + expect.any(String), ); }); @@ -407,7 +655,7 @@ describe("browser recording", () => { }); it("times out stalled stream acquisition and stops a late stream", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); let finishCapture!: (stream: MediaStream) => void; const stopTrack = vi.fn(); getDisplayMedia.mockImplementationOnce( @@ -440,6 +688,111 @@ describe("browser recording", () => { expect(stopTrack).toHaveBeenCalledOnce(); }); + it("clamps stream acquisition and cleanup to the caller's startup deadline", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + let observeCaptureStarted: (() => void) | undefined; + const captureStarted = new Promise((resolve) => { + observeCaptureStarted = resolve; + }); + getDisplayMedia.mockImplementationOnce(() => { + observeCaptureStarted?.(); + return new Promise(() => undefined); + }); + + const startPromise = startBrowserRecording("recording-tab", null, "recording-tab", 40); + await captureStarted; + expect(getDisplayMedia).toHaveBeenCalledOnce(); + const rejection = expect(startPromise).rejects.toMatchObject({ + _tag: "BrowserRecordingCaptureTimeoutError", + tabId: "recording-tab", + timeoutMs: 40, + }); + await vi.advanceTimersByTimeAsync(40); + await vi.advanceTimersByTimeAsync(0); + + await rejection; + expect(startScreencast).toHaveBeenCalledWith("recording-tab", 40); + expect(stopScreencast).toHaveBeenCalledWith("recording-tab", 1); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + }); + + it("bounds compositor initialization and disposes a late result without disturbing a retry", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const initialized = deferred>>(); + const entered = deferred(); + const stopTrack = vi.fn(); + const dispose = vi.fn(); + const stream = { + getVideoTracks: () => [], + getTracks: () => [{ stop: stopTrack }], + } as unknown as MediaStream; + getDisplayMedia.mockResolvedValueOnce(stream); + vi.mocked(createRecordingCompositor).mockImplementationOnce(() => { + entered.resolve(); + return initialized.promise; + }); + const start = startBrowserRecording("compositor-timeout", null, "compositor-timeout", 40); + const rejected = expect(start).rejects.toMatchObject({ + _tag: "BrowserRecordingCaptureTimeoutError", + timeoutMs: 40, + }); + await entered.promise; + await vi.advanceTimersByTimeAsync(40); + await rejected; + expect(vi.mocked(createRecordingCompositor).mock.calls[0]?.[3]?.aborted).toBe(true); + expect(stopTrack).toHaveBeenCalled(); + expect(stopScreencast).toHaveBeenCalledWith("compositor-timeout", 1); + expect(FakeMediaRecorder.instances).toHaveLength(0); + expect(readActiveBrowserRecordingTabIds().has("compositor-timeout")).toBe(false); + expect(useBrowserSurfaceStore.getState().activityByTabId["compositor-timeout"]).toBeUndefined(); + + await startBrowserRecording("compositor-timeout"); + initialized.resolve({ stream, dispose }); + await vi.advanceTimersByTimeAsync(0); + expect(dispose).toHaveBeenCalledOnce(); + expect(FakeMediaRecorder.instances).toHaveLength(1); + expect(FakeMediaRecorder.instances[0]?.state).toBe("recording"); + expect(readActiveBrowserRecordingTabIds().has("compositor-timeout")).toBe(true); + await stopBrowserRecording("compositor-timeout"); + }); + + it.each(["stalled", "failed"])( + "preserves diagnostics when startup cleanup is %s", + async (mode) => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const grant = deferred(); + const cleanup = deferred(); + const nativeFailure = new Error("native stop failed"); + startScreencast.mockImplementationOnce(() => grant.promise); + stopScreencast.mockImplementationOnce(() => + mode === "stalled" ? cleanup.promise : Promise.reject(nativeFailure), + ); + const result = startBrowserRecording("cleanup-deadline", null, "cleanup-deadline", 40).catch( + (cause: unknown) => cause, + ); + await vi.advanceTimersByTimeAsync(0); + expect(startScreencast).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(41); + const cause = await result; + if (mode === "stalled") { + expect(cause).toBeInstanceOf(BrowserRecordingCaptureTimeoutError); + } else { + expect(cause).toMatchObject({ + _tag: "BrowserRecordingOperationError", + operation: "cleanup", + cause: expect.objectContaining({ + cause: expect.any(BrowserRecordingCaptureTimeoutError), + errors: [expect.any(BrowserRecordingCaptureTimeoutError), nativeFailure], + }), + }); + } + expect(readActiveBrowserRecordingTabIds().has("cleanup-deadline")).toBe(false); + cleanup.resolve(undefined); + grant.resolve(); + await vi.advanceTimersByTimeAsync(0); + }, + ); + it("records separate tabs concurrently", async () => { const firstThreadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -684,6 +1037,143 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledOnce(); }); + it("retains captured chunks when a bounded stop expires so finalization can be retried", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + let finishStoppingScreencast: (() => void) | undefined; + let observeStopStarted: (() => void) | undefined; + const stopStarted = new Promise((resolve) => { + observeStopStarted = resolve; + }); + stopScreencast.mockImplementationOnce(async () => { + observeStopStarted?.(); + await new Promise((resolve) => { + finishStoppingScreencast = resolve; + }); + }); + await startBrowserRecording("recording-tab"); + + const stopPromise = stopBrowserRecording("recording-tab", 40); + const rejection = expect(stopPromise).rejects.toMatchObject({ + operation: "stop-deadline", + tabId: "recording-tab", + }); + await stopStarted; + expect(stopScreencast).toHaveBeenCalledWith("recording-tab", 40); + await vi.advanceTimersByTimeAsync(40); + + await rejection; + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab"])); + + finishStoppingScreencast?.(); + await vi.advanceTimersByTimeAsync(0); + await expect(stopBrowserRecording("recording-tab")).resolves.toMatchObject({ + tabId: "recording-tab", + }); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + }); + + it("shares an in-flight artifact save with a retry after the renderer deadline", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + let finishSaving: ((artifact: Awaited>) => void) | undefined; + let observeSaveStarted: (() => void) | undefined; + const saveStarted = new Promise((resolve) => { + observeSaveStarted = resolve; + }); + save.mockImplementationOnce(async () => { + observeSaveStarted?.(); + return await new Promise>>((resolve) => { + finishSaving = resolve; + }); + }); + await startBrowserRecording("recording-tab"); + + const firstStop = stopBrowserRecording("recording-tab", 40); + const rejection = expect(firstStop).rejects.toMatchObject({ operation: "stop-deadline" }); + await saveStarted; + await vi.advanceTimersByTimeAsync(40); + await rejection; + + const retry = stopBrowserRecording("recording-tab"); + expect(save).toHaveBeenCalledOnce(); + finishSaving?.({ + id: "recording-test", + tabId: "recording-tab", + path: "/tmp/recording-test.webm", + mimeType: "video/webm", + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + }); + + await expect(retry).resolves.toMatchObject({ id: "recording-test" }); + expect(save).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + }); + + it.each([ + ["tagged", { _tag: "PreviewAutomationTimeoutError", tabId: "recording-tab", timeoutMs: 40 }], + [ + "message-only", + new Error("Preview automation stop-recording timed out after 40ms in tab recording-tab"), + ], + [ + "Electron invoke", + new Error( + "Error invoking remote method 'desktop:preview-recording-stop': PreviewAutomationTimeoutError: Preview automation stop-recording timed out after 40ms in tab recording-tab", + ), + ], + ])("keeps recording state retryable after a %s desktop stop timeout", async (_, error) => { + stopScreencast.mockRejectedValueOnce(error); + await startBrowserRecording("recording-tab"); + + await expect(stopBrowserRecording("recording-tab", 40)).rejects.toMatchObject({ + operation: "stop-deadline", + tabId: "recording-tab", + }); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab"])); + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + await expect(stopBrowserRecording("recording-tab")).resolves.toMatchObject({ + tabId: "recording-tab", + }); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + }); + + it.each([ + ["tagged", { _tag: "PreviewAutomationTimeoutError", tabId: "recording-tab", timeoutMs: 40 }], + [ + "message-only", + new Error("Preview automation save-recording timed out after 40ms in tab recording-tab"), + ], + [ + "Electron invoke", + new Error( + "Error invoking remote method 'desktop:preview-recording-save': PreviewAutomationTimeoutError: Preview automation save-recording timed out after 40ms in tab recording-tab", + ), + ], + ])("reuses the artifact idempotency key after a %s desktop save timeout", async (_, error) => { + save.mockRejectedValueOnce(error); + await startBrowserRecording("recording-tab"); + + await expect(stopBrowserRecording("recording-tab", 40)).rejects.toMatchObject({ + operation: "stop-deadline", + tabId: "recording-tab", + }); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab"])); + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + await expect(stopBrowserRecording("recording-tab")).resolves.toMatchObject({ + tabId: "recording-tab", + }); + expect(save).toHaveBeenCalledTimes(2); + expect(save.mock.calls[0]?.[3]).toEqual(expect.any(String)); + expect(save.mock.calls[1]?.[3]).toBe(save.mock.calls[0]?.[3]); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + }); + it("finishes startup before stopping so an active recording yields an artifact", async () => { let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { @@ -766,7 +1256,7 @@ describe("browser recording", () => { }); it("fails a stop that waits too long for startup without freeing the recording slot", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 0bbe4bd7bf18..8149275e9749 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,4 +1,8 @@ -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import { + DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS, + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, + PreviewAutomationRecordingDeadlineExpiredError, +} from "@t3tools/contracts"; import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; @@ -6,12 +10,15 @@ import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; +import { randomUUID } from "~/lib/utils"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { createRecordingCompositor } from "./recordingCompositor"; import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; +const isRecordingUploadDeadlineExpired = Schema.is(PreviewAutomationRecordingDeadlineExpiredError); + export class BrowserRecordingUnavailableError extends Schema.TaggedError()( "BrowserRecordingUnavailableError", { @@ -63,7 +70,7 @@ export class BrowserRecordingCaptureTimeoutError extends Schema.TaggedError; @@ -122,10 +131,14 @@ interface ActiveRecording { readonly chunks: Blob[]; readonly startedAt: string; readonly startupSettled: Promise; + readonly artifactSaveKey: string; + artifactSave: Promise | null; releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; compositor: Awaited>; + recorderStopped: Promise | null; + retainForUpload: boolean; savedBlob?: Blob; uploadPromise?: Promise; lifecycle: BrowserRecordingLifecycle; @@ -354,6 +367,7 @@ Object.defineProperty(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, { const captureTabMediaStreamWithTimeout = async ( tabId: string, capturePromise: Promise, + timeoutMs = BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, ): Promise => { let acceptStream = true; let timeoutId: number | null = null; @@ -370,10 +384,10 @@ const captureTabMediaStreamWithTimeout = async ( reject( new BrowserRecordingCaptureTimeoutError({ tabId, - timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, + timeoutMs, }), ), - BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, + timeoutMs, ); }), ]); @@ -393,7 +407,9 @@ const clearActiveRecording = (recording: ActiveRecording): void => { publishActiveRecordingTabIds(); }; -const waitForBrowserRecordingPaint = async (): Promise => { +const waitForBrowserRecordingPaint = async ( + timeoutMs = BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS, +): Promise => { let firstFrameId: number | null = null; let secondFrameId: number | null = null; let timeoutId: number | null = null; @@ -407,7 +423,10 @@ const waitForBrowserRecordingPaint = async (): Promise => { }); }); const timedOut = new Promise((resolve) => { - timeoutId = window.setTimeout(resolve, BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS); + timeoutId = window.setTimeout( + resolve, + Math.min(BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS, timeoutMs), + ); }); try { await Promise.race([painted, timedOut]); @@ -421,12 +440,36 @@ const waitForBrowserRecordingPaint = async (): Promise => { const cleanupFailedRecordingStart = async ( bridge: NonNullable, recording: ActiveRecording, + deadline: number | null, + options: { readonly ignoreDeadlineExpiry?: boolean } = {}, ): Promise => { const errors: unknown[] = []; + const deadlineError = new Error( + `Browser recording startup cleanup exceeded its deadline for tab ${recording.tabId}.`, + ); try { - await bridge.recording.stopScreencast(recording.tabId); + const remainingMs = deadline === null ? undefined : Math.max(0, deadline - Date.now()); + const stop = + remainingMs === undefined + ? bridge.recording.stopScreencast(recording.tabId) + : bridge.recording.stopScreencast(recording.tabId, Math.max(1, remainingMs)); + if (remainingMs === undefined) { + await stop; + } else { + let timeout: ReturnType | null = null; + try { + await Promise.race([ + stop, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(deadlineError), remainingMs); + }), + ]); + } finally { + if (timeout !== null) clearTimeout(timeout); + } + } } catch (error) { - errors.push(error); + if (!options.ignoreDeadlineExpiry || error !== deadlineError) errors.push(error); } try { await stopMediaRecorder(recording.recorder); @@ -487,13 +530,124 @@ const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Prom const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; +const recordingStopDeadlineError = ( + tabId: string, + cause: unknown = new Error(`Browser recording stop exceeded its deadline for tab ${tabId}.`), +): BrowserRecordingOperationError => + new BrowserRecordingOperationError({ + operation: "stop-deadline", + tabId, + cause, + }); + +export const isBrowserRecordingStopDeadlineError = ( + error: unknown, +): error is BrowserRecordingOperationError => + isBrowserRecordingOperationError(error) && error.operation === "stop-deadline"; + +const isDesktopRecordingTimeout = (error: unknown): boolean => { + const seen = new Set(); + let current = error; + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + if ( + ("_tag" in current && + (current as { readonly _tag?: unknown })._tag === "PreviewAutomationTimeoutError") || + ("name" in current && + (current as { readonly name?: unknown }).name === "PreviewAutomationTimeoutError") + ) { + return true; + } + // Electron invoke errors retain the message, but can lose the desktop error's tag and name. + if ( + "message" in current && + typeof current.message === "string" && + /Preview automation (?:stop|save)-recording timed out after \d+ms in tab /.test( + current.message, + ) + ) { + return true; + } + current = "cause" in current ? (current as { readonly cause?: unknown }).cause : undefined; + } + return false; +}; + +const remainingRecordingStopBudget = ( + deadline: number | null, + tabId: string, +): number | undefined => { + if (deadline === null) return undefined; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw recordingStopDeadlineError(tabId); + return remainingMs; +}; + +/** Desktop IPC rejects longer waits; the renderer deadline still bounds the whole stop. */ +const remainingDesktopRecordingStopBudget = ( + deadline: number | null, + tabId: string, +): number | undefined => { + const remainingMs = remainingRecordingStopBudget(deadline, tabId); + return remainingMs === undefined + ? undefined + : Math.min(remainingMs, DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS); +}; + +const awaitWithinRecordingStopDeadline = async ( + promise: Promise, + deadline: number | null, + tabId: string, +): Promise => { + const remainingMs = remainingRecordingStopBudget(deadline, tabId); + if (remainingMs === undefined) return await promise; + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(recordingStopDeadlineError(tabId)), remainingMs); + }), + ]); + } finally { + if (timeout !== null) clearTimeout(timeout); + } +}; + export async function startBrowserRecording( tabId: string, threadRef: ScopedThreadRef | null = null, serverTabId = tabId, + timeoutMs?: number, ): Promise { const bridge = previewBridge; if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); + const deadline = timeoutMs === undefined ? null : Date.now() + timeoutMs; + const remainingStartupBudget = (): number | undefined => + deadline === null ? undefined : Math.max(0, deadline - Date.now()); + const startupDeadlineError = new BrowserRecordingCaptureTimeoutError({ + tabId, + timeoutMs: timeoutMs ?? 0, + }); + const awaitStartup = async (promise: Promise): Promise => { + const remainingMs = remainingStartupBudget(); + if (remainingMs === undefined) return await promise; + if (remainingMs <= 0) { + void promise.catch(() => undefined); + throw startupDeadlineError; + } + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(startupDeadlineError), remainingMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + }; const activeRecording = activeRecordings.get(tabId); if (activeRecording) { if (activeRecording.lifecycle.phase === "recording") { @@ -527,28 +681,36 @@ export async function startBrowserRecording( chunks, startedAt, startupSettled, + artifactSaveKey: randomUUID(), + artifactSave: null, + retainForUpload: false, releaseSurfaceActivity, stream: null, recorder: null, compositor: null, + recorderStopped: null, lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); + let cancelCapture: (() => void) | undefined; try { - await ensureClientSettingsHydrated().catch((cause: unknown) => { + await awaitStartup(ensureClientSettingsHydrated()).catch((cause: unknown) => { clearActiveRecording(recording); throw cause; }); const settings = getClientSettings(); const frameRate = settings.browserRecordingFrameRate; - await waitForBrowserRecordingPaint(); + await awaitStartup(waitForBrowserRecordingPaint(remainingStartupBudget())); + if (remainingStartupBudget() === 0) throw startupDeadlineError; const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. // Only a contended start can be cancelled before it reaches native capture. if (activeRecordings.get(tabId) === recording) return; try { - await bridge.recording.stopScreencast(tabId); + const stopBudget = remainingStartupBudget(); + if (stopBudget === undefined) await bridge.recording.stopScreencast(tabId); + else await bridge.recording.stopScreencast(tabId, Math.max(1, stopBudget)); } catch (cause) { throw recordingStartupCancelledError( recording, @@ -570,8 +732,11 @@ export async function startBrowserRecording( startingLifecycle.grantStarted = true; await throwIfStartupCancelled(); const capture = prepareTabMediaCapture(tabId, frameRate); + cancelCapture = capture.cancel; try { - await bridge.recording.startScreencast(tabId); + const startBudget = remainingStartupBudget(); + if (startBudget === undefined) await bridge.recording.startScreencast(tabId); + else await bridge.recording.startScreencast(tabId, Math.max(1, startBudget)); } catch (cause) { capture.cancel(); if (!isRecordingStarting(recording)) { @@ -591,10 +756,17 @@ export async function startBrowserRecording( throw cause; } try { - recording.stream = await captureTabMediaStreamWithTimeout(tabId, capture.capturePromise); + const captureBudget = remainingStartupBudget(); + recording.stream = await captureTabMediaStreamWithTimeout( + tabId, + capture.capturePromise, + captureBudget === undefined + ? BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS + : Math.max(1, Math.min(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, captureBudget)), + ); return recording.stream; } catch (cause) { - const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording, deadline); if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; throw new BrowserRecordingOperationError({ operation: "capture-media-stream", @@ -611,17 +783,20 @@ export async function startBrowserRecording( } }); startingLifecycle.setQueuedForGrant(grant.queued); - const stream = await Promise.race([ - grant.result, - startingLifecycle.cancelledBeforeGrantSignal.then(() => { - throw new BrowserRecordingStartCancelledError({ tabId }); - }), - ]); + const stream = await awaitStartup( + Promise.race([ + grant.result, + startingLifecycle.cancelledBeforeGrantSignal.then(() => { + throw new BrowserRecordingStartCancelledError({ tabId }); + }), + ]), + ); await throwIfStartupCancelled(); let recorder: MediaRecorder; try { - recording.compositor = await createRecordingCompositor( + const compositorAbort = new AbortController(); + const compositor = createRecordingCompositor( stream, { showKeyPresses: settings.browserRecordingShowKeyPresses, @@ -632,14 +807,27 @@ export async function startBrowserRecording( bridge.recording.onInput((event) => { if (event.tabId === tabId) listener(event.input); }), + compositorAbort.signal, ); + try { + recording.compositor = await awaitStartup(compositor); + } catch (cause) { + compositorAbort.abort(); + // Initialization may finish after the startup slot has been released. + void compositor.then( + (late) => late?.dispose(), + () => undefined, + ); + throw cause; + } recorder = createMediaRecorder(recording.compositor?.stream ?? stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); }); } catch (cause) { - const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + if (cause === startupDeadlineError) throw cause; + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording, deadline); throw new BrowserRecordingOperationError({ operation: "initialize-media-recorder", tabId, @@ -656,7 +844,7 @@ export async function startBrowserRecording( try { recorder.start(1_000); } catch (cause) { - const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording, deadline); throw new BrowserRecordingOperationError({ operation: "start-media-recorder", tabId, @@ -674,6 +862,31 @@ export async function startBrowserRecording( recording.lifecycle = { phase: "recording" }; } return startedAt; + } catch (cause) { + if (cause === startupDeadlineError) { + startingLifecycle.cancelBeforeGrant(); + cancelCapture?.(); + if (startingLifecycle.grantStarted) { + // The startup budget is already exhausted; only genuine cleanup failures add diagnostics. + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording, deadline, { + ignoreDeadlineExpiry: true, + }); + if (cleanupCause !== undefined) { + throw new BrowserRecordingOperationError({ + operation: "cleanup", + tabId, + cause: new AggregateError( + [cause, cleanupCause], + `Browser recording startup and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + } else { + clearActiveRecording(recording); + } + } + throw cause; } finally { settleStartup?.(); } @@ -682,6 +895,7 @@ export async function startBrowserRecording( const finalizeBrowserRecording = async ( bridge: NonNullable, recording: ActiveRecording, + deadline: number | null, ): Promise => { const { tabId } = recording; let result: @@ -691,10 +905,23 @@ const finalizeBrowserRecording = async ( } | { readonly _tag: "Failure"; readonly error: unknown }; try { - await waitForRecordingStartupToSettle(recording); + await awaitWithinRecordingStopDeadline( + waitForRecordingStartupToSettle(recording), + deadline, + tabId, + ); try { - await bridge.recording.stopScreencast(tabId); + const stopBudget = remainingDesktopRecordingStopBudget(deadline, tabId); + await awaitWithinRecordingStopDeadline( + stopBudget === undefined + ? bridge.recording.stopScreencast(tabId) + : bridge.recording.stopScreencast(tabId, stopBudget), + deadline, + tabId, + ); } catch (cause) { + if (isBrowserRecordingStopDeadlineError(cause)) throw cause; + if (isDesktopRecordingTimeout(cause)) throw recordingStopDeadlineError(tabId, cause); throw new BrowserRecordingOperationError({ operation: "stop-screencast", tabId, @@ -705,8 +932,13 @@ const finalizeBrowserRecording = async ( result = { _tag: "Success", artifact: null }; } else { try { - await stopMediaRecorder(recording.recorder); + await awaitWithinRecordingStopDeadline( + (recording.recorderStopped ??= stopMediaRecorder(recording.recorder)), + deadline, + tabId, + ); } catch (cause) { + if (isBrowserRecordingStopDeadlineError(cause)) throw cause; throw new BrowserRecordingOperationError({ operation: "stop-media-recorder", tabId, @@ -726,14 +958,29 @@ const finalizeBrowserRecording = async ( } try { const blob = new Blob(recording.chunks, { type: mimeType }); - const artifact = await bridge.recording.save( - tabId, - mimeType, - new Uint8Array(await blob.arrayBuffer()), - ); + let artifactSave = recording.artifactSave; + if (!artifactSave) { + const data = new Uint8Array( + await awaitWithinRecordingStopDeadline(blob.arrayBuffer(), deadline, tabId), + ); + const saveBudget = remainingDesktopRecordingStopBudget(deadline, tabId); + const saveOperation = + saveBudget === undefined + ? bridge.recording.save(tabId, mimeType, data, recording.artifactSaveKey) + : bridge.recording.save(tabId, mimeType, data, recording.artifactSaveKey, saveBudget); + const trackedSave = saveOperation.catch((cause) => { + if (recording.artifactSave === trackedSave) recording.artifactSave = null; + throw cause; + }); + recording.artifactSave = trackedSave; + artifactSave = trackedSave; + } + const artifact = await awaitWithinRecordingStopDeadline(artifactSave, deadline, tabId); recording.savedBlob = blob; result = { _tag: "Success", artifact }; } catch (cause) { + if (isBrowserRecordingStopDeadlineError(cause)) throw cause; + if (isDesktopRecordingTimeout(cause)) throw recordingStopDeadlineError(tabId, cause); throw new BrowserRecordingOperationError({ operation: "save-artifact", tabId, @@ -745,17 +992,19 @@ const finalizeBrowserRecording = async ( result = { _tag: "Failure", error }; } - if (result._tag === "Failure" && isStartupWaitTimeout(result.error)) { - // Do not clear `active` yet. The renderer-side start promise can still - // resolve later, and its cancellation path will call `stopScreencast`. - // Keeping the slot reserved prevents a newer recording for this tab from - // being started and then accidentally stopped by the older late cleanup. + if ( + result._tag === "Failure" && + (isStartupWaitTimeout(result.error) || isBrowserRecordingStopDeadlineError(result.error)) + ) { + // Keep the slot and captured chunks available. Startup may still need its + // cancellation cleanup, while a deadline failure can be retried to finish + // saving an already-stopped MediaRecorder without losing its artifact. throw result.error; } const cleanupErrors: unknown[] = []; try { - await stopMediaRecorder(recording.recorder); + await (recording.recorderStopped ??= stopMediaRecorder(recording.recorder)); } catch (cause) { cleanupErrors.push(cause); } @@ -764,7 +1013,11 @@ const finalizeBrowserRecording = async ( } catch (cause) { cleanupErrors.push(cause); } finally { - clearActiveRecording(recording); + recording.releaseSurfaceActivity?.(); + recording.releaseSurfaceActivity = null; + if (result._tag === "Failure" || !result.artifact || !recording.retainForUpload) { + clearActiveRecording(recording); + } } const cleanupError = cleanupErrors.length === 0 @@ -796,7 +1049,10 @@ const finalizeBrowserRecording = async ( } throw result.error; } - if (cleanupError) throw cleanupError; + if (cleanupError) { + clearActiveRecording(recording); + throw cleanupError; + } return result.artifact; }; @@ -814,18 +1070,37 @@ const discardBrowserRecording = async ( } }; -export function stopBrowserRecording( +function stopRecording( tabId: string, + timeoutMs: number | undefined, + retainForUpload: boolean, ): Promise { const bridge = previewBridge; const recording = activeRecordings.get(tabId); if (!bridge || !recording) return Promise.resolve(null); - if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; + const deadline = timeoutMs === undefined ? null : Date.now() + timeoutMs; + recording.retainForUpload = retainForUpload; + if (recording.lifecycle.phase === "stopping") { + return awaitWithinRecordingStopDeadline(recording.lifecycle.stopPromise, deadline, tabId).then( + (artifact) => { + if (!recording.retainForUpload) clearActiveRecording(recording); + return artifact; + }, + ); + } if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); const stopPromise = Promise.resolve() - .then(() => finalizeBrowserRecording(bridge, recording)) + .then(() => finalizeBrowserRecording(bridge, recording, deadline)) .catch((error) => { + if ( + isBrowserRecordingStopDeadlineError(error) && + activeRecordings.get(recording.tabId) === recording && + recording.lifecycle.phase === "stopping" && + recording.lifecycle.stopPromise === stopPromise + ) { + recording.lifecycle = { phase: "stop-retryable" }; + } if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { const cleanupAfterStartup = recording.startupSettled.then(() => discardBrowserRecording(bridge, recording), @@ -839,15 +1114,60 @@ export function stopBrowserRecording( return stopPromise; } -/** Joins local stops and shares one upload among concurrent automation requests. */ +export function stopBrowserRecording( + tabId: string, + timeoutMs?: number, +): Promise { + return stopRecording(tabId, timeoutMs, false); +} + +/** Retains the saved file until a transfer succeeds; retries join an unsettled upload. */ export async function stopBrowserRecordingForUpload( tabId: string, upload: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, + timeoutMs?: number, ): Promise<(DesktopPreviewRecordingArtifact & { uploadedAttachmentId: string }) | null> { const recording = activeRecordings.get(tabId); if (!recording) return null; - const artifact = await stopBrowserRecording(tabId); + const deadline = timeoutMs === undefined ? null : Date.now() + timeoutMs; + const artifact = await stopRecording(tabId, timeoutMs, true); if (!artifact || !recording.savedBlob) return null; - recording.uploadPromise ??= upload(artifact, recording.savedBlob); - return { ...artifact, uploadedAttachmentId: await recording.uploadPromise }; + remainingRecordingStopBudget(deadline, tabId); + const blob = recording.savedBlob; + const joinedUpload = recording.uploadPromise !== undefined; + const getUploadPromise = () => { + if (recording.uploadPromise) return recording.uploadPromise; + const pendingUpload = Promise.resolve() + .then(() => upload(artifact, blob)) + .catch((error) => { + if (recording.uploadPromise === pendingUpload) delete recording.uploadPromise; + throw error; + }); + recording.uploadPromise = pendingUpload; + // The request can expire before the retained upload settles. + void pendingUpload.catch(() => undefined); + return pendingUpload; + }; + let uploadedAttachmentId: string; + try { + uploadedAttachmentId = await awaitWithinRecordingStopDeadline( + getUploadPromise(), + deadline, + tabId, + ); + } catch (error) { + if (!joinedUpload || !isRecordingUploadDeadlineExpired(error)) { + throw error; + } + // A joined transfer may still carry the previous stop request's expired deadline. + remainingRecordingStopBudget(deadline, tabId); + uploadedAttachmentId = await awaitWithinRecordingStopDeadline( + getUploadPromise(), + deadline, + tabId, + ); + } + remainingRecordingStopBudget(deadline, tabId); + clearActiveRecording(recording); + return { ...artifact, uploadedAttachmentId }; } diff --git a/apps/web/src/browser/browserRecordingUpload.test.ts b/apps/web/src/browser/browserRecordingUpload.test.ts new file mode 100644 index 000000000000..83476ebcbb2e --- /dev/null +++ b/apps/web/src/browser/browserRecordingUpload.test.ts @@ -0,0 +1,111 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { DesktopPreviewRecordingArtifact } from "@t3tools/contracts"; +import type { runAttachmentUploadCycle } from "@t3tools/client-runtime/state/attachments"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const { uploadCycle, removeUpload } = vi.hoisted(() => ({ + uploadCycle: vi.fn(), + removeUpload: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/attachments", () => ({ + runAttachmentUploadCycle: uploadCycle, + deletePendingAttachmentUpload: removeUpload, +})); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("~/state/attachments", () => ({ attachmentEnvironment: {} })); +vi.mock("~/state/session", () => ({ readPreparedConnection: vi.fn() })); + +import { uploadBrowserRecording } from "./browserRecordingUpload"; + +const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; +const artifact: DesktopPreviewRecordingArtifact = { + id: "recording", + tabId: "tab", + path: "/tmp/recording.webm", + mimeType: "video/webm", + sizeBytes: 5, + createdAt: "2026-09-08T00:00:00.000Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1000); + uploadCycle.mockImplementation(async ({ transport }) => { + const transfer = transport("http://example.test/upload"); + try { + await transfer.done; + return { status: "uploaded", attachmentId: "uploaded-file" }; + } catch (error) { + return { status: "failed", step: "transfer", attachmentId: "uploaded-file", error }; + } + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("uploads within a short host budget without reserving response grace twice", async () => { + const fetch = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetch); + await expect( + uploadBrowserRecording(threadRef, artifact, new Blob(["video"]), 1100), + ).resolves.toBe("uploaded-file"); + expect(fetch).toHaveBeenCalledOnce(); + expect(removeUpload).not.toHaveBeenCalled(); +}); + +it("reports a transport failure within the final second as a transfer error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 500 })), + ); + await expect( + uploadBrowserRecording(threadRef, artifact, new Blob(["video"]), 1500), + ).rejects.toMatchObject({ _tag: "PreviewAutomationRecordingTransferError" }); +}); + +it("does not send bytes after the host deadline and releases the pending attachment", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect( + uploadBrowserRecording(threadRef, artifact, new Blob(["video"]), 1000), + ).rejects.toMatchObject({ + _tag: "PreviewAutomationRecordingDeadlineExpiredError", + }); + expect(fetch).not.toHaveBeenCalled(); + expect(removeUpload).toHaveBeenCalledWith( + expect.objectContaining({ attachmentId: "uploaded-file" }), + ); +}); + +it.each(["http", "transport"])( + "preserves a late %s failure instead of making it retryable", + async (kind) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + vi.setSystemTime(1600); + if (kind === "transport") throw new TypeError("connection failed"); + return new Response(null, { status: 500 }); + }), + ); + await expect( + uploadBrowserRecording(threadRef, artifact, new Blob(["video"]), 1500), + ).rejects.toMatchObject({ _tag: "PreviewAutomationRecordingTransferError" }); + }, +); + +it("classifies a transport deadline abort as retryable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new DOMException("timed out", "TimeoutError"); + }), + ); + await expect( + uploadBrowserRecording(threadRef, artifact, new Blob(["video"]), 1500), + ).rejects.toMatchObject({ _tag: "PreviewAutomationRecordingDeadlineExpiredError" }); +}); diff --git a/apps/web/src/browser/browserRecordingUpload.ts b/apps/web/src/browser/browserRecordingUpload.ts index 399352898388..aebeef165731 100644 --- a/apps/web/src/browser/browserRecordingUpload.ts +++ b/apps/web/src/browser/browserRecordingUpload.ts @@ -26,6 +26,7 @@ export async function uploadBrowserRecording( if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { throw new PreviewAutomationRecordingTooLargeError({ threadId }); } + const deadlineError = new PreviewAutomationRecordingDeadlineExpiredError({ threadId }); const result = await runAttachmentUploadCycle({ registry: appAtomRegistry, createUploadUrl: attachmentEnvironment.createUploadUrl, @@ -43,22 +44,28 @@ export async function uploadBrowserRecording( }, transport: (url) => { const controller = new AbortController(); - // Encoding, saving and minting consume the same request budget. Leave time to reply. - const remainingMs = deadlineMs - Date.now() - 1_000; + // The host deadline already reserves response grace for the complete stop operation. + const remainingMs = deadlineMs - Date.now(); return { abort: () => controller.abort(), done: remainingMs <= 0 - ? Promise.reject(new Error("Recording transfer deadline expired.")) + ? Promise.reject(deadlineError) : fetch(url, { method: "POST", headers: { "Content-Type": artifact.mimeType }, body: blob, signal: AbortSignal.any([controller.signal, AbortSignal.timeout(remainingMs)]), - }).then((response) => { - if (!response.ok) - throw new Error(`Recording upload rejected (${response.status}).`); - }), + }) + .catch((cause: unknown) => { + if (cause instanceof DOMException && cause.name === "TimeoutError") + throw deadlineError; + throw cause; + }) + .then((response) => { + if (!response.ok) + throw new Error(`Recording upload rejected (${response.status}).`); + }), }; }, }); @@ -72,9 +79,7 @@ export async function uploadBrowserRecording( }); } const cause = result.status === "failed" ? result.error : undefined; - if (Date.now() >= deadlineMs - 1_000) { - throw new PreviewAutomationRecordingDeadlineExpiredError({ threadId, cause }); - } + if (cause === deadlineError) throw deadlineError; throw new PreviewAutomationRecordingTransferError({ threadId, cause, diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 456377a4d641..524782885a82 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -1,15 +1,83 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { shallow } from "zustand/shallow"; import { acquireBrowserSurface, + acquireBrowserSurfaceBackgroundCapture, + resolveBrowserSurfaceBackgroundCaptureRect, acquireBrowserSurfaceActivity, resolveBrowserSurfacePanelRect, + selectBrowserSurfaceRenderState, useBrowserSurfaceStore, } from "./browserSurfaceStore"; describe("browserSurfaceStore", () => { beforeEach(() => { - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + useBrowserSurfaceStore.setState({ + activityByTabId: {}, + byTabId: {}, + backgroundCaptureCountByTabId: {}, + }); + }); + + it("reference-counts background capture leases", () => { + const releaseFirst = acquireBrowserSurfaceBackgroundCapture("tab-background"); + const releaseSecond = acquireBrowserSurfaceBackgroundCapture("tab-background"); + + expect(useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId["tab-background"]).toBe( + 2, + ); + + releaseFirst(); + releaseFirst(); + expect(useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId["tab-background"]).toBe( + 1, + ); + + releaseSecond(); + expect( + useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId["tab-background"], + ).toBeUndefined(); + }); + + it("selects only the current tab's stable render inputs", () => { + const release = acquireBrowserSurfaceBackgroundCapture("tab-background"); + const state = useBrowserSurfaceStore.getState(); + const first = selectBrowserSurfaceRenderState(state, "tab-background"); + useBrowserSurfaceStore.setState({ + byTabId: { + ...state.byTabId, + unrelated: { + rect: { x: 10, y: 20, width: 300, height: 200 }, + visible: true, + content: null, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, + zIndex: 30, + updatedAt: 1, + owner: null, + }, + }, + }); + const second = selectBrowserSurfaceRenderState( + useBrowserSurfaceStore.getState(), + "tab-background", + ); + + expect(shallow(first, second)).toBe(true); + expect(first).toEqual({ + backgroundCapture: true, + content: null, + cornerRadius: 0, + fitSourceContent: false, + fittedSourceContent: null, + rect: null, + visible: false, + zIndex: 30, + }); + expect(first).not.toHaveProperty("byTabId"); + release(); }); it("keeps concurrent background work active until every lease is released", () => { @@ -132,6 +200,48 @@ describe("browserSurfaceStore", () => { ).toEqual(staleRect); }); + it("stages a never-presented background tab inside the renderer viewport", () => { + expect( + resolveBrowserSurfaceBackgroundCaptureRect(null, { + width: 1440, + height: 900, + }), + ).toEqual({ + x: 80, + y: 50, + width: 1280, + height: 800, + }); + }); + + it("fits background capture staging to a smaller renderer viewport", () => { + expect( + resolveBrowserSurfaceBackgroundCaptureRect(null, { + width: 800, + height: 600, + }), + ).toEqual({ + x: 0, + y: 0, + width: 800, + height: 600, + }); + }); + + it("clamps a stale presented rectangle to the current renderer viewport", () => { + expect( + resolveBrowserSurfaceBackgroundCaptureRect( + { x: 1_000, y: 700, width: 900, height: 640 }, + { width: 800, height: 600 }, + ), + ).toEqual({ + x: 0, + y: 0, + width: 800, + height: 600, + }); + }); + it("ignores updates and releases from a stale surface lease", () => { const tabId = "leased-browser-surface"; const staleRect = { x: 0, y: 0, width: 500, height: 700 }; diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index a49154ed8def..5b49e4188ae0 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -32,6 +32,7 @@ export interface BrowserSurfaceContentPresentation { interface BrowserSurfaceStoreState { readonly activityByTabId: Record; readonly byTabId: Record; + readonly backgroundCaptureCountByTabId: Record; readonly acquireActivity: (tabId: string) => () => void; readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; readonly present: ( @@ -44,6 +45,8 @@ interface BrowserSurfaceStoreState { ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; + readonly retainBackgroundCapture: (tabId: string) => void; + readonly releaseBackgroundCapture: (tabId: string) => void; } export interface BrowserSurfaceLease { @@ -56,6 +59,26 @@ export interface BrowserSurfaceLease { readonly release: () => void; } +export function selectBrowserSurfaceRenderState( + state: { + readonly byTabId: Record; + readonly backgroundCaptureCountByTabId: Record; + }, + tabId: string, +) { + const current = state.byTabId[tabId]; + return { + backgroundCapture: (state.backgroundCaptureCountByTabId[tabId] ?? 0) > 0, + content: current?.content ?? null, + cornerRadius: current?.cornerRadius ?? 0, + fitSourceContent: current?.fitSourceContent ?? false, + fittedSourceContent: current?.fittedSourceContent ?? null, + rect: current?.rect ?? null, + visible: current?.visible ?? false, + zIndex: current?.zIndex ?? 30, + }; +} + export function resolveBrowserSurfacePanelRect( byTabId: Readonly>, tabId: string, @@ -64,6 +87,35 @@ export function resolveBrowserSurfacePanelRect( return current?.rect ?? null; } +export function resolveBrowserSurfaceBackgroundCaptureRect( + presentedRect: BrowserSurfaceRect | null, + viewport: { readonly width: number; readonly height: number }, +): BrowserSurfaceRect { + const viewportWidth = + Number.isFinite(viewport.width) && viewport.width > 0 ? Math.round(viewport.width) : 1280; + const viewportHeight = + Number.isFinite(viewport.height) && viewport.height > 0 ? Math.round(viewport.height) : 800; + if (presentedRect) { + const width = Math.max(1, Math.min(Math.round(presentedRect.width), viewportWidth)); + const height = Math.max(1, Math.min(Math.round(presentedRect.height), viewportHeight)); + return { + x: Math.max(0, Math.min(Math.round(presentedRect.x), viewportWidth - width)), + y: Math.max(0, Math.min(Math.round(presentedRect.y), viewportHeight - height)), + width, + height, + }; + } + + const width = Math.max(1, Math.min(1280, viewportWidth)); + const height = Math.max(1, Math.min(800, viewportHeight)); + return { + x: Math.max(0, Math.round((viewportWidth - width) / 2)), + y: Math.max(0, Math.round((viewportHeight - height) / 2)), + width, + height, + }; +} + const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => left !== null && left.x === right.x && @@ -74,6 +126,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): export const useBrowserSurfaceStore = create()((set) => ({ activityByTabId: {}, byTabId: {}, + backgroundCaptureCountByTabId: {}, acquireActivity: (tabId) => { let released = false; set((state) => ({ @@ -202,6 +255,25 @@ export const useBrowserSurfaceStore = create()((set) = }, }; }), + retainBackgroundCapture: (tabId) => + set((state) => ({ + backgroundCaptureCountByTabId: { + ...state.backgroundCaptureCountByTabId, + [tabId]: (state.backgroundCaptureCountByTabId[tabId] ?? 0) + 1, + }, + })), + releaseBackgroundCapture: (tabId) => + set((state) => { + const current = state.backgroundCaptureCountByTabId[tabId] ?? 0; + if (current <= 0) return state; + const next = { ...state.backgroundCaptureCountByTabId }; + if (current === 1) { + delete next[tabId]; + } else { + next[tabId] = current - 1; + } + return { backgroundCaptureCountByTabId: next }; + }), })); export const acquireBrowserSurfaceActivity = (tabId: string): (() => void) => @@ -229,3 +301,13 @@ export function acquireBrowserSurface( }, }; } + +export function acquireBrowserSurfaceBackgroundCapture(tabId: string): () => void { + let released = false; + useBrowserSurfaceStore.getState().retainBackgroundCapture(tabId); + return () => { + if (released) return; + released = true; + useBrowserSurfaceStore.getState().releaseBackgroundCapture(tabId); + }; +} diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index c2b3432402ed..c1f2bc1352d4 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -1,4 +1,9 @@ import { EnvironmentId } from "@t3tools/contracts"; +import { + DEV_BROWSER_LOOPBACK_HOST, + DEV_LOOPBACK_HOST, + resolveDevProxyTarget, +} from "@t3tools/shared/devProxy"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const readPreparedConnection = vi.fn(); @@ -212,6 +217,35 @@ describe("browser target resolver", () => { ).toBe("http://localhost:5173/app"); }); + it("keeps the dev runner loopback listener and proxy reachable to local preview guests", async () => { + expect(DEV_LOOPBACK_HOST).toBe("127.0.0.1"); + expect(DEV_BROWSER_LOOPBACK_HOST).toBe("localhost"); + + const proxyTarget = resolveDevProxyTarget("13773", undefined, undefined); + expect(proxyTarget).toBe("http://127.0.0.1:13773/"); + readPreparedConnection.mockReturnValue({ httpBaseUrl: proxyTarget }); + + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5733, + path: "/pair#token=example", + }).resolvedUrl, + ).toBe("http://localhost:5733/pair#token=example"); + }); + + it("preserves concrete backend hosts while translating wildcard listeners", () => { + expect(resolveDevProxyTarget("13773", undefined, "192.168.1.20")).toBe( + "http://192.168.1.20:13773/", + ); + expect(resolveDevProxyTarget("13773", undefined, "2001:db8::20")).toBe( + "http://[2001:db8::20]:13773/", + ); + expect(resolveDevProxyTarget("13773", undefined, "0.0.0.0")).toBe("http://127.0.0.1:13773/"); + expect(resolveDevProxyTarget("13773", undefined, "::")).toBe("http://[::1]:13773/"); + }); + it("leaves malformed input for the normal navigation error path", async () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 4acb1e2b487f..12bc84db96a5 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -3,6 +3,7 @@ import type { EnvironmentId, PreviewUrlResolution, } from "@t3tools/contracts"; +import { DEV_BROWSER_LOOPBACK_HOST } from "@t3tools/shared/devProxy"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; @@ -39,7 +40,7 @@ const resolveEnvironmentPortTarget = ( // Local loopback environments should advertise `localhost` so Chromium // dual-stack lookup can reach a Vite server bound only to ::1 or 127.0.0.1. const resolvedHost = isLocalLoopbackHost(normalizedEnvironmentHost) - ? "localhost" + ? DEV_BROWSER_LOOPBACK_HOST : normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 831167095fa1..f191c2204d95 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -1,15 +1,77 @@ import { describe, expect, it } from "vite-plus/test"; import { + BACKGROUND_CAPTURE_BROWSER_WEBVIEW_OPACITY, + BACKGROUND_CAPTURE_BROWSER_WEBVIEW_Z_INDEX, HIDDEN_BROWSER_WEBVIEW_OFFSET, + resolveHostedBrowserWebviewAriaHidden, + resolveHostedBrowserWebviewPresentation, resolveHostedBrowserWebviewWrapperStyle, } from "./hostedBrowserWebviewStyle"; +describe("resolveHostedBrowserWebviewAriaHidden", () => { + it("exposes only the active guest to host assistive technology", () => { + expect(resolveHostedBrowserWebviewAriaHidden(true)).toBeUndefined(); + expect(resolveHostedBrowserWebviewAriaHidden(false)).toBe(true); + }); +}); + +describe("resolveHostedBrowserWebviewPresentation", () => { + it("stages a background capture when visibility is stale after selection changes", () => { + expect( + resolveHostedBrowserWebviewPresentation({ + backgroundCaptureRequested: true, + rect: { x: -20, y: -10, width: 800, height: 600 }, + rendererViewport: { width: 640, height: 480 }, + selected: false, + surfaceVisible: true, + }), + ).toEqual({ + active: false, + backgroundCapture: true, + rect: { x: 0, y: 0, width: 640, height: 480 }, + }); + }); + + it("keeps the selected visible surface in the foreground during a capture request", () => { + expect( + resolveHostedBrowserWebviewPresentation({ + backgroundCaptureRequested: true, + rect: { x: -20, y: -10, width: 800, height: 600 }, + rendererViewport: { width: 640, height: 480 }, + selected: true, + surfaceVisible: true, + }), + ).toEqual({ + active: true, + backgroundCapture: false, + rect: { x: -20, y: -10, width: 800, height: 600 }, + }); + }); + + it("supplies a deterministic staging rectangle before a surface has presented", () => { + expect( + resolveHostedBrowserWebviewPresentation({ + backgroundCaptureRequested: true, + rect: null, + rendererViewport: { width: 640, height: 480 }, + selected: false, + surfaceVisible: false, + }), + ).toEqual({ + active: false, + backgroundCapture: true, + rect: { x: 0, y: 0, width: 640, height: 480 }, + }); + }); +}); + describe("resolveHostedBrowserWebviewWrapperStyle", () => { it("places an active webview on its presented surface", () => { expect( resolveHostedBrowserWebviewWrapperStyle({ active: true, + backgroundCapture: false, renderingActive: true, rect: { x: 12, y: 34, width: 800, height: 600 }, hiddenSize: { width: 1280, height: 800 }, @@ -24,10 +86,31 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); + it("places a nearly transparent background target above the active surface", () => { + expect( + resolveHostedBrowserWebviewWrapperStyle({ + active: false, + backgroundCapture: true, + renderingActive: true, + rect: { x: 12, y: 34, width: 800, height: 600 }, + hiddenSize: { width: 393, height: 852 }, + }), + ).toEqual({ + left: 12, + top: 34, + width: 800, + height: 600, + zIndex: BACKGROUND_CAPTURE_BROWSER_WEBVIEW_Z_INDEX, + pointerEvents: "none", + opacity: BACKGROUND_CAPTURE_BROWSER_WEBVIEW_OPACITY, + }); + }); + it("clips a floating webview to the mini-player frame", () => { expect( resolveHostedBrowserWebviewWrapperStyle({ active: true, + backgroundCapture: false, renderingActive: true, cornerRadius: 12, zIndex: 48, @@ -47,6 +130,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { it("suspends painting for an inactive webview", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, + backgroundCapture: false, renderingActive: false, rect: { x: 12, y: 34, width: 800, height: 600 }, hiddenSize: { width: 393, height: 852 }, @@ -66,6 +150,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { it("keeps an active background task paintable behind the app", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, + backgroundCapture: false, renderingActive: true, rect: null, hiddenSize: { width: 1280, height: 800 }, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 5bdf9b7c4f6d..d8d740ce1c0a 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -1,4 +1,7 @@ -import type { BrowserSurfaceRect } from "./browserSurfaceStore"; +import { + resolveBrowserSurfaceBackgroundCaptureRect, + type BrowserSurfaceRect, +} from "./browserSurfaceStore"; export interface HostedBrowserWebviewSize { readonly width: number; @@ -12,14 +15,44 @@ export interface HostedBrowserWebviewWrapperStyle { readonly height: number; readonly zIndex: number; readonly pointerEvents: "auto" | "none"; + readonly opacity?: number; readonly borderRadius?: number; readonly visibility?: "hidden" | "visible"; } export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; +export const BACKGROUND_CAPTURE_BROWSER_WEBVIEW_Z_INDEX = 31; +export const BACKGROUND_CAPTURE_BROWSER_WEBVIEW_OPACITY = 0.001; + +export function resolveHostedBrowserWebviewAriaHidden(active: boolean): true | undefined { + return active ? undefined : true; +} + +export function resolveHostedBrowserWebviewPresentation(input: { + readonly backgroundCaptureRequested: boolean; + readonly rect: BrowserSurfaceRect | null; + readonly rendererViewport: HostedBrowserWebviewSize; + readonly selected: boolean; + readonly surfaceVisible: boolean; +}): { + readonly active: boolean; + readonly backgroundCapture: boolean; + readonly rect: BrowserSurfaceRect | null; +} { + const active = input.selected && input.surfaceVisible && input.rect !== null; + const backgroundCapture = !active && input.backgroundCaptureRequested; + return { + active, + backgroundCapture, + rect: backgroundCapture + ? resolveBrowserSurfaceBackgroundCaptureRect(input.rect, input.rendererViewport) + : input.rect, + }; +} export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; + readonly backgroundCapture?: boolean; readonly renderingActive: boolean; readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; @@ -29,6 +62,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { }): HostedBrowserWebviewWrapperStyle { const { active, + backgroundCapture = false, cornerRadius = 0, hiddenSize, keepPaintableWhenInactive = false, @@ -47,6 +81,17 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; } + if (backgroundCapture && rect) { + return { + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + zIndex: BACKGROUND_CAPTURE_BROWSER_WEBVIEW_Z_INDEX, + pointerEvents: "none", + opacity: BACKGROUND_CAPTURE_BROWSER_WEBVIEW_OPACITY, + }; + } if (renderingActive) { // Electron stops compositing a guest that is fully outside the window, even diff --git a/apps/web/src/browser/recordingCompositor.test.ts b/apps/web/src/browser/recordingCompositor.test.ts index d10e69d24663..baa118ae9f85 100644 --- a/apps/web/src/browser/recordingCompositor.test.ts +++ b/apps/web/src/browser/recordingCompositor.test.ts @@ -131,6 +131,51 @@ describe("detached recording compositor", () => { ).toBeNull(); }); + it("releases pending playback resources on abort and cannot revive after playback settles", async () => { + const controller = new AbortController(); + const stop = vi.fn(); + const unsubscribe = vi.fn(); + const cancelFrame = vi.fn(); + const ctx = context(); + let finishPlayback!: () => void; + const playback = new Promise((resolve) => { + finishPlayback = resolve; + }); + const source = { getVideoTracks: () => [] } as unknown as MediaStream; + const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; + const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream }; + const video = { + srcObject: null as MediaStream | null, + readyState: 0, + play: () => playback, + pause: vi.fn(), + requestVideoFrameCallback: () => 7, + cancelVideoFrameCallback: cancelFrame, + }; + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? canvas : video), + }); + vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() }); + const compositor = createRecordingCompositor( + source, + options, + () => unsubscribe, + controller.signal, + ); + controller.abort(); + expect(stop).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(cancelFrame).toHaveBeenCalledWith(7); + expect(video.pause).toHaveBeenCalledOnce(); + expect(video.srcObject).toBeNull(); + expect(ctx.drawImage).not.toHaveBeenCalled(); + finishPlayback(); + await expect(compositor).rejects.toMatchObject({ name: "AbortError" }); + expect(stop).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(ctx.drawImage).not.toHaveBeenCalled(); + }); + it.each([false, true])( "releases the detached output on disposal or playback failure (%s)", async (failPlayback) => { diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts index 352154bd9aef..44b6dd32cc04 100644 --- a/apps/web/src/browser/recordingCompositor.ts +++ b/apps/web/src/browser/recordingCompositor.ts @@ -13,7 +13,9 @@ export async function createRecordingCompositor( source: MediaStream, options: RecordingDecorationOptions, subscribe: (listener: (input: DesktopPreviewRecordingInput) => void) => () => void, + signal?: AbortSignal, ) { + signal?.throwIfAborted(); if (!options.showKeyPresses && !options.showMousePresses) return null; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d", { alpha: false }); @@ -59,6 +61,7 @@ export async function createRecordingCompositor( video.srcObject = null; for (const track of output.getTracks()) track.stop(); }; + signal?.addEventListener("abort", dispose, { once: true }); try { unsubscribe = subscribe((input) => { decorations.apply(input, performance.now()); @@ -66,11 +69,14 @@ export async function createRecordingCompositor( }); frameId = video.requestVideoFrameCallback(frame); await video.play(); + signal?.throwIfAborted(); draw(); return { stream: output, dispose }; } catch (error) { dispose(); throw error; + } finally { + signal?.removeEventListener("abort", dispose); } } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 001342e48262..c28370691be2 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1133,6 +1133,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const pending = props.pendingSurfaceIds.has(surface.id); const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); const previewTabId = previewTabIdOf(surface, props.previewSessions); + const origin = previewTabId + ? props.previewSessions[previewTabId]?.automationOrigin + : undefined; + const creator = origin + ? `Agent ${(origin.contextId.split(":").at(-1) || origin.contextId).slice(-8)} · ${origin.hostLabel}` + : null; // Desktop state is keyed by the session id, but desktop actions // must be addressed with the runtime id. const audio = tabAudioState( @@ -1238,7 +1244,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { className="cursor-pointer flex min-w-0 items-center" onClick={() => props.onActivate(surface)} > - {title} + + {creator ? `${title} · ${creator}` : title} + } /> @@ -1249,6 +1257,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { environmentId={props.environmentId} title={title} /> + ) : creator ? ( + `${title} · Created by ${creator}` ) : ( title )} diff --git a/apps/web/src/components/auth/PairingRouteSurface.logic.test.ts b/apps/web/src/components/auth/PairingRouteSurface.logic.test.ts new file mode 100644 index 000000000000..ea08441f3227 --- /dev/null +++ b/apps/web/src/components/auth/PairingRouteSurface.logic.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { claimPairingToken, createPairingSubmissionQueue } from "./PairingRouteSurface.logic"; + +describe("claimPairingToken", () => { + it("claims each pairing token once", () => { + const attemptedTokens = new Set(); + + expect(claimPairingToken("first-token", attemptedTokens)).toBe("first-token"); + expect(claimPairingToken("first-token", attemptedTokens)).toBeNull(); + expect(claimPairingToken("second-token", attemptedTokens)).toBe("second-token"); + }); + + it("ignores a URL without a pairing token", () => { + expect(claimPairingToken(null, new Set())).toBeNull(); + }); + + it("serializes pairing submissions", async () => { + const queue = createPairingSubmissionQueue(); + const events: Array = []; + let finishFirst: (() => void) | undefined; + + const first = queue.run( + () => + new Promise((resolve) => { + events.push("first:start"); + finishFirst = () => { + events.push("first:finish"); + resolve(); + }; + }), + ); + const second = queue.run(async () => { + events.push("second:start"); + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + + finishFirst?.(); + await first; + await second; + expect(events).toEqual(["first:start", "first:finish", "second:start"]); + }); + + it("continues the queue after a rejected pairing submission", async () => { + const queue = createPairingSubmissionQueue(); + const expectedError = new Error("pairing failed"); + const first = queue.run(async () => { + throw expectedError; + }); + const second = queue.run(async () => "paired"); + + await expect(first).rejects.toBe(expectedError); + await expect(second).resolves.toBe("paired"); + }); +}); diff --git a/apps/web/src/components/auth/PairingRouteSurface.logic.ts b/apps/web/src/components/auth/PairingRouteSurface.logic.ts new file mode 100644 index 000000000000..04c4c3e33ab6 --- /dev/null +++ b/apps/web/src/components/auth/PairingRouteSurface.logic.ts @@ -0,0 +1,27 @@ +export function claimPairingToken( + token: string | null, + attemptedTokens: Set, +): string | null { + if (!token || attemptedTokens.has(token)) return null; + attemptedTokens.add(token); + return token; +} + +export interface PairingSubmissionQueue { + readonly run: (submit: () => Promise) => Promise; +} + +export function createPairingSubmissionQueue(): PairingSubmissionQueue { + let tail: Promise = Promise.resolve(); + + return { + run: (submit: () => Promise) => { + const result = tail.then(submit, submit); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + }; +} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index cad11af57fc6..ebe784ae1460 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -14,6 +14,7 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; import { useAtomCommand } from "../../state/use-atom-command"; +import { claimPairingToken, createPairingSubmissionQueue } from "./PairingRouteSurface.logic"; export function PairingPendingSurface() { return ( @@ -36,34 +37,44 @@ export function PairingRouteSurface({ initialErrorMessage?: string; onAuthenticated: () => void; }) { - const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); - const [credential, setCredential] = useState(() => autoPairTokenRef.current ?? ""); + const [credential, setCredential] = useState(() => peekPairingTokenFromUrl() ?? ""); const [errorMessage, setErrorMessage] = useState(initialErrorMessage ?? ""); const [isSubmitting, setIsSubmitting] = useState(false); - const autoSubmitAttemptedRef = useRef(false); + const [submissionQueue] = useState(createPairingSubmissionQueue); + const attemptedPairingTokensRef = useRef(new Set()); + const pendingSubmissionCountRef = useRef(0); const submitCredential = useCallback( async (nextCredential: string) => { + pendingSubmissionCountRef.current += 1; setIsSubmitting(true); - setErrorMessage(""); - const submitError = await submitServerAuthCredential(nextCredential).then( - () => null, - (error) => errorMessageFromUnknown(error), - ); - - setIsSubmitting(false); - - if (submitError) { - setErrorMessage(submitError); - return; + try { + await submissionQueue.run(async () => { + setErrorMessage(""); + + const submitError = await submitServerAuthCredential(nextCredential).then( + () => null, + (error) => errorMessageFromUnknown(error), + ); + + if (submitError) { + setErrorMessage(submitError); + return; + } + + startTransition(() => { + onAuthenticated(); + }); + }); + } finally { + pendingSubmissionCountRef.current -= 1; + if (pendingSubmissionCountRef.current === 0) { + setIsSubmitting(false); + } } - - startTransition(() => { - onAuthenticated(); - }); }, - [onAuthenticated], + [onAuthenticated, submissionQueue], ); const handleSubmit = useCallback( @@ -75,14 +86,20 @@ export function PairingRouteSurface({ ); useEffect(() => { - const token = autoPairTokenRef.current; - if (!token || autoSubmitAttemptedRef.current) { - return; - } - - autoSubmitAttemptedRef.current = true; - stripPairingTokenFromUrl(); - void submitCredential(token); + const submitPairingTokenFromUrl = () => { + const token = claimPairingToken(peekPairingTokenFromUrl(), attemptedPairingTokensRef.current); + if (!token) return; + + setCredential(token); + stripPairingTokenFromUrl(); + void submitCredential(token); + }; + + submitPairingTokenFromUrl(); + window.addEventListener("hashchange", submitPairingTokenFromUrl); + return () => { + window.removeEventListener("hashchange", submitPairingTokenFromUrl); + }; }, [submitCredential]); return ( diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 173a027e9df2..0d5634c5d0b7 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -1,7 +1,7 @@ "use client"; import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { FILL_PREVIEW_VIEWPORT, @@ -39,6 +39,7 @@ import { } from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { + isBrowserRecordingStopDeadlineError, readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, @@ -50,11 +51,7 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { - browserDefaultOpenProfileId, - browserDefaultOpenViewport, - resolveBrowserDefaults, -} from "~/browser/browserDefaults"; +import { resolveBrowserDefaults } from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -64,9 +61,17 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { closePreviewAutomationTab } from "./closePreviewAutomationTab"; +import { canProbePreviewHostRendering, measurePreviewHostRendering } from "./previewHostRendering"; +import { + revealPreviewAutomationTab, + waitForBrowserSurfaceVisibility, + waitForPreviewPresentation, + withPreviewAutomationBackgroundPresentation, +} from "./previewAutomationPresentation"; import { + PreviewAutomationHostDeadlineExceededError, PreviewAutomationOperationError, - PreviewAutomationOverlayTimeoutError, PreviewAutomationRecordingNotActiveError, PreviewAutomationTargetUnavailableError, PreviewAutomationViewportTimeoutError, @@ -74,64 +79,43 @@ import { import { explicitlySuppressesPreviewMiniPlayer, previewAutomationDefaultViewport, - previewAutomationOpenNeedsOverlay, + previewAutomationNewTabDefaults, + resolvePreviewAutomationOpenWaitPolicy, shouldAutoShowPreviewForAutomationUse, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; +import { + isPreviewAutomationPresentationSuppressed, + prunePreviewAutomationPresentationSuppressions, + setPreviewAutomationPresentationSuppressed, + type PreviewAutomationPresentationSuppressions, +} from "./previewAutomationPresentationSuppression"; +import { waitForDesktopOverlay } from "./previewAutomationOverlayReadiness"; +import { + createPreviewAutomationRequestConsumerAtom, + previewAutomationExecutionBudget, + previewAutomationInputWithRemainingTimeout, + previewAutomationRemainingBestEffortBudget, + previewAutomationRemainingBudget, +} from "./previewAutomationRequestConsumer"; import { assertPreviewRuntimeCurrent, waitForNavigationReadiness, } from "./previewNavigationReadiness"; -import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; -import { createPreviewAutomationClientId } from "./previewAutomationClientId"; +import { + createPreviewAutomationClientId, + getOrCreatePreviewAutomationHostId, + resolvePreviewAutomationHostMetadata, +} from "./previewAutomationClientId"; import { needsPreviewAutomationSessionSync, resolvePreviewAutomationOpenTab, resolvePreviewAutomationTarget, + previewAutomationContextState, } from "./previewAutomationTarget"; -import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget"; -import { isPreviewViewportReady } from "./previewViewportReadiness"; +import { waitForPreviewViewportReadiness } from "./previewViewportReadiness"; import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; -const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; - -const waitForPreviewPresentation = async (runtimeTabId: string): Promise => { - const deadline = Date.now() + PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS; - while (Date.now() <= deadline) { - if (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible) return; - await new Promise((resolve) => window.setTimeout(resolve, 16)); - } -}; - -const waitForDesktopOverlay = async ( - threadRef: ScopedThreadRef, - requestId: string, - tabId: string, - runtimeTabId: string, - operation: PreviewAutomationRequest["operation"], - deadlineMs: number, -): Promise => { - const waitBudgetMs = Math.max(0, deadlineMs - Date.now()); - const ready = await waitForHostReadiness(deadlineMs, async () => { - const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { - operation, - requestId, - }); - if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) { - const status = await previewBridge.automation.status(runtimeTabId); - return status.available; - } - return false; - }); - if (ready) return; - throw new PreviewAutomationOverlayTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - timeoutMs: waitBudgetMs, - }); -}; - interface ExecutablePreviewWebview extends Element { readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise; } @@ -195,31 +179,23 @@ const waitForRenderedViewport = async ( readonly threadId: PreviewAutomationRequest["threadId"]; }, ): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context); - try { + const renderedViewport = await waitForPreviewViewportReadiness({ + setting, + timeoutMs, + assertCurrent: () => assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context), + readViewport: async () => { const webview = findPreviewWebview(runtimeTabId); + if (!webview) return null; const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; const declaredViewport = readDeclaredViewport(webview); - const renderedViewport = webview ? await readWebviewViewport(webview) : null; - if ( - renderedViewport && - isPreviewViewportReady({ - setting, - appliedSettingKey, - declaredViewport, - renderedViewport, - }) - ) { - return renderedViewport; - } - } catch { - // Registration and navigation can transiently replace the guest while - // React applies the server snapshot. Retry until the operation deadline. - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } + return { + appliedSettingKey, + declaredViewport, + renderedViewport: await readWebviewViewport(webview), + }; + }, + }); + if (renderedViewport) return renderedViewport; throw new PreviewAutomationViewportTimeoutError({ ...context, tabId, @@ -230,8 +206,11 @@ const waitForRenderedViewport = async ( const currentStatus = async ( threadRef: ScopedThreadRef, requestedTabId: string | null, + contextId?: string, + /** Only an explicit status request probes frame liveness, and only with budget to spare. */ + hostRenderingBudgetMs?: number, ): Promise => { - const state = readThreadPreviewState(threadRef); + const state = previewAutomationContextState(readThreadPreviewState(threadRef), contextId); const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, state.serverEpoch, tabId) : null; const visible = runtimeTabId @@ -243,9 +222,14 @@ const currentStatus = async ( runtimeTabId && renderingActive ? await readRenderedViewport(runtimeTabId).catch(() => null) : null; + const hostRendering = + hostRenderingBudgetMs !== undefined && canProbePreviewHostRendering(hostRenderingBudgetMs) + ? await measurePreviewHostRendering() + : undefined; const viewportStatus = { ...(viewportSetting === undefined ? {} : { viewportSetting }), ...(viewport === null ? {} : { viewport }), + ...(hostRendering === undefined ? {} : { hostRendering }), }; if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) { const status = await previewBridge.automation.status(runtimeTabId); @@ -321,13 +305,25 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const lastFocusReportRef = useRef(null); const registry = useContext(RegistryContext); const [automationClientId] = useState(createPreviewAutomationClientId); + const [automationHostId] = useState(getOrCreatePreviewAutomationHostId); + const automationHostMetadata = useMemo( + () => + resolvePreviewAutomationHostMetadata( + window.desktopBridge?.getPreviewAutomationHostMetadata?.(), + navigator.platform, + automationHostId, + ), + [automationHostId], + ); const initialAutomationHost = useMemo( () => ({ clientId: automationClientId, + hostId: automationHostId, environmentId, + ...automationHostMetadata, supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], }), - [automationClientId, environmentId], + [automationClientId, automationHostId, automationHostMetadata, environmentId], ); const automationRequestsAtom = previewEnvironment.automationRequests({ environmentId, @@ -339,6 +335,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const open = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + const close = useAtomCommand(previewEnvironment.close, { + reportFailure: false, + }); const resize = useAtomCommand(previewEnvironment.resize, { reportFailure: false, }); @@ -352,20 +351,40 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); const [automationConnectionAtom] = useState(() => Atom.make(null)); const automationConnectionId = useAtomValue(automationConnectionAtom); - const presentationSuppressedRuntimeTabsRef = useRef(new Map>()); + const presentationSuppressedRuntimeTabsRef = useRef( + new Map(), + ); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { - // Session sync and tab creation consume the same budget as overlay registration. - const hostDeadlineMs = Date.now() + resolveHostWaitBudgetMs(request.timeoutMs); + const operationBudgetMs = previewAutomationExecutionBudget(request.timeoutMs); + const operationDeadline = Date.now() + operationBudgetMs; + const remainingOperationBudget = (requestedTimeoutMs = request.timeoutMs): number => { + const remainingMs = previewAutomationRemainingBudget(operationDeadline, requestedTimeoutMs); + if (remainingMs > 0) return remainingMs; + throw new PreviewAutomationHostDeadlineExceededError({ + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + tabId, + timeoutMs: operationBudgetMs, + }); + }; const threadRef: ScopedThreadRef = { environmentId, threadId: request.threadId, }; + const threadKey = scopedThreadKey(threadRef); let tabId = request.tabId ?? null; const browserActivity = { release: null as (() => void) | null }; try { - let state = readThreadPreviewState(threadRef); + const openBrowserDefaults = + request.operation === "open" ? await resolveBrowserDefaults() : undefined; + let state = previewAutomationContextState( + readThreadPreviewState(threadRef), + request.contextId, + ); const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); if (needsSessionSync) { const listTarget = { @@ -378,9 +397,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return raiseAtomCommandFailure(result); } reconcilePreviewServerSessions(threadRef, result.value); - state = readThreadPreviewState(threadRef); + state = previewAutomationContextState( + readThreadPreviewState(threadRef), + request.contextId, + ); } tabId = request.tabId ?? state.snapshot?.tabId ?? null; + prunePreviewAutomationPresentationSuppressions( + presentationSuppressedRuntimeTabsRef.current, + threadKey, + new Set( + Object.keys(state.sessions).map((sessionTabId) => + previewRuntimeTabId(threadRef, state.serverEpoch, sessionTabId), + ), + ), + ); const unavailableTarget = { requestId: request.requestId, operation: request.operation, @@ -399,14 +430,17 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); if (request.operation !== "open") { const { autoShowFloatingPreview } = await resolveBrowserDefaults(); + assertPreviewRuntimeCurrent(threadRef, readyTabId, runtimeTabId, request); + remainingOperationBudget(); if ( shouldAutoShowPreviewForAutomationUse({ operation: request.operation, autoShowFloatingPreview, - presentationSuppressed: - presentationSuppressedRuntimeTabsRef.current - .get(request.threadId) - ?.has(runtimeTabId) ?? false, + presentationSuppressed: isPreviewAutomationPresentationSuppressed( + presentationSuppressedRuntimeTabsRef.current, + threadKey, + runtimeTabId, + ), }) ) { usePreviewMiniPlayerStore @@ -421,7 +455,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) readyTabId, runtimeTabId, request.operation, - hostDeadlineMs, + remainingOperationBudget(), ); return { bridge, @@ -431,9 +465,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }; switch (request.operation) { case "status": - return await currentStatus(threadRef, tabId); + return await currentStatus( + threadRef, + tabId, + request.contextId, + previewAutomationRemainingBestEffortBudget(operationDeadline, request.timeoutMs), + ); case "open": { const input = request.input as PreviewAutomationOpenInput; + if (!openBrowserDefaults) { + throw new Error("Browser defaults were not resolved for preview open"); + } + const shouldPresentPreview = shouldOpenPreviewMiniPlayer( + input, + openBrowserDefaults.autoShowFloatingPreview, + ); const resolvedInputUrl = input.url ? resolveBrowserNavigationTarget(environmentId, { kind: "url", @@ -451,16 +497,24 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { - const defaults = await resolveBrowserDefaults(); + remainingOperationBudget(); const result = await open({ environmentId, input: { threadId: request.threadId, ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), - // An agent that didn't state a size gets the user's - // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), + // Profile and viewport are fixed when the guest attaches. + // Use the same configured defaults as a hand-opened tab. + ...previewAutomationNewTabDefaults(openBrowserDefaults), + ...(request.contextId + ? { + automationOrigin: { + contextId: request.contextId, + hostId: automationHostId, + hostLabel: automationHostMetadata.label, + }, + } + : {}), }, }); if (result._tag === "Failure") { @@ -492,6 +546,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) activeRuntimeTabId, request, ); + remainingOperationBudget(); return await resize({ environmentId, input: { @@ -509,23 +564,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) updatePreviewServerSnapshot(threadRef, resizeResult.value); } } - const shouldPresentPreview = shouldOpenPreviewMiniPlayer( - input, - (await resolveBrowserDefaults()).autoShowFloatingPreview, - ); + assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request); + remainingOperationBudget(); const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input); - const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get( - request.threadId, - ); + if (explicitlySuppressed || shouldPresentPreview) { + setPreviewAutomationPresentationSuppressed( + presentationSuppressedRuntimeTabsRef.current, + threadKey, + activeRuntimeTabId, + explicitlySuppressed, + ); + } if (explicitlySuppressed) { - if (suppressedTabs) { - suppressedTabs.add(activeRuntimeTabId); - } else { - presentationSuppressedRuntimeTabsRef.current.set( - request.threadId, - new Set([activeRuntimeTabId]), - ); - } const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId( usePreviewMiniPlayerStore.getState().byThreadKey, threadRef, @@ -533,29 +583,44 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) if (miniPlayerTabId === activeTabId) { usePreviewMiniPlayerStore.getState().close(threadRef); } - } else if (shouldPresentPreview) { - suppressedTabs?.delete(activeRuntimeTabId); - if (suppressedTabs?.size === 0) { - presentationSuppressedRuntimeTabsRef.current.delete(request.threadId); - } } if (shouldPresentPreview) { - usePreviewMiniPlayerStore - .getState() - .open(threadRef, browserMiniPlayerSource(activeTabId)); + revealPreviewAutomationTab(threadRef, activeTabId); } - if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { - await requireReadyTab(); + const waitPolicy = activeSnapshot + ? resolvePreviewAutomationOpenWaitPolicy( + input, + activeSnapshot, + reusedExistingTab, + shouldPresentPreview, + ) + : null; + if (waitPolicy?.acknowledgeAfterCreation) { + return await currentStatus(threadRef, activeTabId, request.contextId); + } + if (waitPolicy?.waitForOverlay) { + await waitForDesktopOverlay( + threadRef, + request.requestId, + activeTabId, + activeRuntimeTabId, + request.operation, + remainingOperationBudget(), + ); } if (shouldPresentPreview) { // React commits the thread-bound surface asynchronously. Settle // briefly so active-thread opens report visible=true, without // turning a background thread's offscreen mini player into an // operation failure. - await waitForPreviewPresentation(activeRuntimeTabId); + await waitForPreviewPresentation( + activeRuntimeTabId, + previewAutomationRemainingBestEffortBudget(operationDeadline, request.timeoutMs), + ); } if (reusedExistingTab && resolvedInputUrl && previewBridge) { assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request); + remainingOperationBudget(); await previewBridge.navigate(activeRuntimeTabId, resolvedInputUrl); await waitForNavigationReadiness( threadRef, @@ -564,10 +629,48 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) activeRuntimeTabId, request.operation, "load", - request.timeoutMs, + remainingOperationBudget(), ); } - return await currentStatus(threadRef, activeTabId); + if (waitPolicy?.waitForVisibility) { + await waitForBrowserSurfaceVisibility({ + threadRef, + requestId: request.requestId, + tabId: activeTabId, + runtimeTabId: activeRuntimeTabId, + timeoutMs: remainingOperationBudget(), + }); + } + return await currentStatus(threadRef, activeTabId, request.contextId); + } + case "close": { + const closeTabId = tabId; + if (!closeTabId) return await currentStatus(threadRef, null, request.contextId); + const bridge = previewBridge; + if (!bridge) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const closeState = readThreadPreviewState(threadRef); + const runtimeTabId = previewRuntimeTabId(threadRef, closeState.serverEpoch, closeTabId); + remainingOperationBudget(); + const result = await closePreviewAutomationTab({ + closePreview: close, + closeRuntimeTab: (targetRuntimeTabId) => bridge.closeTab(targetRuntimeTabId), + runtimeTabId, + snapshot: closeState.sessions[closeTabId] ?? null, + tabId: closeTabId, + threadRef, + }); + if (result._tag === "Failure") { + return raiseAtomCommandFailure(result); + } + setPreviewAutomationPresentationSuppressed( + presentationSuppressedRuntimeTabsRef.current, + threadKey, + runtimeTabId, + false, + ); + return await currentStatus(threadRef, closeTabId, request.contextId); } case "navigate": { const ready = await requireReadyTab(); @@ -587,15 +690,16 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ready.runtimeTabId, request.operation, input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, + remainingOperationBudget(input.timeoutMs ?? request.timeoutMs), ); - return await currentStatus(threadRef, ready.tabId); + return await currentStatus(threadRef, ready.tabId, request.contextId); } case "resize": { const ready = await requireReadyTab(); const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => { + remainingOperationBudget(input.timeoutMs ?? request.timeoutMs); const operationState = assertPreviewRuntimeCurrent( threadRef, ready.tabId, @@ -628,7 +732,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ready.tabId, ready.runtimeTabId, setting, - input.timeoutMs ?? request.timeoutMs, + remainingOperationBudget(input.timeoutMs ?? request.timeoutMs), { requestId: request.requestId, operation: request.operation, @@ -674,7 +778,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) case "setColorScheme": { const ready = await requireReadyTab(); const input = request.input as PreviewAutomationSetColorSchemeInput; - await ready.bridge.setColorScheme(ready.runtimeTabId, input.colorScheme); + await ready.bridge.setColorScheme( + ready.runtimeTabId, + input.colorScheme, + remainingOperationBudget(input.timeoutMs ?? request.timeoutMs), + ); return { tabId: ready.tabId, colorScheme: input.colorScheme, @@ -682,48 +790,111 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "snapshot": { const ready = await requireReadyTab(); - return await ready.bridge.automation.snapshot(ready.runtimeTabId); + const presentationTimeoutMs = remainingOperationBudget(); + const snapshot = await withPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: request.requestId, + tabId: ready.tabId, + runtimeTabId: ready.runtimeTabId, + timeoutMs: presentationTimeoutMs, + use: async (background) => + await ready.bridge.automation.snapshot( + ready.runtimeTabId, + background, + remainingOperationBudget(), + ), + }); + // A missing screenshot is usually the host not painting at all. + // Say so, after the presentation lease is released, when budget remains. + const probeBudgetMs = previewAutomationRemainingBestEffortBudget( + operationDeadline, + request.timeoutMs, + ); + if ( + snapshot.screenshot !== null || + snapshot.screenshotUnavailable === undefined || + !canProbePreviewHostRendering(probeBudgetMs) + ) { + return snapshot; + } + return { + ...snapshot, + screenshotUnavailable: { + ...snapshot.screenshotUnavailable, + hostRendering: await measurePreviewHostRendering(), + }, + }; } case "click": { const ready = await requireReadyTab(); + const input = request.input as Parameters[1]; return await ready.bridge.automation.click( ready.runtimeTabId, - request.input as Parameters[1], + previewAutomationInputWithRemainingTimeout( + input, + request.timeoutMs, + remainingOperationBudget, + ), ); } case "type": { const ready = await requireReadyTab(); + const input = request.input as Parameters[1]; return await ready.bridge.automation.type( ready.runtimeTabId, - request.input as Parameters[1], + previewAutomationInputWithRemainingTimeout( + input, + request.timeoutMs, + remainingOperationBudget, + ), ); } case "press": { const ready = await requireReadyTab(); + const input = request.input as Parameters[1]; return await ready.bridge.automation.press( ready.runtimeTabId, - request.input as Parameters[1], + previewAutomationInputWithRemainingTimeout( + input, + request.timeoutMs, + remainingOperationBudget, + ), ); } case "scroll": { const ready = await requireReadyTab(); + const input = request.input as Parameters[1]; return await ready.bridge.automation.scroll( ready.runtimeTabId, - request.input as Parameters[1], + previewAutomationInputWithRemainingTimeout( + input, + request.timeoutMs, + remainingOperationBudget, + ), ); } case "evaluate": { const ready = await requireReadyTab(); + const input = request.input as Parameters[1]; return await ready.bridge.automation.evaluate( ready.runtimeTabId, - request.input as Parameters[1], + previewAutomationInputWithRemainingTimeout( + input, + request.timeoutMs, + remainingOperationBudget, + ), ); } case "waitFor": { const ready = await requireReadyTab(); + const input = request.input as Parameters[1]; return await ready.bridge.automation.waitFor( ready.runtimeTabId, - request.input as Parameters[1], + previewAutomationInputWithRemainingTimeout( + input, + request.timeoutMs, + remainingOperationBudget, + ), ); } case "recordingStart": { @@ -732,6 +903,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ready.runtimeTabId, threadRef, ready.tabId, + remainingOperationBudget(request.timeoutMs), ); return { tabId: ready.tabId, @@ -758,13 +930,27 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) request.input !== null && "transferToEnvironment" in request.input && request.input.transferToEnvironment === true; - const artifact = stopRuntimeTabId - ? transferToEnvironment - ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) => - uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs), - ) - : await stopBrowserRecording(stopRuntimeTabId) - : null; + let artifact = null; + if (stopRuntimeTabId) { + try { + artifact = transferToEnvironment + ? await stopBrowserRecordingForUpload( + stopRuntimeTabId, + (saved, blob) => + uploadBrowserRecording(threadRef, saved, blob, operationDeadline), + remainingOperationBudget(request.timeoutMs), + ) + : await stopBrowserRecording( + stopRuntimeTabId, + remainingOperationBudget(request.timeoutMs), + ); + } catch (cause) { + if (isBrowserRecordingStopDeadlineError(cause)) { + remainingOperationBudget(request.timeoutMs); + } + throw cause; + } + } if (!artifact || !stopTabId) { return raisePreviewAutomationHostError( new PreviewAutomationRecordingNotActiveError({ @@ -794,7 +980,16 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) browserActivity.release?.(); } }, - [environmentId, listPreviews, open, registry, resize], + [ + automationHostId, + automationHostMetadata.label, + close, + environmentId, + listPreviews, + open, + registry, + resize, + ], ); const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); const setRequestHandler = useAtomSet(requestHandlerAtom); diff --git a/apps/web/src/components/preview/closePreviewAutomationTab.test.ts b/apps/web/src/components/preview/closePreviewAutomationTab.test.ts new file mode 100644 index 000000000000..94c6ec8c16fe --- /dev/null +++ b/apps/web/src/components/preview/closePreviewAutomationTab.test.ts @@ -0,0 +1,78 @@ +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, +} from "~/previewStateStore"; + +import { closePreviewAutomationTab } from "./closePreviewAutomationTab"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-08-31T14:00:00.000Z", +}; + +beforeEach(resetPreviewStateForTests); + +describe("closePreviewAutomationTab", () => { + it("removes the server session before destroying the Electron runtime", async () => { + applyPreviewServerSnapshot(threadRef, snapshot); + const calls: string[] = []; + const closePreview = vi.fn(async () => { + calls.push("server"); + return AsyncResult.success(undefined); + }); + const closeRuntimeTab = vi.fn(async () => { + calls.push("runtime"); + }); + + const result = await closePreviewAutomationTab({ + closePreview, + closeRuntimeTab, + runtimeTabId: "runtime-tab-1", + snapshot, + tabId: snapshot.tabId, + threadRef, + }); + + expect(result._tag).toBe("Success"); + expect(calls).toEqual(["server", "runtime"]); + expect(readThreadPreviewState(threadRef).sessions).toEqual({}); + expect(closePreview).toHaveBeenCalledWith({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, tabId: snapshot.tabId }, + }); + expect(closeRuntimeTab).toHaveBeenCalledWith("runtime-tab-1"); + }); + + it("keeps the runtime alive and restores state when the server close fails", async () => { + applyPreviewServerSnapshot(threadRef, snapshot); + const closeRuntimeTab = vi.fn(async () => undefined); + + const result = await closePreviewAutomationTab({ + closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))), + closeRuntimeTab, + runtimeTabId: "runtime-tab-1", + snapshot, + tabId: snapshot.tabId, + threadRef, + }); + + expect(result._tag).toBe("Failure"); + expect(closeRuntimeTab).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot }); + }); +}); diff --git a/apps/web/src/components/preview/closePreviewAutomationTab.ts b/apps/web/src/components/preview/closePreviewAutomationTab.ts new file mode 100644 index 000000000000..d954797894a9 --- /dev/null +++ b/apps/web/src/components/preview/closePreviewAutomationTab.ts @@ -0,0 +1,34 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { + EnvironmentId, + PreviewCloseInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; + +import { closePreviewSession } from "./closePreviewSession"; + +interface ClosePreviewAutomationTabInput { + readonly closePreview: (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewCloseInput; + }) => Promise>; + readonly closeRuntimeTab: (runtimeTabId: string) => Promise; + readonly runtimeTabId: string; + readonly snapshot: PreviewSessionSnapshot | null; + readonly tabId: string; + readonly threadRef: ScopedThreadRef; +} + +/** + * Removes the authoritative preview session before disposing its local Electron + * runtime. A failed server close leaves the runtime available for a retry. + */ +export async function closePreviewAutomationTab( + input: ClosePreviewAutomationTabInput, +): Promise> { + const result = await closePreviewSession(input); + if (result._tag === "Failure") return result; + await input.closeRuntimeTab(input.runtimeTabId); + return result; +} diff --git a/apps/web/src/components/preview/previewAutomationClientId.test.ts b/apps/web/src/components/preview/previewAutomationClientId.test.ts new file mode 100644 index 000000000000..8ed509940ecc --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationClientId.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getOrCreatePreviewAutomationHostId, + resolvePreviewAutomationHostMetadata, + resolvePreviewAutomationHostPlatform, +} from "./previewAutomationClientId"; + +const fixedCrypto = (byte: number): Crypto => + ({ + getRandomValues: (values: Uint8Array) => { + values.fill(byte); + return values; + }, + }) as Crypto; + +describe("preview automation host identity", () => { + it("persists one stable id for every environment hosted by the desktop profile", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + const first = getOrCreatePreviewAutomationHostId(storage, fixedCrypto(1)); + const second = getOrCreatePreviewAutomationHostId(storage, fixedCrypto(2)); + + expect(first).toBe("preview-01010101010101010101010101010101"); + expect(second).toBe(first); + }); + + it("keeps separate profiles unambiguous on the same operating system", () => { + const makeStorage = () => { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + }; + + expect(getOrCreatePreviewAutomationHostId(makeStorage(), fixedCrypto(1))).not.toBe( + getOrCreatePreviewAutomationHostId(makeStorage(), fixedCrypto(2)), + ); + }); + + it("reuses one renderer-lifetime fallback after storage recovers", () => { + let storageUnavailable = true; + const recoveredStorage = { + getItem: () => { + if (storageUnavailable) throw new Error("storage unavailable"); + return null; + }, + setItem: () => undefined, + }; + + const first = getOrCreatePreviewAutomationHostId(recoveredStorage, fixedCrypto(3)); + storageUnavailable = false; + const second = getOrCreatePreviewAutomationHostId(recoveredStorage, fixedCrypto(4)); + + expect(second).toBe(first); + }); + + it("reports native labels and derives a bounded fallback without OS-only identity", () => { + expect(resolvePreviewAutomationHostPlatform("MacIntel")).toBe("macos"); + expect(resolvePreviewAutomationHostPlatform("Win32")).toBe("windows"); + expect( + resolvePreviewAutomationHostMetadata( + { label: "MacBook Pro", platform: "macos" }, + "Win32", + "preview-aaaaaaaaaaaaaaaaaaaaaaaa12345678", + ), + ).toEqual({ label: "MacBook Pro", platform: "macos" }); + expect( + resolvePreviewAutomationHostMetadata( + null, + "Win32", + "preview-aaaaaaaaaaaaaaaaaaaaaaaa12345678", + ), + ).toEqual({ label: "Windows (12345678)", platform: "windows" }); + expect( + resolvePreviewAutomationHostMetadata( + { label: 42, platform: "invalid" } as never, + "Win32", + "preview-aaaaaaaaaaaaaaaaaaaaaaaa12345678", + ), + ).toEqual({ label: "Windows (12345678)", platform: "windows" }); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationClientId.ts b/apps/web/src/components/preview/previewAutomationClientId.ts index 2d243de3031f..994fc66e38d1 100644 --- a/apps/web/src/components/preview/previewAutomationClientId.ts +++ b/apps/web/src/components/preview/previewAutomationClientId.ts @@ -1,4 +1,81 @@ -export function createPreviewAutomationClientId(): string { - const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); +import type { + DesktopPreviewAutomationHostMetadata, + PreviewAutomationHostLabel, + PreviewAutomationHostPlatform, +} from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + +export const PREVIEW_AUTOMATION_HOST_ID_STORAGE_KEY = "t3.previewAutomationHostId.v1"; + +let fallbackPreviewAutomationHostId: string | undefined; + +export function createPreviewAutomationClientId(crypto: Crypto = globalThis.crypto): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; } + +export function getOrCreatePreviewAutomationHostId( + storage: Pick = globalThis.localStorage, + crypto: Crypto = globalThis.crypto, +): string { + if (fallbackPreviewAutomationHostId !== undefined) return fallbackPreviewAutomationHostId; + + let created: string | undefined; + try { + const existing = storage.getItem(PREVIEW_AUTOMATION_HOST_ID_STORAGE_KEY); + if (existing && /^preview-[a-f0-9]{32}$/u.test(existing)) return existing; + created = createPreviewAutomationClientId(crypto); + storage.setItem(PREVIEW_AUTOMATION_HOST_ID_STORAGE_KEY, created); + return created; + } catch { + fallbackPreviewAutomationHostId ??= created ?? createPreviewAutomationClientId(crypto); + return fallbackPreviewAutomationHostId; + } +} + +export function resolvePreviewAutomationHostPlatform( + navigatorPlatform: string, +): PreviewAutomationHostPlatform { + const platform = navigatorPlatform.toLowerCase(); + if (platform.includes("mac")) return "macos"; + if (platform.includes("win")) return "windows"; + if (platform.includes("linux")) return "linux"; + return "unknown"; +} + +export function resolvePreviewAutomationHostMetadata( + nativeMetadata: DesktopPreviewAutomationHostMetadata | null | undefined, + navigatorPlatform: string, + clientId: string, +): { + readonly label: PreviewAutomationHostLabel; + readonly platform: PreviewAutomationHostPlatform; +} { + const nativeLabel = Predicate.isString(nativeMetadata?.label) ? nativeMetadata.label.trim() : ""; + const nativePlatform = nativeMetadata?.platform; + const platform = + nativePlatform === "macos" || + nativePlatform === "windows" || + nativePlatform === "linux" || + nativePlatform === "unknown" + ? nativePlatform + : resolvePreviewAutomationHostPlatform(navigatorPlatform); + if (nativeLabel) { + return { + label: nativeLabel.slice(0, 128) as PreviewAutomationHostLabel, + platform, + }; + } + const platformLabel = + platform === "macos" + ? "macOS" + : platform === "windows" + ? "Windows" + : platform === "linux" + ? "Linux" + : "Desktop"; + return { + label: `${platformLabel} (${clientId.slice(-8)})` as PreviewAutomationHostLabel, + platform, + }; +} diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index 5e1378599878..6c964f827d15 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -79,6 +79,72 @@ export class PreviewAutomationViewportTimeoutError extends Schema.TaggedError()( + "PreviewAutomationVisibilityTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + timeoutMs: Schema.Int, + activeSurfaceKind: Schema.optional(Schema.Literals(["inline-preview", "right-panel", "none"])), + activeSurfaceId: Schema.optional(Schema.NullOr(Schema.String)), + inlinePreviewOpen: Schema.optional(Schema.Boolean), + inlinePreviewTabId: Schema.optional(Schema.NullOr(PreviewTabId)), + rightPanelOpen: Schema.optional(Schema.Boolean), + rightPanelSurfaceId: Schema.optional(Schema.NullOr(Schema.String)), + surfaceRegistered: Schema.optional(Schema.Boolean), + presentationRectAvailable: Schema.optional(Schema.Boolean), + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview browser surface for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not become visible within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationBackgroundPresentationTimeoutError extends Schema.TaggedError()( + "PreviewAutomationBackgroundPresentationTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview browser surface for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not staged for background automation within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationHostDeadlineExceededError extends Schema.TaggedError()( + "PreviewAutomationHostDeadlineExceededError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}) did not complete within the ${this.timeoutMs}ms host response budget.`; + } +} + export class PreviewAutomationTargetUnavailableError extends Schema.TaggedError()( "PreviewAutomationTargetUnavailableError", { @@ -217,6 +283,9 @@ export const PreviewAutomationHostError = Schema.Union([ PreviewAutomationOverlayTimeoutError, PreviewAutomationNavigationTimeoutError, PreviewAutomationViewportTimeoutError, + PreviewAutomationVisibilityTimeoutError, + PreviewAutomationBackgroundPresentationTimeoutError, + PreviewAutomationHostDeadlineExceededError, PreviewAutomationTargetUnavailableError, PreviewAutomationRecordingNotActiveError, PreviewAutomationTargetNotEditableHostError, diff --git a/apps/web/src/components/preview/previewAutomationHostBudget.test.ts b/apps/web/src/components/preview/previewAutomationHostBudget.test.ts deleted file mode 100644 index 1aaa92e19a69..000000000000 --- a/apps/web/src/components/preview/previewAutomationHostBudget.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - PREVIEW_HOST_RESPONSE_MARGIN_MS, - resolveHostWaitBudgetMs, - waitForHostReadiness, -} from "./previewAutomationHostBudget"; - -describe("resolveHostWaitBudgetMs", () => { - it("reserves the full response margin once the request budget allows it", () => { - expect(resolveHostWaitBudgetMs(15_000)).toBe(15_000 - PREVIEW_HOST_RESPONSE_MARGIN_MS); - expect(resolveHostWaitBudgetMs(60_000)).toBe(60_000 - PREVIEW_HOST_RESPONSE_MARGIN_MS); - }); - - it("keeps most of a short request budget instead of collapsing it", () => { - expect(resolveHostWaitBudgetMs(1_000)).toBe(800); - expect(resolveHostWaitBudgetMs(100)).toBe(80); - }); - - it("returns a non-negative budget for invalid input", () => { - for (const invalid of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) { - expect(resolveHostWaitBudgetMs(invalid)).toBeGreaterThanOrEqual(0); - } - }); -}); - -describe("waitForHostReadiness", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(0); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it.each([1, 10, 100, 250, 1_000, 15_000])( - "stops unavailable-overlay polling before a %ims broker timeout", - async (requestTimeoutMs) => { - const deadlineMs = Date.now() + resolveHostWaitBudgetMs(requestTimeoutMs); - let finishedAt: number | undefined; - const result = waitForHostReadiness(deadlineMs, async () => false).then((ready) => { - finishedAt = Date.now(); - return ready; - }); - - await vi.advanceTimersByTimeAsync(requestTimeoutMs); - - expect(await result).toBe(false); - expect(finishedAt).toBeLessThan(requestTimeoutMs); - expect(vi.getTimerCount()).toBe(0); - }, - ); - - it("includes session setup in the deadline instead of starting a fresh wait budget", async () => { - const deadlineMs = Date.now() + resolveHostWaitBudgetMs(15_000); - await vi.advanceTimersByTimeAsync(2_000); - let finished = false; - const result = waitForHostReadiness(deadlineMs, async () => false).then((ready) => { - finished = true; - return ready; - }); - - await vi.advanceTimersByTimeAsync(11_499); - expect(finished).toBe(false); - await vi.advanceTimersByTimeAsync(1); - expect(await result).toBe(false); - expect(Date.now()).toBe(deadlineMs); - expect(vi.getTimerCount()).toBe(0); - }); - - it("skips readiness probes when setup has already exhausted the deadline", async () => { - const deadlineMs = Date.now() + resolveHostWaitBudgetMs(100); - await vi.advanceTimersByTimeAsync(100); - const isReady = vi.fn(async () => false); - - expect(await waitForHostReadiness(deadlineMs, isReady)).toBe(false); - expect(isReady).not.toHaveBeenCalled(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("returns as soon as the overlay is ready and clears its timeout", async () => { - const isReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); - const result = waitForHostReadiness(800, isReady); - - await vi.advanceTimersByTimeAsync(50); - - expect(await result).toBe(true); - expect(isReady).toHaveBeenCalledTimes(2); - expect(vi.getTimerCount()).toBe(0); - }); - - it.each([false, true])( - "keeps a probe result of %s that wins the deadline race", - async (ready) => { - const isReady = vi.fn( - () => new Promise((resolve) => setTimeout(() => resolve(ready), 80)), - ); - const result = waitForHostReadiness(80, isReady); - - await vi.advanceTimersByTimeAsync(80); - - expect(await result).toBe(ready); - expect(isReady).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }, - ); - - it("bounds a stalled status probe and ignores a late ready result", async () => { - let completeProbe!: (ready: boolean) => void; - const isReady = vi.fn( - () => - new Promise((resolve) => { - completeProbe = resolve; - }), - ); - const result = waitForHostReadiness(80, isReady); - - await vi.advanceTimersByTimeAsync(80); - expect(await result).toBe(false); - expect(vi.getTimerCount()).toBe(0); - - completeProbe(true); - await vi.runAllTimersAsync(); - expect(isReady).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); - - it("preserves probe failures and clears the pending timeout", async () => { - const error = new Error("Preview target was replaced"); - const isReady = vi.fn().mockRejectedValue(error); - - await expect(waitForHostReadiness(800, isReady)).rejects.toBe(error); - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/apps/web/src/components/preview/previewAutomationHostBudget.ts b/apps/web/src/components/preview/previewAutomationHostBudget.ts deleted file mode 100644 index 3cf4be00077f..000000000000 --- a/apps/web/src/components/preview/previewAutomationHostBudget.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Allow time to deliver an overlay failure before the broker times out. */ -export const PREVIEW_HOST_RESPONSE_MARGIN_MS = 1_500; - -const HOST_RESPONSE_MARGIN_FRACTION = 0.2; - -export function resolveHostWaitBudgetMs(requestTimeoutMs: number): number { - if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) { - return 0; - } - const reservedMs = Math.min( - PREVIEW_HOST_RESPONSE_MARGIN_MS, - Math.ceil(requestTimeoutMs * HOST_RESPONSE_MARGIN_FRACTION), - ); - return Math.max(0, requestTimeoutMs - reservedMs); -} - -/** Both readiness probes and polling delays share the request's host deadline. */ -export async function waitForHostReadiness( - deadlineMs: number, - isReady: () => Promise, -): Promise { - while (Date.now() < deadlineMs) { - let timeout: ReturnType | undefined; - let ready: boolean | null; - try { - ready = await Promise.race([ - isReady(), - new Promise((resolve) => { - timeout = setTimeout(() => resolve(null), Math.max(0, deadlineMs - Date.now())); - }), - ]); - } finally { - clearTimeout(timeout); - } - if (ready) return true; - if (ready === null || Date.now() >= deadlineMs) return false; - await new Promise((resolve) => - setTimeout(resolve, Math.min(50, deadlineMs - Date.now())), - ); - } - return false; -} diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts index 148c1102ab28..2726ba6e0399 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts @@ -5,7 +5,9 @@ import { DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, explicitlySuppressesPreviewMiniPlayer, previewAutomationDefaultViewport, + previewAutomationNewTabDefaults, previewAutomationOpenNeedsOverlay, + resolvePreviewAutomationOpenWaitPolicy, shouldAutoShowPreviewForAutomationUse, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; @@ -20,6 +22,18 @@ const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessio }); describe("preview automation open readiness", () => { + it("opens replacement automation tabs in the configured browser profile", () => { + expect( + previewAutomationNewTabDefaults({ + viewport: { _tag: "freeform", width: 393, height: 852 }, + profileId: "work", + }), + ).toEqual({ + viewport: { _tag: "freeform", width: 393, height: 852 }, + profileId: "work", + }); + }); + it("opens the inline preview by default", () => { expect(shouldOpenPreviewMiniPlayer({} as PreviewAutomationOpenInput)).toBe(true); }); @@ -41,26 +55,131 @@ describe("preview automation open readiness", () => { ).toBe(false); }); - it("waits when an empty tab is immediately given a URL", () => { + it("acknowledges a newly created shown tab without cold renderer readiness", () => { expect( - previewAutomationOpenNeedsOverlay( + resolvePreviewAutomationOpenWaitPolicy( + { url: "https://example.com" } as PreviewAutomationOpenInput, + snapshot({ + _tag: "Loading", + url: "https://example.com/", + title: "", + }), + false, + true, + ), + ).toEqual({ + acknowledgeAfterCreation: true, + waitForOverlay: false, + waitForVisibility: false, + }); + }); + + it("acknowledges a newly created background tab without renderer readiness", () => { + expect( + resolvePreviewAutomationOpenWaitPolicy( + { url: "https://example.com", show: false } as PreviewAutomationOpenInput, + snapshot({ + _tag: "Loading", + url: "https://example.com/", + title: "", + }), + false, + false, + ), + ).toEqual({ + acknowledgeAfterCreation: true, + waitForOverlay: false, + waitForVisibility: false, + }); + }); + + it("waits for the overlay and visibility when navigating a reused tab", () => { + expect( + resolvePreviewAutomationOpenWaitPolicy( { url: "https://example.com" } as PreviewAutomationOpenInput, snapshot({ _tag: "Idle" }), + true, + true, ), - ).toBe(true); + ).toEqual({ + acknowledgeAfterCreation: false, + waitForOverlay: true, + waitForVisibility: true, + }); }); - it("waits for existing tabs that already have rendered content", () => { + it("waits for an existing rendered overlay without requiring visibility when show is false", () => { expect( - previewAutomationOpenNeedsOverlay( + resolvePreviewAutomationOpenWaitPolicy( + { show: false } as PreviewAutomationOpenInput, + snapshot({ + _tag: "Success", + url: "https://example.com/", + title: "Example", + }), + true, + false, + ), + ).toEqual({ + acknowledgeAfterCreation: false, + waitForOverlay: true, + waitForVisibility: false, + }); + }); + + it("does not require visibility for a reused empty tab", () => { + expect( + resolvePreviewAutomationOpenWaitPolicy( + {} as PreviewAutomationOpenInput, + snapshot({ _tag: "Idle" }), + true, + true, + ), + ).toEqual({ + acknowledgeAfterCreation: false, + waitForOverlay: false, + waitForVisibility: false, + }); + }); + + it("does not require visibility for a reused failed tab", () => { + expect( + resolvePreviewAutomationOpenWaitPolicy( + {} as PreviewAutomationOpenInput, + snapshot({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -2, + description: "Failed", + }), + true, + true, + ), + ).toEqual({ + acknowledgeAfterCreation: false, + waitForOverlay: true, + waitForVisibility: false, + }); + }); + + it("does not require visibility when browser defaults keep a reused tab in the background", () => { + expect( + resolvePreviewAutomationOpenWaitPolicy( {} as PreviewAutomationOpenInput, snapshot({ _tag: "Success", url: "https://example.com/", title: "Example", }), + true, + shouldOpenPreviewMiniPlayer({}, false), ), - ).toBe(true); + ).toEqual({ + acknowledgeAfterCreation: false, + waitForOverlay: true, + waitForVisibility: false, + }); }); it("gives newly-created automation tabs a stable desktop viewport", () => { @@ -94,6 +213,34 @@ describe("shouldOpenPreviewMiniPlayer with the floating-preview preference", () expect(shouldOpenPreviewMiniPlayer({ show: true }, false)).toBe(true); }); + it("shares the resolved explicit-over-default policy with reused-tab readiness", () => { + const renderedSnapshot = snapshot({ + _tag: "Success", + url: "https://example.com/", + title: "Example", + }); + + const explicitShow = { open: true } as PreviewAutomationOpenInput; + expect( + resolvePreviewAutomationOpenWaitPolicy( + explicitShow, + renderedSnapshot, + true, + shouldOpenPreviewMiniPlayer(explicitShow, false), + ).waitForVisibility, + ).toBe(true); + + const explicitBackground = { open: false } as PreviewAutomationOpenInput; + expect( + resolvePreviewAutomationOpenWaitPolicy( + explicitBackground, + renderedSnapshot, + true, + shouldOpenPreviewMiniPlayer(explicitBackground, true), + ).waitForVisibility, + ).toBe(false); + }); + it("distinguishes an explicit background request from a disabled preference", () => { expect(explicitlySuppressesPreviewMiniPlayer({ open: false })).toBe(true); expect(explicitlySuppressesPreviewMiniPlayer({ show: false })).toBe(true); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts index 5db7222def70..ee5b837baa4e 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts @@ -2,10 +2,13 @@ import { FILL_PREVIEW_VIEWPORT, type PreviewAutomationOperation, type PreviewAutomationOpenInput, + type PreviewOpenInput, type PreviewSessionSnapshot, type PreviewViewportSetting, } from "@t3tools/contracts"; +import type { BrowserDefaults } from "~/browser/browserDefaults"; + /** * Viewport an agent-opened tab falls back to when the user has no configured * browser default. Agents screenshot and assert against what they open, so a @@ -18,6 +21,19 @@ export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = { height: 800, } as const satisfies PreviewViewportSetting; +export interface PreviewAutomationOpenWaitPolicy { + readonly acknowledgeAfterCreation: boolean; + readonly waitForOverlay: boolean; + readonly waitForVisibility: boolean; +} + +/** Browser state fixed when a new automation tab is created. */ +export function previewAutomationNewTabDefaults( + defaults: Pick, +): Pick { + return { viewport: defaults.viewport, profileId: defaults.profileId }; +} + /** * An explicit `open`/`show` is the agent deliberately surfacing or suppressing * its work, so it outranks the preference; the setting only decides what @@ -51,6 +67,30 @@ export function previewAutomationOpenNeedsOverlay( return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; } +export function resolvePreviewAutomationOpenWaitPolicy( + input: PreviewAutomationOpenInput, + snapshot: PreviewSessionSnapshot, + reusedExistingTab: boolean, + shouldPresentPreview: boolean, +): PreviewAutomationOpenWaitPolicy { + if (!reusedExistingTab) { + return { + acknowledgeAfterCreation: true, + waitForOverlay: false, + waitForVisibility: false, + }; + } + const canPresentBrowserSurface = + input.url !== undefined || + snapshot.navStatus._tag === "Loading" || + snapshot.navStatus._tag === "Success"; + return { + acknowledgeAfterCreation: false, + waitForOverlay: previewAutomationOpenNeedsOverlay(input, snapshot), + waitForVisibility: shouldPresentPreview && canPresentBrowserSurface, + }; +} + /** * Whether a freshly opened automation tab still needs a viewport applied. * diff --git a/apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts new file mode 100644 index 000000000000..9f56431333a6 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts @@ -0,0 +1,168 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + readThreadPreviewState: vi.fn(), + status: vi.fn(), +})); + +vi.mock("~/previewStateStore", () => ({ + readThreadPreviewState: mocks.readThreadPreviewState, +})); + +vi.mock("./previewBridge", () => ({ + previewBridge: { + automation: { + status: mocks.status, + }, + }, +})); + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +import { + PreviewAutomationOverlayTimeoutError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; +import { waitForDesktopOverlay } from "./previewAutomationOverlayReadiness"; + +describe("waitForDesktopOverlay", () => { + it("clamps overlay polling to a short remaining operation budget", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + clearTimeout, + setTimeout, + }); + const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }; + const tabId = "tab-1"; + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + mocks.readThreadPreviewState.mockReturnValue({ + serverEpoch: "epoch-1", + sessions: { + [tabId]: { tabId }, + }, + desktopByTabId: { + [tabId]: true, + }, + }); + mocks.status.mockResolvedValue({ available: false }); + try { + const overlay = waitForDesktopOverlay( + threadRef, + "request-1", + tabId, + runtimeTabId, + "open", + 40, + ); + const rejection = expect(overlay).rejects.toBeInstanceOf( + PreviewAutomationOverlayTimeoutError, + ); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("bounds a stalled overlay status call by the remaining deadline", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + clearTimeout, + setTimeout, + }); + const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }; + const tabId = "tab-1"; + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + mocks.readThreadPreviewState.mockReturnValue({ + serverEpoch: "epoch-1", + sessions: { + [tabId]: { tabId }, + }, + desktopByTabId: { + [tabId]: true, + }, + }); + mocks.status.mockReturnValue(new Promise(() => undefined)); + try { + const overlay = waitForDesktopOverlay( + threadRef, + "request-1", + tabId, + runtimeTabId, + "open", + 40, + ); + const rejection = expect(overlay).rejects.toBeInstanceOf( + PreviewAutomationOverlayTimeoutError, + ); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("rejects a runtime guest replaced while overlay status is pending", async () => { + vi.stubGlobal("window", { + clearTimeout, + setTimeout, + }); + const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }; + const tabId = "tab-1"; + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + mocks.readThreadPreviewState.mockReturnValue({ + serverEpoch: "epoch-1", + sessions: { + [tabId]: { tabId }, + }, + desktopByTabId: { + [tabId]: true, + }, + }); + let resolveStatus!: (status: { readonly available: boolean }) => void; + mocks.status.mockReturnValue( + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + try { + const overlay = waitForDesktopOverlay( + threadRef, + "request-1", + tabId, + runtimeTabId, + "open", + 1_000, + ); + + mocks.readThreadPreviewState.mockReturnValue({ + serverEpoch: "epoch-2", + sessions: { + [tabId]: { tabId }, + }, + desktopByTabId: { + [tabId]: true, + }, + }); + resolveStatus({ available: true }); + + await expect(overlay).rejects.toBeInstanceOf(PreviewAutomationTargetUnavailableError); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationOverlayReadiness.ts b/apps/web/src/components/preview/previewAutomationOverlayReadiness.ts new file mode 100644 index 000000000000..e3aef36d75c8 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationOverlayReadiness.ts @@ -0,0 +1,51 @@ +import { type PreviewAutomationRequest, type ScopedThreadRef } from "@t3tools/contracts"; + +import { previewBridge } from "./previewBridge"; +import { PreviewAutomationOverlayTimeoutError } from "./previewAutomationErrors"; +import { assertPreviewRuntimeCurrent } from "./previewNavigationReadiness"; + +export async function waitForDesktopOverlay( + threadRef: ScopedThreadRef, + requestId: string, + tabId: string, + runtimeTabId: string, + operation: PreviewAutomationRequest["operation"], + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + const timeoutError = () => + new PreviewAutomationOverlayTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + timeoutMs, + }); + while (Date.now() <= deadline) { + const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { + operation, + requestId, + }); + if (state.desktopByTabId[tabId] && previewBridge) { + const remainingStatusBudgetMs = deadline - Date.now(); + if (remainingStatusBudgetMs <= 0) break; + let timeoutId: number | undefined; + const status = await Promise.race([ + previewBridge.automation.status(runtimeTabId), + new Promise((_resolve, reject) => { + timeoutId = window.setTimeout(() => reject(timeoutError()), remainingStatusBudgetMs); + }), + ]).finally(() => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }); + assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { + operation, + requestId, + }); + if (status.available) return; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await new Promise((resolve) => window.setTimeout(resolve, Math.min(50, remainingMs))); + } + throw timeoutError(); +} diff --git a/apps/web/src/components/preview/previewAutomationPresentation.test.ts b/apps/web/src/components/preview/previewAutomationPresentation.test.ts new file mode 100644 index 000000000000..cb93642e25f7 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationPresentation.test.ts @@ -0,0 +1,647 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { acquireBrowserSurface, useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + reconcilePreviewServerSessions, + resetPreviewStateForTests, +} from "~/previewStateStore"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { + isPreviewAutomationTabPresented, + readPreviewAutomationPresentationDiagnostics, + revealPreviewAutomationTab, + waitForBrowserSurfaceVisibility, + waitForPreviewPresentation, + withPreviewAutomationBackgroundPresentation, + waitForPreviewAutomationBackgroundPresentation, +} from "./previewAutomationPresentation"; +import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; + +const threadRef = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); + +const snapshot = (tabId: string, updatedAt: string): PreviewSessionSnapshot => ({ + threadId: threadRef.threadId, + tabId, + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt, +}); + +const presentationTarget = (tabId: string, runtimeTabId: string) => ({ + threadRef, + tabId, + runtimeTabId, +}); + +const addRuntimeTab = (tabId: string): string => { + applyPreviewServerSnapshot(threadRef, snapshot(tabId, "2026-07-25T00:00:00.000Z")); + return previewRuntimeTabId(threadRef, readThreadPreviewState(threadRef).serverEpoch, tabId); +}; + +describe("preview automation presentation", () => { + beforeEach(() => { + resetPreviewStateForTests(); + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {} }); + useBrowserSurfaceStore.setState({ byTabId: {}, backgroundCaptureCountByTabId: {} }); + }); + + it("selects the requested preview tab and its inline preview surface together", () => { + applyPreviewServerSnapshot(threadRef, snapshot("tab-1", "2026-07-25T00:00:00.000Z")); + applyPreviewServerSnapshot(threadRef, snapshot("tab-2", "2026-07-25T00:00:01.000Z")); + const tabOneRuntimeId = previewRuntimeTabId( + threadRef, + readThreadPreviewState(threadRef).serverEpoch, + "tab-1", + ); + + revealPreviewAutomationTab(threadRef, "tab-1"); + + expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-1"); + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, threadRef), + ).toMatchObject({ + source: { kind: "browser", tabId: "tab-1" }, + }); + expect(isPreviewAutomationTabPresented(presentationTarget("tab-1", tabOneRuntimeId))).toBe( + false, + ); + expect( + readPreviewAutomationPresentationDiagnostics(presentationTarget("tab-1", tabOneRuntimeId)), + ).toEqual({ + activeSurfaceKind: "inline-preview", + activeSurfaceId: "mini-player:tab-1", + inlinePreviewOpen: true, + inlinePreviewTabId: "tab-1", + rightPanelOpen: false, + rightPanelSurfaceId: null, + surfaceRegistered: false, + presentationRectAvailable: false, + }); + + const serverIdSurface = acquireBrowserSurface("tab-1"); + serverIdSurface.present({ x: 0, y: 0, width: 800, height: 600 }, true); + expect(isPreviewAutomationTabPresented(presentationTarget("tab-1", tabOneRuntimeId))).toBe( + false, + ); + expect( + readPreviewAutomationPresentationDiagnostics(presentationTarget("tab-1", tabOneRuntimeId)), + ).toMatchObject({ + surfaceRegistered: false, + presentationRectAvailable: false, + }); + serverIdSurface.release(); + + const surface = acquireBrowserSurface(tabOneRuntimeId); + surface.present({ x: 0, y: 0, width: 800, height: 600 }, true); + + expect(isPreviewAutomationTabPresented(presentationTarget("tab-1", tabOneRuntimeId))).toBe( + true, + ); + expect( + readPreviewAutomationPresentationDiagnostics(presentationTarget("tab-1", tabOneRuntimeId)), + ).toMatchObject({ + surfaceRegistered: true, + presentationRectAvailable: true, + }); + + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "browser", tabId: "tab-2" }); + expect(isPreviewAutomationTabPresented(presentationTarget("tab-1", tabOneRuntimeId))).toBe( + false, + ); + expect( + readPreviewAutomationPresentationDiagnostics(presentationTarget("tab-1", tabOneRuntimeId)), + ).toEqual({ + activeSurfaceKind: "inline-preview", + activeSurfaceId: "mini-player:tab-2", + inlinePreviewOpen: true, + inlinePreviewTabId: "tab-2", + rightPanelOpen: false, + rightPanelSurfaceId: null, + surfaceRegistered: true, + presentationRectAvailable: true, + }); + surface.release(); + }); + + it("reports presentation precedence and hidden retained panel state", () => { + expect( + readPreviewAutomationPresentationDiagnostics( + presentationTarget("tab-requested", "runtime-requested"), + ), + ).toEqual({ + activeSurfaceKind: "none", + activeSurfaceId: null, + inlinePreviewOpen: false, + inlinePreviewTabId: null, + rightPanelOpen: false, + rightPanelSurfaceId: null, + surfaceRegistered: false, + presentationRectAvailable: false, + }); + + useRightPanelStore.getState().openBrowser(threadRef, "tab-panel"); + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "browser", tabId: "tab-inline" }); + + expect( + readPreviewAutomationPresentationDiagnostics( + presentationTarget("tab-requested", "runtime-requested"), + ), + ).toEqual({ + activeSurfaceKind: "inline-preview", + activeSurfaceId: "mini-player:tab-inline", + inlinePreviewOpen: true, + inlinePreviewTabId: "tab-inline", + rightPanelOpen: true, + rightPanelSurfaceId: "browser:tab-panel", + surfaceRegistered: false, + presentationRectAvailable: false, + }); + + usePreviewMiniPlayerStore.getState().close(threadRef); + useRightPanelStore.getState().close(threadRef); + + expect( + readPreviewAutomationPresentationDiagnostics( + presentationTarget("tab-requested", "runtime-requested"), + ), + ).toEqual({ + activeSurfaceKind: "none", + activeSurfaceId: null, + inlinePreviewOpen: false, + inlinePreviewTabId: null, + rightPanelOpen: false, + rightPanelSurfaceId: "browser:tab-panel", + surfaceRegistered: false, + presentationRectAvailable: false, + }); + }); + + it("reports a right-panel presentation separately from the inline preview", () => { + useRightPanelStore.getState().openBrowser(threadRef, "tab-panel"); + + expect( + readPreviewAutomationPresentationDiagnostics( + presentationTarget("tab-requested", "runtime-requested"), + ), + ).toEqual({ + activeSurfaceKind: "right-panel", + activeSurfaceId: "browser:tab-panel", + inlinePreviewOpen: false, + inlinePreviewTabId: null, + rightPanelOpen: true, + rightPanelSurfaceId: "browser:tab-panel", + surfaceRegistered: false, + presentationRectAvailable: false, + }); + }); + + it("uses the operation budget when background staging does not render", async () => { + vi.useFakeTimers(); + const runtimeTabId = addRuntimeTab("tab-background"); + vi.stubGlobal("document", { + querySelectorAll: () => [], + }); + vi.stubGlobal("window", { + setTimeout, + }); + try { + const presentation = waitForPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-background", + tabId: "tab-background", + runtimeTabId, + timeoutMs: 40, + }); + const rejection = expect(presentation).rejects.toMatchObject({ + _tag: "PreviewAutomationBackgroundPresentationTimeoutError", + requestId: "request-background", + tabId: "tab-background", + timeoutMs: 40, + }); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("clamps open visibility polling to the remaining operation budget", async () => { + vi.useFakeTimers(); + const runtimeTabId = addRuntimeTab("tab-open"); + vi.stubGlobal("window", { + setTimeout, + }); + try { + const visibility = waitForBrowserSurfaceVisibility({ + threadRef, + requestId: "request-open", + tabId: "tab-open", + runtimeTabId, + timeoutMs: 40, + }); + const rejection = expect(visibility).rejects.toMatchObject({ + _tag: "PreviewAutomationVisibilityTimeoutError", + requestId: "request-open", + tabId: "tab-open", + timeoutMs: 40, + }); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("does not reopen the inline preview while the right panel selects the tab", async () => { + vi.useFakeTimers(); + const tabId = "tab-panel-open"; + const runtimeTabId = addRuntimeTab(tabId); + useRightPanelStore.getState().openBrowser(threadRef, tabId); + const panelSurface = acquireBrowserSurface(runtimeTabId); + panelSurface.present({ x: 0, y: 0, width: 800, height: 600 }, false); + const panelOwner = useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.owner; + vi.stubGlobal("window", { + setTimeout, + }); + try { + const visibility = waitForBrowserSurfaceVisibility({ + threadRef, + requestId: "request-panel-open", + tabId, + runtimeTabId, + timeoutMs: 100, + }); + const rejection = expect(visibility).rejects.toMatchObject({ + _tag: "PreviewAutomationVisibilityTimeoutError", + requestId: "request-panel-open", + tabId, + timeoutMs: 100, + }); + + await vi.advanceTimersByTimeAsync(100); + await rejection; + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, threadRef), + ).toBeNull(); + expect(useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.owner).toBe(panelOwner); + } finally { + panelSurface.release(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("clamps best-effort presentation settling to the remaining operation budget", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + setTimeout, + }); + try { + const settled = vi.fn(); + const presentation = waitForPreviewPresentation("tab-open", 40).then(settled); + + await vi.advanceTimersByTimeAsync(39); + expect(settled).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await presentation; + expect(settled).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("rejects an open visibility wait when the runtime guest is replaced", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + setTimeout, + }); + const tabId = "tab-open"; + const serverSnapshot = snapshot(tabId, "2026-07-25T00:00:00.000Z"); + reconcilePreviewServerSessions(threadRef, { + sessions: [serverSnapshot], + serverEpoch: "epoch-1", + revision: 1, + }); + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + try { + const visibility = waitForBrowserSurfaceVisibility({ + threadRef, + requestId: "request-open", + tabId, + runtimeTabId, + timeoutMs: 500, + }); + const rejection = expect(visibility).rejects.toBeInstanceOf( + PreviewAutomationTargetUnavailableError, + ); + + reconcilePreviewServerSessions(threadRef, { + sessions: [serverSnapshot], + serverEpoch: "epoch-2", + revision: 0, + }); + await vi.advanceTimersByTimeAsync(50); + + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("rejects background staging when the runtime guest is replaced", async () => { + vi.useFakeTimers(); + vi.stubGlobal("document", { + querySelectorAll: () => [], + }); + vi.stubGlobal("window", { + setTimeout, + }); + const tabId = "tab-background"; + const serverSnapshot = snapshot(tabId, "2026-07-25T00:00:00.000Z"); + reconcilePreviewServerSessions(threadRef, { + sessions: [serverSnapshot], + serverEpoch: "epoch-1", + revision: 1, + }); + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + try { + const presentation = waitForPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-background", + tabId, + runtimeTabId, + timeoutMs: 500, + }); + const rejection = expect(presentation).rejects.toBeInstanceOf( + PreviewAutomationTargetUnavailableError, + ); + + reconcilePreviewServerSessions(threadRef, { + sessions: [serverSnapshot], + serverEpoch: "epoch-2", + revision: 0, + }); + await vi.advanceTimersByTimeAsync(16); + + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("accepts a tab that becomes foregrounded while background staging renders", async () => { + vi.useFakeTimers(); + const runtimeTabId = addRuntimeTab("tab-foregrounded"); + vi.stubGlobal("document", { + querySelectorAll: () => [], + }); + vi.stubGlobal("window", { + setTimeout, + }); + const surface = acquireBrowserSurface(runtimeTabId); + try { + revealPreviewAutomationTab(threadRef, "tab-foregrounded"); + const presentation = waitForPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-foregrounded", + tabId: "tab-foregrounded", + runtimeTabId, + timeoutMs: 40, + }); + + surface.present({ x: 0, y: 0, width: 800, height: 600 }, true); + await vi.advanceTimersByTimeAsync(16); + + await expect(presentation).resolves.toBeUndefined(); + } finally { + surface.release(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("stages a visible surface when another inline preview surface is selected", async () => { + const runtimeTabId = addRuntimeTab("tab-background"); + vi.stubGlobal("document", { + querySelectorAll: () => [ + { + dataset: { + previewViewport: runtimeTabId, + previewBackgroundCapture: "true", + }, + offsetWidth: 800, + }, + ], + }); + vi.stubGlobal("window", { + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + const surface = acquireBrowserSurface(runtimeTabId); + try { + surface.present({ x: 0, y: 0, width: 800, height: 600 }, true); + revealPreviewAutomationTab(threadRef, "tab-foreground"); + + const background = await withPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-background", + tabId: "tab-background", + runtimeTabId, + timeoutMs: 40, + use: async (isBackground) => { + expect( + useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId], + ).toBe(1); + return isBackground; + }, + }); + + expect(background).toBe(true); + expect( + useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId], + ).toBeUndefined(); + } finally { + surface.release(); + vi.unstubAllGlobals(); + } + }); + + it("falls back to frame timers when compositor animation frames remain paused", async () => { + vi.useFakeTimers(); + const runtimeTabId = addRuntimeTab("tab-background"); + const cancelAnimationFrame = vi.fn(); + vi.stubGlobal("document", { + querySelectorAll: () => [ + { + dataset: { + previewViewport: runtimeTabId, + previewBackgroundCapture: "true", + }, + offsetWidth: 800, + }, + ], + }); + vi.stubGlobal("window", { + requestAnimationFrame: () => 1, + cancelAnimationFrame, + }); + const use = vi.fn(async (background: boolean) => background); + try { + const operation = withPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-paused-frame", + tabId: "tab-background", + runtimeTabId, + timeoutMs: 40, + use, + }); + expect(useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId]).toBe(1); + + await vi.advanceTimersByTimeAsync(32); + await expect(operation).resolves.toBe(true); + + expect(use).toHaveBeenCalledWith(true); + expect(cancelAnimationFrame).toHaveBeenCalledTimes(2); + expect(cancelAnimationFrame).toHaveBeenNthCalledWith(1, 1); + expect(cancelAnimationFrame).toHaveBeenNthCalledWith(2, 1); + expect( + useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId], + ).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("retains a background capture lease until a timed-out operation settles", async () => { + vi.useFakeTimers(); + const runtimeTabId = addRuntimeTab("tab-background"); + vi.stubGlobal("document", { + querySelectorAll: () => [ + { + dataset: { + previewViewport: runtimeTabId, + previewBackgroundCapture: "true", + }, + offsetWidth: 800, + }, + ], + }); + vi.stubGlobal("window", { + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + try { + let settleOperation!: () => void; + const stalledOperation = new Promise((resolve) => { + settleOperation = resolve; + }); + const operation = withPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-stalled", + tabId: "tab-background", + runtimeTabId, + timeoutMs: 40, + use: () => stalledOperation, + }); + const rejection = expect(operation).rejects.toMatchObject({ + _tag: "PreviewAutomationBackgroundPresentationTimeoutError", + requestId: "request-stalled", + tabId: "tab-background", + timeoutMs: 40, + }); + await Promise.resolve(); + expect(useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId]).toBe(1); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + expect(useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId]).toBe(1); + + settleOperation(); + await stalledOperation; + await Promise.resolve(); + expect( + useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId], + ).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("handles a delayed capture rejection after the presentation deadline wins", async () => { + vi.useFakeTimers(); + const runtimeTabId = addRuntimeTab("tab-background"); + vi.stubGlobal("document", { + querySelectorAll: () => [ + { + dataset: { + previewViewport: runtimeTabId, + previewBackgroundCapture: "true", + }, + offsetWidth: 800, + }, + ], + }); + vi.stubGlobal("window", { + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + try { + let rejectOperation!: (cause: Error) => void; + const stalledOperation = new Promise((_resolve, reject) => { + rejectOperation = reject; + }); + const operation = withPreviewAutomationBackgroundPresentation({ + threadRef, + requestId: "request-rejected-after-timeout", + tabId: "tab-background", + runtimeTabId, + timeoutMs: 40, + use: () => stalledOperation, + }); + const rejection = expect(operation).rejects.toMatchObject({ + _tag: "PreviewAutomationBackgroundPresentationTimeoutError", + requestId: "request-rejected-after-timeout", + tabId: "tab-background", + timeoutMs: 40, + }); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + + rejectOperation(new Error("delayed desktop capture failure")); + await Promise.resolve(); + await Promise.resolve(); + expect( + useBrowserSurfaceStore.getState().backgroundCaptureCountByTabId[runtimeTabId], + ).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationPresentation.ts b/apps/web/src/components/preview/previewAutomationPresentation.ts new file mode 100644 index 000000000000..044be7daa169 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationPresentation.ts @@ -0,0 +1,296 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { + acquireBrowserSurfaceBackgroundCapture, + useBrowserSurfaceStore, +} from "~/browser/browserSurfaceStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; +import { setActivePreviewTab } from "~/previewStateStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { + PreviewAutomationBackgroundPresentationTimeoutError, + PreviewAutomationVisibilityTimeoutError, +} from "./previewAutomationErrors"; +import { assertPreviewRuntimeCurrent } from "./previewNavigationReadiness"; + +interface PreviewAutomationPresentationTarget { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly runtimeTabId: string; +} + +interface PreviewAutomationBackgroundPresentationInput extends PreviewAutomationPresentationTarget { + readonly requestId: string; + readonly timeoutMs: number; +} + +interface PreviewAutomationVisibilityInput extends PreviewAutomationPresentationTarget { + readonly requestId: string; + readonly timeoutMs: number; +} + +interface PreviewAutomationBackgroundPresentationUseInput< + A, +> extends PreviewAutomationBackgroundPresentationInput { + readonly use: (background: boolean) => Promise; +} + +type PreviewAutomationPresentationDiagnostics = Required< + Pick< + PreviewAutomationVisibilityTimeoutError, + | "activeSurfaceKind" + | "activeSurfaceId" + | "inlinePreviewOpen" + | "inlinePreviewTabId" + | "rightPanelOpen" + | "rightPanelSurfaceId" + | "surfaceRegistered" + | "presentationRectAvailable" + > +>; + +const PREVIEW_AUTOMATION_COMPOSITOR_FRAME_FALLBACK_MS = 16; + +function backgroundPresentationTimeoutError( + input: PreviewAutomationBackgroundPresentationInput, +): PreviewAutomationBackgroundPresentationTimeoutError { + return new PreviewAutomationBackgroundPresentationTimeoutError({ + requestId: input.requestId, + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + tabId: input.tabId, + timeoutMs: input.timeoutMs, + }); +} + +async function waitForPreviewAutomationCompositorFrame( + deadline: number, + timeoutError: () => PreviewAutomationBackgroundPresentationTimeoutError, +): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw timeoutError(); + + await new Promise((resolve) => { + let settled = false; + let animationFrameId: number | undefined; + const complete = () => { + if (settled) return; + settled = true; + if (animationFrameId !== undefined) window.cancelAnimationFrame?.(animationFrameId); + globalThis.clearTimeout(timer); + resolve(); + }; + const timer = globalThis.setTimeout( + () => { + complete(); + }, + Math.min(PREVIEW_AUTOMATION_COMPOSITOR_FRAME_FALLBACK_MS, remainingMs), + ); + animationFrameId = window.requestAnimationFrame(complete); + }); +} + +export function revealPreviewAutomationTab(ref: ScopedThreadRef, tabId: string): void { + setActivePreviewTab(ref, tabId); + usePreviewMiniPlayerStore.getState().open(ref, browserMiniPlayerSource(tabId)); +} + +function readPreviewAutomationPresentation(input: PreviewAutomationPresentationTarget) { + const panel = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + input.threadRef, + ); + const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + input.threadRef, + ); + const presentation = useBrowserSurfaceStore.getState().byTabId[input.runtimeTabId]; + return { panel, miniPlayerTabId, presentation }; +} + +export function readPreviewAutomationPresentationDiagnostics( + input: PreviewAutomationPresentationTarget, +): PreviewAutomationPresentationDiagnostics { + const { panel, miniPlayerTabId, presentation } = readPreviewAutomationPresentation(input); + const activeSurfaceKind = + miniPlayerTabId !== null + ? ("inline-preview" as const) + : panel.isOpen && panel.activeSurfaceId !== null + ? ("right-panel" as const) + : ("none" as const); + return { + activeSurfaceKind, + activeSurfaceId: + miniPlayerTabId !== null + ? `mini-player:${miniPlayerTabId}` + : panel.isOpen + ? panel.activeSurfaceId + : null, + inlinePreviewOpen: miniPlayerTabId !== null, + inlinePreviewTabId: miniPlayerTabId ?? null, + rightPanelOpen: panel.isOpen, + rightPanelSurfaceId: panel.activeSurfaceId, + surfaceRegistered: presentation !== undefined, + presentationRectAvailable: presentation?.rect != null, + }; +} + +export function isPreviewAutomationTabPresented( + input: PreviewAutomationPresentationTarget, +): boolean { + const { panel, miniPlayerTabId, presentation } = readPreviewAutomationPresentation(input); + const requestedSurfaceIsActive = + (panel.isOpen && panel.activeSurfaceId === `browser:${input.tabId}`) || + miniPlayerTabId === input.tabId; + return requestedSurfaceIsActive && (presentation?.visible ?? false); +} + +const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; + +export async function waitForPreviewPresentation( + runtimeTabId: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + Math.min(PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS, timeoutMs); + while (true) { + if (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible) return; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return; + await new Promise((resolve) => window.setTimeout(resolve, Math.min(16, remainingMs))); + } +} + +export async function waitForBrowserSurfaceVisibility( + input: PreviewAutomationVisibilityInput, +): Promise { + const deadline = Date.now() + input.timeoutMs; + const requiredStableMs = Math.min(100, Math.max(0, input.timeoutMs - 50)); + let presentedSince: number | null = null; + while (Date.now() <= deadline) { + assertPreviewRuntimeCurrent(input.threadRef, input.tabId, input.runtimeTabId, { + operation: "open", + requestId: input.requestId, + }); + const now = Date.now(); + if (isPreviewAutomationTabPresented(input)) { + presentedSince ??= now; + // Require the selection to survive multiple presentation updates. A + // single transient `visible` frame can otherwise make open acknowledge + // just before routing or panel reconciliation unmounts the surface. + if (now - presentedSince >= requiredStableMs) return; + } else { + presentedSince = null; + // Same-server reconciliation and route hydration can race a cold open. + // Reassert the explicit show request only when no surface already + // selects this tab. Reopening the mini-player would steal the shared + // browser-surface lease from a panel that is still becoming visible. + const { panel, miniPlayerTabId } = readPreviewAutomationPresentation(input); + const selectedInPanel = panel.isOpen && panel.activeSurfaceId === `browser:${input.tabId}`; + if (!selectedInPanel && miniPlayerTabId !== input.tabId) { + revealPreviewAutomationTab(input.threadRef, input.tabId); + } + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await new Promise((resolve) => window.setTimeout(resolve, Math.min(50, remainingMs))); + } + throw new PreviewAutomationVisibilityTimeoutError({ + requestId: input.requestId, + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + tabId: input.tabId, + timeoutMs: input.timeoutMs, + ...readPreviewAutomationPresentationDiagnostics(input), + }); +} + +export async function waitForPreviewAutomationBackgroundPresentation( + input: PreviewAutomationBackgroundPresentationInput, +): Promise { + const deadline = Date.now() + input.timeoutMs; + const timeoutError = () => backgroundPresentationTimeoutError(input); + while (true) { + assertPreviewRuntimeCurrent(input.threadRef, input.tabId, input.runtimeTabId, { + operation: "snapshot", + requestId: input.requestId, + }); + if (isPreviewAutomationTabPresented(input)) { + return; + } + + const wrapper = Array.from( + document.querySelectorAll("[data-preview-viewport]"), + ).find( + (candidate) => + candidate.dataset["previewViewport"] === input.runtimeTabId && + candidate.dataset["previewBackgroundCapture"] === "true", + ); + if (wrapper) { + // Force the staged wrapper through layout, then allow Chromium two + // compositor frames before asking the guest WebContents for pixels. + // Electron can pause the host renderer's animation frames after placing + // a native guest over it, so each wait falls back to one frame interval. + void wrapper.offsetWidth; + await waitForPreviewAutomationCompositorFrame(deadline, timeoutError); + await waitForPreviewAutomationCompositorFrame(deadline, timeoutError); + assertPreviewRuntimeCurrent(input.threadRef, input.tabId, input.runtimeTabId, { + operation: "snapshot", + requestId: input.requestId, + }); + return; + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await new Promise((resolve) => window.setTimeout(resolve, Math.min(16, remainingMs))); + } + + throw timeoutError(); +} + +export async function withPreviewAutomationBackgroundPresentation( + input: PreviewAutomationBackgroundPresentationUseInput, +): Promise { + assertPreviewRuntimeCurrent(input.threadRef, input.tabId, input.runtimeTabId, { + operation: "snapshot", + requestId: input.requestId, + }); + const background = !isPreviewAutomationTabPresented(input); + if (!background) return await input.use(false); + + const timeoutError = () => backgroundPresentationTimeoutError(input); + const deadline = Date.now() + input.timeoutMs; + const releaseCapture = acquireBrowserSurfaceBackgroundCapture(input.runtimeTabId); + let captureStarted = false; + let timer: ReturnType | undefined; + + try { + await waitForPreviewAutomationBackgroundPresentation(input); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw timeoutError(); + + assertPreviewRuntimeCurrent(input.threadRef, input.tabId, input.runtimeTabId, { + operation: "snapshot", + requestId: input.requestId, + }); + const stillBackground = !isPreviewAutomationTabPresented(input); + const capture = input.use(stillBackground); + captureStarted = true; + // Keep the finalized capture in the race: Promise.race retains its rejection + // handler after the deadline wins, so a delayed capture failure is observed + // while this finalizer releases the presentation lease. + const operation = capture.finally(releaseCapture); + const captureDeadline = new Promise((_resolve, reject) => { + timer = globalThis.setTimeout(() => reject(timeoutError()), remainingMs); + }); + return await Promise.race([operation, captureDeadline]); + } finally { + if (timer !== undefined) globalThis.clearTimeout(timer); + if (!captureStarted) releaseCapture(); + } +} diff --git a/apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts b/apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts new file mode 100644 index 000000000000..7ab420d3cf79 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isPreviewAutomationPresentationSuppressed, + prunePreviewAutomationPresentationSuppressions, + setPreviewAutomationPresentationSuppressed, + type PreviewAutomationPresentationSuppressions, +} from "./previewAutomationPresentationSuppression"; + +describe("preview automation presentation suppression", () => { + it("records and clears suppression without retaining empty thread entries", () => { + const suppressions: PreviewAutomationPresentationSuppressions = new Map(); + + setPreviewAutomationPresentationSuppressed( + suppressions, + "environment:thread", + "runtime-1", + true, + ); + expect( + isPreviewAutomationPresentationSuppressed(suppressions, "environment:thread", "runtime-1"), + ).toBe(true); + + setPreviewAutomationPresentationSuppressed( + suppressions, + "environment:thread", + "runtime-1", + false, + ); + expect(suppressions.size).toBe(0); + }); + + it("prunes runtime identities replaced by server reconciliation", () => { + const suppressions: PreviewAutomationPresentationSuppressions = new Map([ + ["environment:thread", new Set(["stale-runtime", "current-runtime"])], + ]); + + prunePreviewAutomationPresentationSuppressions( + suppressions, + "environment:thread", + new Set(["current-runtime"]), + ); + + expect(suppressions.get("environment:thread")).toEqual(new Set(["current-runtime"])); + }); + + it("removes the thread entry when reconciliation leaves no suppressed runtime", () => { + const suppressions: PreviewAutomationPresentationSuppressions = new Map([ + ["environment:thread", new Set(["stale-runtime"])], + ]); + + prunePreviewAutomationPresentationSuppressions(suppressions, "environment:thread", new Set()); + + expect(suppressions.size).toBe(0); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationPresentationSuppression.ts b/apps/web/src/components/preview/previewAutomationPresentationSuppression.ts new file mode 100644 index 000000000000..5faee10ce3af --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationPresentationSuppression.ts @@ -0,0 +1,42 @@ +export type PreviewAutomationPresentationSuppressions = Map>; + +export function isPreviewAutomationPresentationSuppressed( + suppressions: PreviewAutomationPresentationSuppressions, + threadKey: string, + runtimeTabId: string, +): boolean { + return suppressions.get(threadKey)?.has(runtimeTabId) ?? false; +} + +export function setPreviewAutomationPresentationSuppressed( + suppressions: PreviewAutomationPresentationSuppressions, + threadKey: string, + runtimeTabId: string, + suppressed: boolean, +): void { + const suppressedTabs = suppressions.get(threadKey); + if (suppressed) { + if (suppressedTabs) { + suppressedTabs.add(runtimeTabId); + } else { + suppressions.set(threadKey, new Set([runtimeTabId])); + } + return; + } + + suppressedTabs?.delete(runtimeTabId); + if (suppressedTabs?.size === 0) suppressions.delete(threadKey); +} + +export function prunePreviewAutomationPresentationSuppressions( + suppressions: PreviewAutomationPresentationSuppressions, + threadKey: string, + activeRuntimeTabIds: ReadonlySet, +): void { + const suppressedTabs = suppressions.get(threadKey); + if (!suppressedTabs) return; + for (const runtimeTabId of suppressedTabs) { + if (!activeRuntimeTabIds.has(runtimeTabId)) suppressedTabs.delete(runtimeTabId); + } + if (suppressedTabs.size === 0) suppressions.delete(threadKey); +} diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index af3a95c32c78..8b09c2d69348 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -10,12 +10,19 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; import { + PreviewAutomationBackgroundPresentationTimeoutError, + PreviewAutomationHostDeadlineExceededError, PreviewAutomationRecordingNotActiveError, PreviewAutomationTargetUnavailableError, + PreviewAutomationVisibilityTimeoutError, PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom, + previewAutomationExecutionBudget, + previewAutomationInputWithRemainingTimeout, + previewAutomationRemainingBestEffortBudget, + previewAutomationRemainingBudget, serializePreviewAutomationError, } from "./previewAutomationRequestConsumer"; @@ -53,6 +60,54 @@ const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Pro }); describe("previewAutomationRequestConsumer", () => { + it("preserves the full execution budget for short requested timeouts", () => { + expect(previewAutomationExecutionBudget(100, 250)).toBe(100); + expect(previewAutomationExecutionBudget(500, 250)).toBe(500); + expect(previewAutomationExecutionBudget(501, 250)).toBe(500); + expect(previewAutomationExecutionBudget(750, 250)).toBe(500); + expect(previewAutomationExecutionBudget(751, 250)).toBe(501); + expect(previewAutomationExecutionBudget(1_000, 250)).toBe(750); + expect(previewAutomationExecutionBudget(1_000)).toBe(750); + }); + + it("keeps execution budgets monotonic as requested timeouts increase", () => { + const budgets = Array.from({ length: 1_001 }, (_, timeoutMs) => + previewAutomationExecutionBudget(timeoutMs, 250), + ); + + expect(budgets.every((budget, index) => index === 0 || budget >= budgets[index - 1]!)).toBe( + true, + ); + }); + + it("reports an expired operation budget instead of clamping it to one millisecond", () => { + expect(previewAutomationRemainingBudget(1_000, 15_000, 999)).toBe(1); + expect(previewAutomationRemainingBudget(1_000, 15_000, 1_001)).toBe(-1); + }); + + it("clamps an expired best-effort operation budget to zero", () => { + expect(previewAutomationRemainingBestEffortBudget(1_000, 15_000, 999)).toBe(1); + expect(previewAutomationRemainingBestEffortBudget(1_000, 15_000, 1_001)).toBe(0); + }); + + it("clamps timeout-bearing desktop inputs to the remaining host budget", () => { + const remainingOperationBudget = vi.fn((requestedTimeoutMs: number) => + Math.min(requestedTimeoutMs, 125), + ); + + expect( + previewAutomationInputWithRemainingTimeout( + { locator: "text=Continue", timeoutMs: 500 }, + 15_000, + remainingOperationBudget, + ), + ).toEqual({ locator: "text=Continue", timeoutMs: 125 }); + expect( + previewAutomationInputWithRemainingTimeout({ text: "ready" }, 750, remainingOperationBudget), + ).toEqual({ text: "ready", timeoutMs: 125 }); + expect(remainingOperationBudget.mock.calls).toEqual([[500], [750]]); + }); + it("acknowledges a replacement stream before consuming requests from it", async () => { const requestsAtom = Atom.make( AsyncResult.success({ @@ -291,6 +346,193 @@ describe("previewAutomationRequestConsumer", () => { }); }); + it("preserves browser visibility timeouts as timeout responses", () => { + const error = new PreviewAutomationVisibilityTimeoutError({ + requestId: "request-open", + environmentId, + threadId, + tabId, + timeoutMs: 2_000, + activeSurfaceKind: "inline-preview", + activeSurfaceId: "mini-player:tab-1", + inlinePreviewOpen: true, + inlinePreviewTabId: "tab-1", + rightPanelOpen: false, + rightPanelSurfaceId: null, + surfaceRegistered: true, + presentationRectAvailable: false, + }); + + expect( + serializePreviewAutomationError(error, { + requestId: "request-open", + operation: "open", + environmentId, + threadId, + tabId, + }), + ).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + detail: { + tabId: "tab-1", + timeoutMs: 2_000, + activeSurfaceKind: "inline-preview", + activeSurfaceId: "mini-player:tab-1", + inlinePreviewOpen: true, + inlinePreviewTabId: "tab-1", + rightPanelOpen: false, + rightPanelSurfaceId: null, + surfaceRegistered: true, + presentationRectAvailable: false, + }, + }); + }); + + it("preserves background presentation timeouts as timeout responses", () => { + const error = new PreviewAutomationBackgroundPresentationTimeoutError({ + requestId: "request-background", + environmentId, + threadId, + tabId, + timeoutMs: 1_500, + }); + + expect( + serializePreviewAutomationError(error, { + requestId: "request-background", + operation: "snapshot", + environmentId, + threadId, + tabId, + }), + ).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + detail: { tabId: "tab-1", timeoutMs: 1_500 }, + }); + }); + + it("preserves host response deadline errors as timeout responses", () => { + const error = new PreviewAutomationHostDeadlineExceededError({ + requestId: "request-snapshot", + operation: "snapshot", + environmentId, + threadId, + tabId, + timeoutMs: 14_750, + }); + + expect( + serializePreviewAutomationError(error, { + requestId: "request-snapshot", + operation: "snapshot", + environmentId, + threadId, + tabId, + }), + ).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + detail: { tabId: "tab-1", timeoutMs: 14_750 }, + }); + }); + + it("responds before the broker deadline when the host operation stalls", async () => { + vi.useFakeTimers(); + try { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); + const state = consumerState(() => new Promise(() => undefined)); + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + clientId, + connectionAtom: state.connectionAtom, + environmentId, + requestHandlerAtom: state.requestHandlerAtom, + respond, + label: "test:preview-automation-host-deadline", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + registry.set( + requestsAtom, + AsyncResult.success( + requestEvent("request-stalled", { + operation: "snapshot", + tabId, + timeoutMs: 1_000, + }), + ), + ); + + await vi.advanceTimersByTimeAsync(750); + + expect(respond).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: "request-stalled", + ok: false, + error: expect.objectContaining({ + _tag: "PreviewAutomationTimeoutError", + detail: expect.objectContaining({ timeoutMs: 750 }), + }), + }), + ); + registry.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not collapse a short host timeout to one millisecond", async () => { + vi.useFakeTimers(); + try { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); + const state = consumerState(() => new Promise(() => undefined)); + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + clientId, + connectionAtom: state.connectionAtom, + environmentId, + requestHandlerAtom: state.requestHandlerAtom, + respond, + label: "test:preview-automation-short-deadline", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + registry.set( + requestsAtom, + AsyncResult.success( + requestEvent("request-short", { + operation: "click", + tabId, + timeoutMs: 100, + }), + ), + ); + + await vi.advanceTimersByTimeAsync(99); + expect(respond).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(respond).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: "request-short", + ok: false, + error: expect.objectContaining({ + _tag: "PreviewAutomationTimeoutError", + detail: expect.objectContaining({ timeoutMs: 100 }), + }), + }), + ); + registry.dispose(); + } finally { + vi.useRealTimers(); + } + }); + it("maps desktop non-editable targets to the public typed response", () => { expect( serializePreviewAutomationError( diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts index 89a9387e4af6..bf8afc551aff 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -7,6 +7,7 @@ import type { import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { + PreviewAutomationHostDeadlineExceededError, PreviewAutomationOperationError, type PreviewAutomationOperationContext, serializePreviewAutomationHostError, @@ -14,6 +15,73 @@ import { type AutomationStreamResult = AsyncResult.AsyncResult; +export const PREVIEW_AUTOMATION_RESPONSE_GRACE_MS = 250; + +export const previewAutomationExecutionBudget = ( + timeoutMs: number, + responseGraceMs = PREVIEW_AUTOMATION_RESPONSE_GRACE_MS, +): number => Math.min(timeoutMs, Math.max(responseGraceMs * 2, timeoutMs - responseGraceMs)); + +export const previewAutomationRemainingBudget = ( + operationDeadline: number, + requestedTimeoutMs: number, + now = Date.now(), +): number => Math.min(requestedTimeoutMs, operationDeadline - now); + +export const previewAutomationRemainingBestEffortBudget = ( + operationDeadline: number, + requestedTimeoutMs: number, + now = Date.now(), +): number => + Math.max(0, previewAutomationRemainingBudget(operationDeadline, requestedTimeoutMs, now)); + +export const previewAutomationInputWithRemainingTimeout = ( + input: Input & { readonly timeoutMs?: number | undefined }, + requestTimeoutMs: number, + remainingOperationBudget: (requestedTimeoutMs: number) => number, +): Input & { readonly timeoutMs: number } => ({ + ...input, + timeoutMs: remainingOperationBudget(input.timeoutMs ?? requestTimeoutMs), +}); + +const handleWithinResponseBudget = ( + request: PreviewAutomationRequest, + environmentId: PreviewAutomationHost["environmentId"], + handle: Promise, +): Promise => + new Promise((resolve, reject) => { + let settled = false; + const timeoutMs = previewAutomationExecutionBudget(request.timeoutMs); + const timer = globalThis.setTimeout(() => { + if (settled) return; + settled = true; + reject( + new PreviewAutomationHostDeadlineExceededError({ + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + tabId: request.tabId ?? null, + timeoutMs, + }), + ); + }, timeoutMs); + handle.then( + (value) => { + if (settled) return; + settled = true; + globalThis.clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + globalThis.clearTimeout(timer); + reject(error); + }, + ); + }); + export function serializePreviewAutomationError( error: unknown, context: PreviewAutomationOperationContext, @@ -63,33 +131,31 @@ export function createPreviewAutomationRequestConsumerAtom(options: { return; } const request = event.request; - void get - .once(options.requestHandlerAtom) - .handle(request) - .then( - (value) => - options.respond({ - clientId: options.clientId, - connectionId: event.connectionId, - requestId: request.requestId, - ok: true, - ...(value === undefined ? {} : { result: value }), - }), - (error) => - options.respond({ - clientId: options.clientId, - connectionId: event.connectionId, + const handle = get.once(options.requestHandlerAtom).handle(request); + void handleWithinResponseBudget(request, options.environmentId, handle).then( + (value) => + options.respond({ + clientId: options.clientId, + connectionId: event.connectionId, + requestId: request.requestId, + ok: true, + ...(value === undefined ? {} : { result: value }), + }), + (error) => + options.respond({ + clientId: options.clientId, + connectionId: event.connectionId, + requestId: request.requestId, + ok: false, + error: serializePreviewAutomationError(error, { requestId: request.requestId, - ok: false, - error: serializePreviewAutomationError(error, { - requestId: request.requestId, - operation: request.operation, - environmentId: options.environmentId, - threadId: request.threadId, - tabId: request.tabId ?? null, - }), + operation: request.operation, + environmentId: options.environmentId, + threadId: request.threadId, + tabId: request.tabId ?? null, }), - ); + }), + ); }; get.addFinalizer(() => { diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts index 11a70951b8f2..0705fc4ca226 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.test.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.test.ts @@ -5,6 +5,7 @@ import { needsPreviewAutomationSessionSync, resolvePreviewAutomationOpenTab, resolvePreviewAutomationTarget, + previewAutomationContextState, } from "./previewAutomationTarget"; const snapshot = (tabId: string): PreviewSessionSnapshot => ({ @@ -17,6 +18,32 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); describe("preview automation target selection", () => { + it("keeps siblings and human selection out of an agent's implicit target", () => { + const human = snapshot("human"); + const first = { + ...snapshot("a"), + automationOrigin: { contextId: "a", hostId: "host", hostLabel: "Windows" }, + }; + const second = { + ...snapshot("b"), + automationOrigin: { contextId: "b", hostId: "host", hostLabel: "Windows" }, + }; + const state = { snapshot: human, sessions: { human, a: first, b: second } }; + expect(previewAutomationContextState(state, "new").snapshot).toBeNull(); + expect(previewAutomationContextState(state, "a").snapshot).toBe(first); + expect(previewAutomationContextState({ ...state, snapshot: second }, "a").snapshot).toBe(first); + expect( + resolvePreviewAutomationOpenTab(previewAutomationContextState(state, "new"), undefined, true), + ).toBeNull(); + expect( + resolvePreviewAutomationTarget(previewAutomationContextState(state, "a"), "b").snapshot, + ).toBe(second); + expect(Object.keys(previewAutomationContextState(state, "a").sessions)).toEqual([ + "human", + "a", + "b", + ]); + }); it("refreshes authoritative sessions whenever the caller relies on the active tab", () => { const active = snapshot("tab-active"); expect( diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts index f1858f87d18c..18a23beead81 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.ts @@ -5,6 +5,19 @@ interface PreviewAutomationSessionIndex { readonly sessions: Readonly>; } +/** UI selection belongs to the human; an agent's implicit target belongs to its context. */ +export function previewAutomationContextState( + state: T, + contextId: string | undefined, +): T { + if (contextId === undefined) return state; + const snapshot = + Object.values(state.sessions) + .filter((session) => session.automationOrigin?.contextId === contextId) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null; + return { ...state, snapshot }; +} + export function needsPreviewAutomationSessionSync( state: PreviewAutomationSessionIndex, requestedTabId: string | undefined, diff --git a/apps/web/src/components/preview/previewHostRendering.test.ts b/apps/web/src/components/preview/previewHostRendering.test.ts new file mode 100644 index 000000000000..530d1e9a4c26 --- /dev/null +++ b/apps/web/src/components/preview/previewHostRendering.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + PREVIEW_HOST_RENDERING_PROBE_MS, + canProbePreviewHostRendering, + measurePreviewHostRendering, +} from "./previewHostRendering"; + +describe("measurePreviewHostRendering", () => { + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + let frameCallbacks: Array<{ id: number; callback: FrameRequestCallback }> = []; + let nextFrameId = 1; + const cancelAnimationFrame = vi.fn((id: number) => { + frameCallbacks = frameCallbacks.filter((entry) => entry.id !== id); + }); + + beforeEach(() => { + vi.useFakeTimers(); + frameCallbacks = []; + nextFrameId = 1; + cancelAnimationFrame.mockClear(); + globalThis.requestAnimationFrame = (callback) => { + const id = nextFrameId++; + frameCallbacks.push({ id, callback }); + return id; + }; + globalThis.cancelAnimationFrame = cancelAnimationFrame; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + it("reports a hidden host as paused without waiting for a throttled timer", async () => { + vi.stubGlobal("document", { visibilityState: "hidden" }); + + await expect(measurePreviewHostRendering()).resolves.toBe("paused"); + + expect(frameCallbacks).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("reports an active host once an animation frame fires within the bound", async () => { + const measurement = measurePreviewHostRendering(); + expect(frameCallbacks).toHaveLength(1); + frameCallbacks[0]!.callback(16); + await expect(measurement).resolves.toBe("active"); + expect(cancelAnimationFrame).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("reports a paused host and cancels the frame request when no frame arrives", async () => { + const measurement = measurePreviewHostRendering(); + await vi.advanceTimersByTimeAsync(PREVIEW_HOST_RENDERING_PROBE_MS); + await expect(measurement).resolves.toBe("paused"); + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(frameCallbacks).toHaveLength(0); + }); + + it("only probes when the request budget leaves room for the answer", () => { + expect(canProbePreviewHostRendering(PREVIEW_HOST_RENDERING_PROBE_MS * 2)).toBe(true); + expect(canProbePreviewHostRendering(PREVIEW_HOST_RENDERING_PROBE_MS * 2 - 1)).toBe(false); + expect(canProbePreviewHostRendering(0)).toBe(false); + }); +}); diff --git a/apps/web/src/components/preview/previewHostRendering.ts b/apps/web/src/components/preview/previewHostRendering.ts new file mode 100644 index 000000000000..2ddce449e2c4 --- /dev/null +++ b/apps/web/src/components/preview/previewHostRendering.ts @@ -0,0 +1,45 @@ +import type { PreviewAutomationHostRendering } from "@t3tools/contracts"; + +/** + * How long one animation frame may take before the host counts as paused. + * Chromium stops delivering frames when the display is off or the window is + * hidden; a painting window answers within a frame or two. + */ +export const PREVIEW_HOST_RENDERING_PROBE_MS = 500; + +/** + * Probes whether the host window is painting. There is no continuous timer: + * one animation frame is requested only while a status or snapshot request + * needs the answer, and the losing side of the race is cancelled. Hidden + * documents report paused immediately, without waiting on a throttled timer. + */ +export const measurePreviewHostRendering = ( + boundMs = PREVIEW_HOST_RENDERING_PROBE_MS, +): Promise => + new Promise((resolve) => { + if (globalThis.document?.visibilityState === "hidden") { + resolve("paused"); + return; + } + let frameId: number | null = null; + let timer: ReturnType | null = null; + frameId = globalThis.requestAnimationFrame(() => { + frameId = null; + if (timer !== null) globalThis.clearTimeout(timer); + resolve("active"); + }); + timer = globalThis.setTimeout(() => { + timer = null; + if (frameId !== null) globalThis.cancelAnimationFrame(frameId); + resolve("paused"); + }, boundMs); + }); + +/** + * Only requests with room for the probe measure liveness, so the 500 ms + * favicon status lookup behind every action stays cheap. A host without + * animation frames at all (non-browser test environments) reports nothing. + */ +export const canProbePreviewHostRendering = (remainingBudgetMs: number): boolean => + typeof globalThis.requestAnimationFrame === "function" && + remainingBudgetMs >= PREVIEW_HOST_RENDERING_PROBE_MS * 2; diff --git a/apps/web/src/components/preview/previewNavigationReadiness.test.ts b/apps/web/src/components/preview/previewNavigationReadiness.test.ts index cefff7184133..1f127e999706 100644 --- a/apps/web/src/components/preview/previewNavigationReadiness.test.ts +++ b/apps/web/src/components/preview/previewNavigationReadiness.test.ts @@ -1,8 +1,9 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ readThreadPreviewState: vi.fn(), + status: vi.fn(), })); vi.mock("~/previewStateStore", () => ({ @@ -16,17 +17,25 @@ vi.mock("./previewBridge", () => ({ previewBridge: { automation: { evaluate: vi.fn(), - status: vi.fn(), + status: mocks.status, }, }, })); import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; import { waitForNavigationReadiness } from "./previewNavigationReadiness"; describe("waitForNavigationReadiness", () => { + beforeEach(() => { + mocks.readThreadPreviewState.mockReset(); + mocks.status.mockReset(); + }); + it("rejects a replaced runtime target even when readiness polling is disabled", async () => { const threadRef = { environmentId: EnvironmentId.make("environment-2"), @@ -53,4 +62,44 @@ describe("waitForNavigationReadiness", () => { ), ).rejects.toBeInstanceOf(PreviewAutomationTargetUnavailableError); }); + + it("clamps readiness polling to a short remaining operation budget", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + setTimeout, + }); + const threadRef = { + environmentId: EnvironmentId.make("environment-2"), + threadId: ThreadId.make("thread-1"), + }; + const tabId = "tab_1"; + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + mocks.readThreadPreviewState.mockReturnValue({ + serverEpoch: "epoch-1", + sessions: { + [tabId]: { tabId }, + }, + }); + mocks.status.mockResolvedValue({ available: true, loading: true }); + try { + const readiness = waitForNavigationReadiness( + threadRef, + "request-1", + tabId, + runtimeTabId, + "navigate", + "load", + 40, + ); + const rejection = expect(readiness).rejects.toBeInstanceOf( + PreviewAutomationNavigationTimeoutError, + ); + + await vi.advanceTimersByTimeAsync(40); + await rejection; + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); }); diff --git a/apps/web/src/components/preview/previewNavigationReadiness.ts b/apps/web/src/components/preview/previewNavigationReadiness.ts index dd5e7247b714..ff9d00b3b6eb 100644 --- a/apps/web/src/components/preview/previewNavigationReadiness.ts +++ b/apps/web/src/components/preview/previewNavigationReadiness.ts @@ -61,7 +61,9 @@ export async function waitForNavigationReadiness( const status = await previewBridge.automation.status(runtimeTabId); if (status.available && !status.loading) return; } - await new Promise((resolve) => window.setTimeout(resolve, 50)); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await new Promise((resolve) => window.setTimeout(resolve, Math.min(50, remainingMs))); } throw new PreviewAutomationNavigationTimeoutError({ requestId, diff --git a/apps/web/src/components/preview/previewViewportReadiness.test.ts b/apps/web/src/components/preview/previewViewportReadiness.test.ts index c47ffc3ff08c..1a57005e1ad8 100644 --- a/apps/web/src/components/preview/previewViewportReadiness.test.ts +++ b/apps/web/src/components/preview/previewViewportReadiness.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; -import { isPreviewViewportReady } from "./previewViewportReadiness"; +import { + isPreviewViewportReady, + waitForPreviewViewportReadiness, +} from "./previewViewportReadiness"; describe("isPreviewViewportReady", () => { const landscape = { @@ -70,3 +73,81 @@ describe("isPreviewViewportReady", () => { ).toBe(false); }); }); + +describe("waitForPreviewViewportReadiness", () => { + const setting = { + _tag: "preset", + width: 844, + height: 390, + presetId: "iphone-12-pro", + } as const; + const readyViewport = { + appliedSettingKey: browserViewportSettingKey(setting), + declaredViewport: { width: 844, height: 390 }, + renderedViewport: { width: 844, height: 390 }, + } as const; + + it("clamps polling to the remaining short deadline", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { setTimeout }); + try { + const result = waitForPreviewViewportReadiness({ + setting, + timeoutMs: 40, + assertCurrent: vi.fn(), + readViewport: vi.fn().mockResolvedValue(null), + }); + + await vi.advanceTimersByTimeAsync(40); + await expect(result).resolves.toBeNull(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("returns at the deadline when the guest never answers a viewport read", async () => { + vi.useFakeTimers(); + try { + const result = waitForPreviewViewportReadiness({ + setting, + timeoutMs: 40, + assertCurrent: vi.fn(), + readViewport: () => new Promise(() => {}), + }); + await vi.advanceTimersByTimeAsync(40); + await expect(result).resolves.toBeNull(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("revalidates runtime identity after an awaited viewport read", async () => { + let resolveViewport!: (value: typeof readyViewport) => void; + const readViewport = vi.fn( + () => + new Promise((resolve) => { + resolveViewport = resolve; + }), + ); + const replaced = new Error("runtime replaced"); + const assertCurrent = vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementation(() => { + throw replaced; + }); + const result = waitForPreviewViewportReadiness({ + setting, + timeoutMs: 1_000, + assertCurrent, + readViewport, + }); + + resolveViewport(readyViewport); + + await expect(result).rejects.toBe(replaced); + expect(assertCurrent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/components/preview/previewViewportReadiness.ts b/apps/web/src/components/preview/previewViewportReadiness.ts index 5ff963fbceb9..920dbfba1077 100644 --- a/apps/web/src/components/preview/previewViewportReadiness.ts +++ b/apps/web/src/components/preview/previewViewportReadiness.ts @@ -35,3 +35,44 @@ export function isPreviewViewportReady(input: { Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance ); } + +export async function waitForPreviewViewportReadiness(input: { + readonly setting: PreviewViewportSetting; + readonly timeoutMs: number; + readonly assertCurrent: () => void; + readonly readViewport: () => Promise<{ + readonly appliedSettingKey: string | null; + readonly declaredViewport: PreviewRenderedViewportSize | null; + readonly renderedViewport: PreviewRenderedViewportSize | null; + } | null>; +}): Promise { + const deadline = Date.now() + input.timeoutMs; + while (Date.now() <= deadline) { + input.assertCurrent(); + let viewportState: Awaited> = null; + const remainingReadMs = deadline - Date.now(); + if (remainingReadMs <= 0) break; + let readTimeout: ReturnType | undefined; + try { + viewportState = await Promise.race([ + input.readViewport(), + new Promise((resolve) => { + readTimeout = setTimeout(() => resolve(null), remainingReadMs); + }), + ]); + } catch { + // Registration and navigation can transiently replace the guest while + // React applies the server snapshot. Retry until the operation deadline. + } finally { + if (readTimeout !== undefined) clearTimeout(readTimeout); + } + input.assertCurrent(); + if (viewportState && isPreviewViewportReady({ setting: input.setting, ...viewportState })) { + return viewportState.renderedViewport; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await new Promise((resolve) => window.setTimeout(resolve, Math.min(50, remainingMs))); + } + return null; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1d710f273cd0..bf65b3226092 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,7 +9,11 @@ import "vite-plus/test/config"; import { defineConfig, type Connect, type Plugin } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; -import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; +import { + DEV_LOOPBACK_HOST, + DEV_PROXIED_PATH_PREFIXES, + resolveDevProxyTarget, +} from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; @@ -29,7 +33,7 @@ const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1"; const port = Number(process.env.PORT ?? 5733); const explicitHost = process.env.HOST?.trim(); -const host = explicitHost || "localhost"; +const host = explicitHost || DEV_LOOPBACK_HOST; const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; @@ -85,41 +89,11 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; -function resolveDevProxyTarget( - backendPort: string | undefined, - wsUrl: string | undefined, -): string | undefined { - // Browser dev is single-origin: the backend port is proxied through this - // server so the app works from any origin (localhost, tailnet, LAN, phone). - // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode. - const port = Number(backendPort?.trim()); - if (Number.isInteger(port) && port > 0) { - return `http://localhost:${port}/`; - } - - // dev:desktop still points the renderer straight at the backend, so fall - // back to deriving the target from the explicit websocket URL. - if (!wsUrl) { - return undefined; - } - - try { - const url = new URL(wsUrl); - if (url.protocol === "ws:") { - url.protocol = "http:"; - } else if (url.protocol === "wss:") { - url.protocol = "https:"; - } - url.pathname = ""; - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return undefined; - } -} - -const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl); +const devProxyTarget = resolveDevProxyTarget( + process.env.T3CODE_PORT, + configuredWsUrl, + process.env.T3CODE_HOST, +); // Vite's dev server sends JS uncompressed. On localhost that is free; over a // shared origin (tailnet, LAN) it is the whole cold-start: bundled dev serves diff --git a/packages/client-runtime/src/rpc/multiplexProtocol.ts b/packages/client-runtime/src/rpc/multiplexProtocol.ts new file mode 100644 index 000000000000..4f2328baac75 --- /dev/null +++ b/packages/client-runtime/src/rpc/multiplexProtocol.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import type { RpcClient } from "effect/unstable/rpc"; + +/** Keep a slow subscription's receive queue from blocking the socket's other RPCs. */ +export const multiplexProtocol = ( + protocol: RpcClient.Protocol["Service"], +): RpcClient.Protocol["Service"] => ({ + ...protocol, + run: (clientId, receive) => + Effect.scoped( + Effect.gen(function* () { + const scope = yield* Effect.scope; + const pending = new Map>(); + return yield* protocol.run(clientId, (message) => + Effect.gen(function* () { + if (!("requestId" in message)) { + // Connection failure must unblock even subscriptions whose consumer + // has stopped. Their bounded receive queues are closed by receive. + yield* Effect.forEach(pending.values(), Fiber.interrupt, { + concurrency: "unbounded", + }); + pending.clear(); + return yield* receive(message); + } + const requestId = message.requestId; + const previous = pending.get(requestId); + const delivery = yield* (previous ? Fiber.await(previous) : Effect.void).pipe( + Effect.andThen(() => receive(message)), + Effect.forkIn(scope), + ); + pending.set(requestId, delivery); + delivery.addObserver(() => { + if (pending.get(requestId) === delivery) pending.delete(requestId); + }); + // RPC ACKs still follow delivery into each bounded queue. The server + // cannot send that subscription's next batch until it is accepted. + }), + ); + }), + ), +}); diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 9d49b8e6240a..d08b3cf8f799 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -163,6 +163,7 @@ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown const isRpcRequest = Schema.is(RpcRequest); const isPing = Schema.is(Schema.TaggedStruct("Ping", {})); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeVoid = Schema.encodeSync(Schema.toCodecJson(Schema.Void)); const encodeServerConfig = Schema.encodeSync(ServerConfig); const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); const encodeDefect = Schema.encodeSync(Schema.Defect()); @@ -288,6 +289,72 @@ const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents }); describe("RpcSessionFactory", () => { + it.effect("keeps unrelated RPC responses flowing while a stream consumer is backpressured", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* session.ready; + + const consuming = yield* Deferred.make(); + const resume = yield* Deferred.make(); + const received: string[] = []; + const subscription = yield* session.client[WS_METHODS.previewAutomationConnect]( + { clientId: "slow-host", environmentId: TARGET.environmentId }, + { streamBufferSize: 1 }, + ).pipe( + Stream.runForEach((event) => + Deferred.succeed(consuming, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + Effect.andThen(() => + Effect.sync(() => { + received.push(event.connectionId); + }), + ), + ), + ), + Effect.forkScoped, + ); + const streamRequest = yield* awaitRequest(socket, 1); + const connected = { type: "connected", connectionId: "slow-connection" }; + socket.serverMessage( + encodeJson({ _tag: "Chunk", requestId: streamRequest.id, values: [connected] }), + ); + yield* Deferred.await(consuming); + // One batch larger than the bounded receive queue suspends delivery until + // this consumer resumes. A different request must still complete. + socket.serverMessage( + encodeJson({ _tag: "Chunk", requestId: streamRequest.id, values: [connected, connected] }), + ); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: streamRequest.id, + exit: { + _tag: "Success", + value: encodeVoid(undefined), + }, + }), + ); + const probe = yield* session.probe.pipe(Effect.forkScoped); + const probeRequest = yield* awaitRequest(socket, 2); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: probeRequest.id, + exit: { _tag: "Success", value: {} }, + }), + ); + yield* TestClock.adjust("1 second"); + expect(probe.pollUnsafe()).toEqual(Exit.void); + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(subscription); + expect(received).toEqual(Array(3).fill("slow-connection")); + }).pipe(Effect.scoped), + ); + it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 93bb2ff2f7d3..994ae8738a56 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -25,6 +25,7 @@ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; +import { multiplexProtocol } from "./multiplexProtocol.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import type { ConnectionAttemptError, @@ -197,7 +198,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( RpcClient.makeProtocolSocket({ retryTransientErrors: false, retryPolicy: Schedule.recurs(0), - }), + }).pipe(Effect.map(multiplexProtocol)), ).pipe( Layer.provide( Layer.mergeAll( diff --git a/packages/contracts/src/ipc.test.ts b/packages/contracts/src/ipc.test.ts index 20db75368a9a..8765d7b7e489 100644 --- a/packages/contracts/src/ipc.test.ts +++ b/packages/contracts/src/ipc.test.ts @@ -1,7 +1,12 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { DesktopEnvironmentBootstrapSchema } from "./ipc.ts"; +import { + DesktopEnvironmentBootstrapSchema, + DesktopPreviewAutomationSnapshotInputSchema, + DesktopPreviewRecordingSaveInputSchema, + DesktopPreviewRecordingStopInputSchema, +} from "./ipc.ts"; describe("DesktopEnvironmentBootstrapSchema", () => { const decode = Schema.decodeUnknownSync(DesktopEnvironmentBootstrapSchema); @@ -36,3 +41,64 @@ describe("DesktopEnvironmentBootstrapSchema", () => { ).toBeNull(); }); }); + +describe("desktop recording finalization deadlines", () => { + it("preserves explicit stop and save timeouts", () => { + const decodeStop = Schema.decodeUnknownSync(DesktopPreviewRecordingStopInputSchema); + const decodeSave = Schema.decodeUnknownSync(DesktopPreviewRecordingSaveInputSchema); + const data = new Uint8Array([1, 2, 3]); + + expect(decodeStop({ tabId: "tab-1", timeoutMs: 1_250 })).toEqual({ + tabId: "tab-1", + timeoutMs: 1_250, + }); + expect( + decodeSave({ + tabId: "tab-1", + mimeType: "video/webm", + data, + idempotencyKey: "f3088f18-9595-44f8-a67c-50c587d034a2", + timeoutMs: 1_250, + }), + ).toEqual({ + tabId: "tab-1", + mimeType: "video/webm", + data, + idempotencyKey: "f3088f18-9595-44f8-a67c-50c587d034a2", + timeoutMs: 1_250, + }); + expect(() => + decodeSave({ + tabId: "tab-1", + mimeType: "video/webm", + data, + idempotencyKey: "../unsafe", + }), + ).toThrow(); + }); +}); + +describe("DesktopPreviewAutomationSnapshotInputSchema", () => { + const decode = Schema.decodeUnknownSync(DesktopPreviewAutomationSnapshotInputSchema); + + it("defaults omitted legacy fields to foreground capture and the desktop timeout", () => { + expect(decode({ tabId: "tab-1" })).toEqual({ + tabId: "tab-1", + background: false, + timeoutMs: 15_000, + }); + expect(decode({ tabId: "tab-1", background: undefined })).toEqual({ + tabId: "tab-1", + background: false, + timeoutMs: 15_000, + }); + }); + + it("preserves a caller-supplied snapshot timeout", () => { + expect(decode({ tabId: "tab-1", background: true, timeoutMs: 1_250 })).toEqual({ + tabId: "tab-1", + background: true, + timeoutMs: 1_250, + }); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 700e5184cf83..2e372d951303 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,8 +1,10 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, + type PreviewAutomationHostPlatform, PreviewAutomationPressInput, PreviewAutomationScrollInput, PreviewAutomationSnapshot, @@ -1015,6 +1017,17 @@ export const DesktopPreviewTabInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, }); +/** Longest wait a desktop preview IPC method accepts; callers with a longer budget clamp to it. */ +export const DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS = 60_000; + +export const DesktopPreviewAutomationSnapshotInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + background: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)) + .check(Schema.isLessThanOrEqualTo(DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS)) + .pipe(Schema.withDecodingDefault(Effect.succeed(15_000))), +}); + /** * Tab creation carries the client's configured browser defaults so the guest * is born already zoomed and color-scheme-emulated. Applying them after @@ -1063,8 +1076,24 @@ export const DesktopPreviewClearDataInputSchema = Schema.Struct({ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, colorScheme: DesktopPreviewColorSchemeSchema, + timeoutMs: Schema.optional( + Schema.Int.check(Schema.isGreaterThan(0)).check( + Schema.isLessThanOrEqualTo(DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS), + ), + ), }); +export const DesktopPreviewRecordingStartInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + timeoutMs: Schema.optional( + Schema.Int.check(Schema.isGreaterThan(0)).check( + Schema.isLessThanOrEqualTo(DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS), + ), + ), +}); + +export const DesktopPreviewRecordingStopInputSchema = DesktopPreviewRecordingStartInputSchema; + export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, audioMuted: Schema.Boolean, @@ -1082,6 +1111,17 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, mimeType: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), data: Schema.Uint8Array, + idempotencyKey: Schema.String.check( + Schema.isTrimmed(), + Schema.isNonEmpty(), + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9-]+$/i), + ), + timeoutMs: Schema.optional( + Schema.Int.check(Schema.isGreaterThan(0)).check( + Schema.isLessThanOrEqualTo(DESKTOP_PREVIEW_OPERATION_TIMEOUT_MAX_MS), + ), + ), }); export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ @@ -1120,6 +1160,10 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ */ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; +export interface DesktopPreviewAutomationHostMetadata { + readonly label: string; + readonly platform: PreviewAutomationHostPlatform; +} export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; @@ -1130,6 +1174,8 @@ export interface DesktopBridge { setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear?: (listener: () => void) => () => void; onTrackpadScrollEnd?: (listener: () => void) => () => void; + /** Physical renderer metadata used only for non-disruptive preview host discovery. */ + getPreviewAutomationHostMetadata?: () => DesktopPreviewAutomationHostMetadata; /** * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: * the packaged app ships only the `en-US` Chromium locale pak, so @@ -1278,7 +1324,11 @@ export interface DesktopPreviewBridge { * Emulate `prefers-color-scheme` on the guest page ("system" clears the * override). Persists per tab and is re-applied across webview swaps. */ - setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; + setColorScheme: ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + timeoutMs?: number, + ) => Promise; /** * Silence the tab's audio output. Persists per tab and is re-applied across * webview swaps, but is dropped when the tab closes. Muting a silent tab is @@ -1328,18 +1378,24 @@ export interface DesktopPreviewBridge { }; recording: { onInput: (listener: (event: DesktopPreviewRecordingInputEvent) => void) => () => void; - startScreencast: (tabId: string) => Promise; - stopScreencast: (tabId: string) => Promise; + startScreencast: (tabId: string, timeoutMs?: number) => Promise; + stopScreencast: (tabId: string, timeoutMs?: number) => Promise; save: ( tabId: string, mimeType: string, data: Uint8Array, + idempotencyKey: string, + timeoutMs?: number, ) => Promise; onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void; }; automation: { status: (tabId: string) => Promise; - snapshot: (tabId: string) => Promise; + snapshot: ( + tabId: string, + background: boolean, + timeoutMs?: number, + ) => Promise; click: (tabId: string, input: PreviewAutomationClickInput) => Promise; type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; press: (tabId: string, input: PreviewAutomationPressInput) => Promise; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745ef8..2101ac93b60d 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -14,9 +14,14 @@ import { import { PreviewAutomationHost, PreviewAutomationError, + PreviewAutomationEvaluateInput, PreviewAutomationOpenInput, + PreviewAutomationPressInput, + PreviewAutomationRecordingStartInput, PreviewAutomationResizeInput, PreviewAutomationResizeResult, + PreviewAutomationScrollInput, + PreviewAutomationSetColorSchemeInput, PreviewAutomationStatus, } from "./previewAutomation.ts"; @@ -31,7 +36,26 @@ const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); +const decodeEvaluateInput = Schema.decodeUnknownSync(PreviewAutomationEvaluateInput); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); +const decodePressInput = Schema.decodeUnknownSync(PreviewAutomationPressInput); +const decodeScrollInput = Schema.decodeUnknownSync(PreviewAutomationScrollInput); +const decodeSetColorSchemeInput = Schema.decodeUnknownSync(PreviewAutomationSetColorSchemeInput); +const decodeRecordingStartInput = Schema.decodeUnknownSync(PreviewAutomationRecordingStartInput); + +describe("Preview automation mutation deadlines", () => { + it("preserves explicit deadlines for mutations that follow overlay readiness", () => { + expect(decodePressInput({ key: "Enter", timeoutMs: 1_250 }).timeoutMs).toBe(1_250); + expect(decodeScrollInput({ deltaY: 100, timeoutMs: 1_250 }).timeoutMs).toBe(1_250); + expect(decodeSetColorSchemeInput({ colorScheme: "dark", timeoutMs: 1_250 }).timeoutMs).toBe( + 1_250, + ); + expect(decodeRecordingStartInput({ timeoutMs: 1_250 }).timeoutMs).toBe(1_250); + expect(decodeEvaluateInput({ expression: "document.title", timeoutMs: 1_250 }).timeoutMs).toBe( + 1_250, + ); + }); +}); describe("PreviewAutomationOpenInput", () => { it("accepts the inline preview visibility flag", () => { @@ -167,14 +191,37 @@ describe("PreviewAutomationHost", () => { expect( decodeAutomationHost({ clientId: "current", + hostId: "desktop-current", environmentId: "environment-1", + label: "MacBook Pro", + platform: "macos", supportedOperations: ["status", "resize"], - }).supportedOperations, - ).toEqual(["status", "resize"]); + }), + ).toMatchObject({ + hostId: "desktop-current", + label: "MacBook Pro", + platform: "macos", + supportedOperations: ["status", "resize"], + }); }); }); describe("PreviewAutomationError", () => { + it("preserves explicit host selection failures without operation context", () => { + const error = decodeAutomationError({ + _tag: "PreviewAutomationHostAssignmentConflictError", + environmentId: "environment-1", + threadId: "thread-1", + providerSessionId: "provider-session-1", + providerInstanceId: "codex", + requestedHostId: "host-windows", + assignedHostId: "host-mac", + }); + + expect(error._tag).toBe("PreviewAutomationHostAssignmentConflictError"); + expect(error.message).toContain("host-mac"); + }); + it("preserves a typed non-editable target failure", () => { const error = decodeAutomationError({ _tag: "PreviewAutomationTargetNotEditableError", diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 6e4154301a19..ff799310c995 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -162,6 +162,13 @@ export const PreviewNavStatus = Schema.Union([ ]); export type PreviewNavStatus = typeof PreviewNavStatus.Type; +export const PreviewAutomationOrigin = Schema.Struct({ + contextId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + hostId: TrimmedNonEmptyString, + hostLabel: Schema.String, +}); +export type PreviewAutomationOrigin = typeof PreviewAutomationOrigin.Type; + export const PreviewSessionSnapshot = Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: PreviewTabId, @@ -176,6 +183,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ * switching would require tearing the guest down and losing page state. */ profileId: Schema.optional(BrowserProfileId), + /** Creator attribution; tabs remain in the main conversation. */ + automationOrigin: Schema.optional(PreviewAutomationOrigin), updatedAt: Schema.String, }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; @@ -193,6 +202,7 @@ export const PreviewOpenInput = Schema.Struct({ viewport: Schema.optional(PreviewViewportSetting), /** Omit to open under the client's configured default profile. */ profileId: Schema.optional(BrowserProfileId), + automationOrigin: Schema.optional(PreviewAutomationOrigin), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index f87739670f75..d31223780b25 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -41,6 +41,7 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ /** Advertised by current desktop hosts for mixed-version routing. */ export const PREVIEW_AUTOMATION_OPERATIONS = [ ...PREVIEW_AUTOMATION_V1_OPERATIONS, + "close", "resize", "setColorScheme", ] as const; @@ -63,6 +64,17 @@ const PreviewAutomationTabTargetFields = { export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; +/** + * Whether the host renderer's animation frames fire. `paused` means Chromium + * treats the desktop window as hidden (idle display, minimized or occluded + * window). An idle display also stalls native captures, presentation waits, + * and recordings until input resumes; a merely minimized window has still + * captured on Windows, so read `paused` together with the snapshot's own + * `screenshotUnavailable` stage. + */ +export const PreviewAutomationHostRendering = Schema.Literals(["active", "paused"]); +export type PreviewAutomationHostRendering = typeof PreviewAutomationHostRendering.Type; + export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, visible: Schema.Boolean, @@ -74,6 +86,14 @@ export const PreviewAutomationStatus = Schema.Struct({ viewportSetting: Schema.optional(PreviewViewportSetting), /** Measured guest-page viewport in CSS pixels when a webview is ready. */ viewport: Schema.optional(PreviewRenderedViewportSize), + /** Measured only by hosts that probe frame liveness and only when the request budget allows. */ + hostRendering: Schema.optional(PreviewAutomationHostRendering), + /** + * Whether the desktop host currently holds its display-sleep block for live + * automation, so an idle display cannot starve compositor frames. Reported + * only by desktop hosts that keep the display awake. + */ + displaySleepBlocked: Schema.optional(Schema.Boolean), }); export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; @@ -110,7 +130,7 @@ export const PreviewAutomationOpenInput = Schema.Struct({ ) .annotate({ description: - "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", + "Opens the collaborative browser for the current thread. Newly created tabs acknowledge server creation immediately while any requested presentation and initial page load continue; reopening an existing shown tab waits for stable panel presentation. Wait on the returned tab before interacting while its initial page loads.", }); export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; @@ -267,6 +287,7 @@ export const PreviewAutomationSetColorSchemeInput = Schema.Struct({ description: "Emulated prefers-color-scheme for the page: light, dark, or system to follow the OS appearance.", }), + timeoutMs: OptionalTimeoutMs, }).annotate({ description: "Emulates prefers-color-scheme in the active browser tab without changing the OS or app theme.", @@ -377,6 +398,7 @@ export const PreviewAutomationPressInput = Schema.Struct({ description: "Modifier keys held while pressing key.", }), ), + timeoutMs: OptionalTimeoutMs, }).annotate({ description: "Presses one keyboard key in the active browser tab." }); export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; @@ -398,6 +420,7 @@ export const PreviewAutomationScrollInput = Schema.Struct({ locator: Schema.optional(Locator).annotate({ description: "Playwright selector for a scrollable container. Omit to scroll the viewport.", }), + timeoutMs: OptionalTimeoutMs, }) .check( Schema.makeFilter((input) => { @@ -415,6 +438,12 @@ export const PreviewAutomationScrollInput = Schema.Struct({ }); export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; +export const PreviewAutomationRecordingStartInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, + timeoutMs: OptionalTimeoutMs, +}).annotate({ description: "Starts recording the active browser tab." }); +export type PreviewAutomationRecordingStartInput = typeof PreviewAutomationRecordingStartInput.Type; + export const PreviewAutomationEvaluateInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, expression: Schema.String.check(Schema.isTrimmed()) @@ -438,6 +467,7 @@ export const PreviewAutomationEvaluateInput = Schema.Struct({ "Serialize and return the value instead of a remote object reference. Defaults to true.", }), ), + timeoutMs: OptionalTimeoutMs, }).annotate({ description: "Evaluates JavaScript in the page. Prefer snapshot and semantic actions; use evaluate for inspection or unsupported interactions.", @@ -527,6 +557,31 @@ export const PreviewAutomationActionEvent = Schema.Struct({ }); export type PreviewAutomationActionEvent = typeof PreviewAutomationActionEvent.Type; +/** + * Where a missing screenshot was lost: the native capture rejected, the image + * could not be encoded, the capture was interrupted at the screenshot budget, + * an earlier interrupted capture still blocks the host's capture gate, or the + * operation deadline left no budget to try. + */ +export const PreviewAutomationScreenshotUnavailableStage = Schema.Literals([ + "capture", + "encode", + "interrupted", + "pending-capture", + "budget", +]); +export type PreviewAutomationScreenshotUnavailableStage = + typeof PreviewAutomationScreenshotUnavailableStage.Type; + +export const PreviewAutomationScreenshotUnavailable = Schema.Struct({ + stage: PreviewAutomationScreenshotUnavailableStage, + /** The host's own failure message, bounded and free of page content. */ + message: Schema.String.check(Schema.isMaxLength(512)), + hostRendering: Schema.optional(PreviewAutomationHostRendering), +}); +export type PreviewAutomationScreenshotUnavailable = + typeof PreviewAutomationScreenshotUnavailable.Type; + export const PreviewAutomationSnapshot = Schema.Struct({ url: Schema.String, title: Schema.String, @@ -537,12 +592,16 @@ export const PreviewAutomationSnapshot = Schema.Struct({ consoleEntries: Schema.Array(PreviewAutomationConsoleEntry), networkEntries: Schema.Array(PreviewAutomationNetworkEntry), actionTimeline: Schema.Array(PreviewAutomationActionEvent), - screenshot: Schema.Struct({ - mimeType: Schema.Literal("image/png"), - data: Schema.String, - width: Schema.Int, - height: Schema.Int, - }), + screenshot: Schema.NullOr( + Schema.Struct({ + mimeType: Schema.Literal("image/png"), + data: Schema.String, + width: Schema.Int, + height: Schema.Int, + }), + ), + /** Present only when `screenshot` is null and the host could say why. */ + screenshotUnavailable: Schema.optional(PreviewAutomationScreenshotUnavailable), }); export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; @@ -570,6 +629,17 @@ export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; +export const PreviewAutomationHostPlatform = Schema.Literals([ + "macos", + "windows", + "linux", + "unknown", +]); +export type PreviewAutomationHostPlatform = typeof PreviewAutomationHostPlatform.Type; + +export const PreviewAutomationHostLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type PreviewAutomationHostLabel = typeof PreviewAutomationHostLabel.Type; + export const PreviewAutomationHostIdentity = Schema.Struct({ clientId: PreviewAutomationClientId, environmentId: EnvironmentId, @@ -578,6 +648,12 @@ export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity export const PreviewAutomationHost = Schema.Struct({ ...PreviewAutomationHostIdentity.fields, + /** Stable physical renderer identity. Missing legacy values fall back to clientId. */ + hostId: Schema.optional(PreviewAutomationClientId), + /** Optional so current servers remain compatible with older desktop renderers. */ + label: Schema.optional(PreviewAutomationHostLabel), + /** Optional so current servers remain compatible with older desktop renderers. */ + platform: Schema.optional(PreviewAutomationHostPlatform), /** * Missing means the pre-capability-negotiation V1 operation set. This lets * a newer server safely coexist with an older desktop during rollout. @@ -586,6 +662,46 @@ export const PreviewAutomationHost = Schema.Struct({ }); export type PreviewAutomationHost = typeof PreviewAutomationHost.Type; +export const PreviewAutomationAvailableHost = Schema.Struct({ + hostId: PreviewAutomationClientId, + label: PreviewAutomationHostLabel, + platform: PreviewAutomationHostPlatform, + supportedOperations: Schema.Array(PreviewAutomationOperation), + focused: Schema.Boolean, +}); +export type PreviewAutomationAvailableHost = typeof PreviewAutomationAvailableHost.Type; + +export const PreviewAutomationHostListInput = Schema.Struct({ + platform: Schema.optional( + PreviewAutomationHostPlatform.annotate({ + description: "Optional renderer platform filter. Omit to list every available host.", + }), + ), +}); +export type PreviewAutomationHostListInput = typeof PreviewAutomationHostListInput.Type; + +export const PreviewAutomationHostList = Schema.Struct({ + hosts: Schema.Array(PreviewAutomationAvailableHost), + assignedHostId: Schema.NullOr(PreviewAutomationClientId), +}); +export type PreviewAutomationHostList = typeof PreviewAutomationHostList.Type; + +export const PreviewAutomationSelectHostInput = Schema.Struct({ + hostId: Schema.String.check(Schema.isTrimmed()) + .check(Schema.isNonEmpty()) + .check(Schema.isMaxLength(128)) + .annotateKey({ + description: + "Stable host id returned by preview_list_hosts. The host must be available in this caller's environment.", + }), +}); +export type PreviewAutomationSelectHostInput = typeof PreviewAutomationSelectHostInput.Type; + +export const PreviewAutomationHostSelection = Schema.Struct({ + host: PreviewAutomationAvailableHost, +}); +export type PreviewAutomationHostSelection = typeof PreviewAutomationHostSelection.Type; + export const PreviewAutomationHostFocus = Schema.Struct({ ...PreviewAutomationHostIdentity.fields, connectionId: PreviewAutomationConnectionId, @@ -605,6 +721,8 @@ export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; export const PreviewAutomationRequest = Schema.Struct({ requestId: TrimmedNonEmptyString, threadId: ThreadId, + /** Independent agent control within the bearer-owned conversation. */ + contextId: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), tabId: Schema.optional(PreviewTabId), tabIdExplicit: Schema.optional(Schema.Boolean), operation: PreviewAutomationOperation, @@ -675,14 +793,18 @@ export class McpCapabilityUnavailableError extends Schema.TaggedError()( + "PreviewAutomationHostUnavailableError", + { + ...PreviewAutomationSessionScopeErrorFields, + hostId: PreviewAutomationClientId, + operation: Schema.optional(PreviewAutomationOperation), + }, +) { + override get message(): string { + return this.operation === undefined + ? `Preview automation host ${this.hostId} is not available in environment ${this.environmentId}.` + : `Preview automation host ${this.hostId} is not available for ${this.operation} in environment ${this.environmentId}.`; + } +} + +export class PreviewAutomationHostAssignmentConflictError extends Schema.TaggedError()( + "PreviewAutomationHostAssignmentConflictError", + { + ...PreviewAutomationSessionScopeErrorFields, + requestedHostId: PreviewAutomationClientId, + assignedHostId: PreviewAutomationClientId, + }, +) { + override get message(): string { + return `This agent's preview context is already assigned to host ${this.assignedHostId}; it was not moved to ${this.requestedHostId}. Existing tabs stay on their host across reconnection. A separate agent context can select another host.`; + } +} + export class PreviewAutomationUnsupportedClientError extends Schema.TaggedError()( "PreviewAutomationUnsupportedClientError", { @@ -929,6 +1079,8 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationRecordingDeadlineExpiredError, PreviewAutomationUnavailableError, PreviewAutomationNoAvailableHostError, + PreviewAutomationHostUnavailableError, + PreviewAutomationHostAssignmentConflictError, PreviewAutomationUnsupportedClientError, PreviewAutomationTabNotFoundError, PreviewAutomationTimeoutError, diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts index 13336cb48a1e..1ff9eb56331f 100644 --- a/packages/shared/src/devProxy.ts +++ b/packages/shared/src/devProxy.ts @@ -10,6 +10,60 @@ */ export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; +/** Internal listener and proxy host for worktree-local development services. */ +export const DEV_LOOPBACK_HOST = "127.0.0.1"; + +/** Host advertised to Chromium so either IPv4 or IPv6 loopback listeners remain reachable. */ +export const DEV_BROWSER_LOOPBACK_HOST = "localhost"; + +export function resolveDevProxyTarget( + backendPort: string | undefined, + wsUrl: string | undefined, + backendHost: string | undefined, +): string | undefined { + // Browser dev is single-origin: the backend port is proxied through the web + // server so the app works from any origin (localhost, tailnet, LAN, phone). + const port = Number(backendPort?.trim()); + if (Number.isInteger(port) && port > 0) { + const normalizedHost = backendHost?.trim(); + const proxyHost = + normalizedHost === "::1" || + normalizedHost === "[::1]" || + normalizedHost === "::" || + normalizedHost === "[::]" + ? "[::1]" + : normalizedHost === "0.0.0.0" || !normalizedHost + ? DEV_LOOPBACK_HOST + : normalizedHost === DEV_BROWSER_LOOPBACK_HOST + ? DEV_BROWSER_LOOPBACK_HOST + : normalizedHost.includes(":") && !normalizedHost.startsWith("[") + ? `[${normalizedHost}]` + : normalizedHost; + return `http://${proxyHost}:${port}/`; + } + + // Desktop dev points the renderer straight at the backend, so fall back to + // deriving the target from its explicit websocket URL. + if (!wsUrl) { + return undefined; + } + + try { + const url = new URL(wsUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return undefined; + } +} + export function isDevProxiedPath(pathname: string): boolean { return DEV_PROXIED_PATH_PREFIXES.some( (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 5a270386a30d..139c163c820b 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -253,8 +253,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, path.resolve("/tmp/custom-t3")); assert.equal(env.T3CODE_PORT, "4222"); - assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); - assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); + assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); assert.equal(env.T3CODE_NO_BROWSER, "1"); assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); assert.equal(env.T3CODE_LOG_WS_EVENTS, "1"); @@ -378,6 +378,10 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); + assert.equal( + env.T3CODE_DESKTOP_USER_DATA_DIR, + path.resolve("/tmp/my-t3/userdata/electron"), + ); assert.equal(env.PORT, "5733"); assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); assert.equal(env.HOST, "127.0.0.1"); @@ -408,6 +412,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_PORT, "13773"); assert.equal(env.PORT, "5733"); + assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); + assert.equal(env.HOST, undefined); }), ); @@ -485,7 +491,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); - assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); }), ); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 5a44c7c1845e..65ea6f236c25 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -6,6 +6,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { DEV_LOOPBACK_HOST } from "@t3tools/shared/devProxy"; import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; @@ -28,7 +29,6 @@ const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; -const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; // HTTP(S) requests to these ports are blocked by the Fetch standard before a // browser reaches the network. Keep the complete list here so explicit or // future wider offsets cannot produce a URL that curl accepts but browsers @@ -48,10 +48,10 @@ const FETCH_BAD_PORTS = new Set([ const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; /** - * Bind hosts on which a backend still answers `http://localhost:`, which - * is where single-origin browser dev proxies to. Loopback and the wildcards - * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only - * that address and the proxy target goes dark. + * Bind hosts on which a backend still answers through a local loopback address, + * which is where single-origin browser dev proxies to. Loopback and the + * wildcards qualify; a specific interface (e.g. a LAN IP) does not — the OS + * binds only that address and the proxy target goes dark. */ export function isProxiableBindHost(host: string): boolean { const normalized = host.trim(); @@ -307,6 +307,7 @@ export function createDevRunnerEnv({ devUrl, }: CreateDevRunnerEnvInput): Effect.Effect { return Effect.gen(function* () { + const path = yield* Path.Path; const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved @@ -318,9 +319,7 @@ export function createDevRunnerEnv({ const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), - VITE_DEV_SERVER_URL: - devUrl?.toString() ?? - `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, + VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://${DEV_LOOPBACK_HOST}:${webPort}`, }; if (configuredBaseDir !== undefined) { @@ -329,6 +328,12 @@ export function createDevRunnerEnv({ delete output.T3CODE_HOME; } + if (isDesktopMode && configuredBaseDir !== undefined) { + output.T3CODE_DESKTOP_USER_DATA_DIR = path.join(resolvedBaseDir, "userdata", "electron"); + } else { + delete output.T3CODE_DESKTOP_USER_DATA_DIR; + } + // A dev-runner server is never launcher-managed. When the shell that runs // this script was itself spawned by the machine's managed t3 service (an // agent working inside T3 Code), these leak through and the child server @@ -363,14 +368,14 @@ export function createDevRunnerEnv({ // ignore those values rather than infer from their absence. output.T3CODE_SINGLE_ORIGIN_DEV = "1"; } else { - output.VITE_HTTP_URL = `http://localhost:${serverPort}`; - output.VITE_WS_URL = `ws://localhost:${serverPort}`; + output.VITE_HTTP_URL = `http://${DEV_LOOPBACK_HOST}:${serverPort}`; + output.VITE_WS_URL = `ws://${DEV_LOOPBACK_HOST}:${serverPort}`; delete output.T3CODE_SINGLE_ORIGIN_DEV; } } else { output.T3CODE_PORT = String(serverPort); - output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; - output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; + output.VITE_HTTP_URL = `http://${DEV_LOOPBACK_HOST}:${serverPort}`; + output.VITE_WS_URL = `ws://${DEV_LOOPBACK_HOST}:${serverPort}`; // Desktop pins the renderer to loopback on purpose; an ambient marker // must not make Vite drop those URLs. delete output.T3CODE_SINGLE_ORIGIN_DEV; @@ -411,7 +416,7 @@ export function createDevRunnerEnv({ } if (isDesktopMode) { - output.HOST = DESKTOP_DEV_LOOPBACK_HOST; + output.HOST = DEV_LOOPBACK_HOST; delete output.T3CODE_DESKTOP_WS_URL; } From e00af5a986253752e3250f7b6cb44ef11e99b09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 30 Sep 2026 12:31:38 +0100 Subject: [PATCH 2/2] docs(preview): update base and verification facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Co-authored by GPT-6 in Codex via T3 Code --- BRANCH_DETAILS.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 7f3b94aac849..43bdeb54f431 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -129,18 +129,19 @@ Primary files: Focused regression coverage lives in `scripts/dev-runner.test.ts`, `apps/desktop/src/app/DesktopAppIdentity.test.ts`, `apps/desktop/src/app/DesktopEnvironment.test.ts`, `apps/desktop/src/ipc/methods/window.test.ts`, `apps/desktop/src/preview/Manager.test.ts`, `apps/server/src/mcp/McpHttpServer.test.ts`, `apps/server/src/mcp/PreviewAutomationBroker.test.ts`, `apps/server/src/mcp/toolkits/preview/tools.test.ts`, `apps/server/src/mcp/toolkits/preview/handlers.test.ts`, `apps/web/src/browser/browserRecording.test.ts`, `apps/web/src/browser/browserRecordingUpload.test.ts`, `apps/web/src/browser/recordingCompositor.test.ts`, `apps/web/src/browser/browserSurfaceStore.test.ts`, `apps/web/src/browser/browserTargetResolver.test.ts`, `apps/web/src/browser/hostedBrowserWebviewStyle.test.ts`, `apps/web/src/browser/browserViewportActions.test.ts`, `apps/web/src/browser/browserViewportLayout.test.ts`, `apps/web/src/browser/previewRuntimeTabId.test.ts`, `apps/web/src/components/auth/PairingRouteSurface.logic.test.ts`, `apps/web/src/components/preview/closePreviewAutomationTab.test.ts`, `apps/web/src/components/preview/previewAutomationClientId.test.ts`, `apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts`, `apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts`, `apps/web/src/components/preview/previewAutomationPresentation.test.ts`, `apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts`, `apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts`, `apps/web/src/components/preview/previewHostRendering.test.ts`, `apps/web/src/components/preview/previewNavigationReadiness.test.ts`, `apps/web/src/components/preview/previewViewportReadiness.test.ts`, `apps/web/src/components/preview/previewViewportRollback.test.ts`, `packages/contracts/src/ipc.test.ts`, and `packages/contracts/src/preview.test.ts`. ```sh -vp test run scripts/dev-runner.test.ts apps/desktop/src/app/DesktopAppIdentity.test.ts apps/desktop/src/app/DesktopEnvironment.test.ts apps/desktop/src/ipc/methods/window.test.ts apps/desktop/src/preview/Manager.test.ts apps/server/src/mcp/McpHttpServer.test.ts apps/server/src/mcp/PreviewAutomationBroker.test.ts apps/server/src/mcp/toolkits/preview/tools.test.ts apps/server/src/mcp/toolkits/preview/handlers.test.ts apps/web/src/browser/browserRecording.test.ts apps/web/src/browser/browserRecordingUpload.test.ts apps/web/src/browser/recordingCompositor.test.ts apps/web/src/components/preview/PreviewAutomationHosts.test.tsx apps/web/src/browser/browserSurfaceStore.test.ts apps/web/src/browser/browserTargetResolver.test.ts apps/web/src/browser/hostedBrowserWebviewStyle.test.ts apps/web/src/browser/browserViewportActions.test.ts apps/web/src/browser/browserViewportLayout.test.ts apps/web/src/browser/previewRuntimeTabId.test.ts apps/web/src/components/auth/PairingRouteSurface.logic.test.ts apps/web/src/components/preview/closePreviewAutomationTab.test.ts apps/web/src/components/preview/previewAutomationClientId.test.ts apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts apps/web/src/components/preview/previewAutomationPresentation.test.ts apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts apps/web/src/components/preview/previewHostRendering.test.ts apps/web/src/components/preview/previewNavigationReadiness.test.ts apps/web/src/components/preview/previewViewportReadiness.test.ts apps/web/src/components/preview/previewViewportRollback.test.ts packages/contracts/src/ipc.test.ts packages/contracts/src/preview.test.ts apps/server/src/auth/EnvironmentAuth.test.ts apps/server/src/provider/Layers/ClaudeAdapter.test.ts apps/server/src/pullRequest/PullRequestService.test.ts apps/mobile/src/features/terminal/terminalInput.test.ts apps/mobile/src/features/terminal/terminalPaste.test.ts apps/mobile/src/features/terminal/terminalMenu.test.ts +vp test run scripts/dev-runner.test.ts apps/desktop/src/app/DesktopAppIdentity.test.ts apps/desktop/src/app/DesktopEnvironment.test.ts apps/desktop/src/app/DesktopClerk.test.ts apps/desktop/src/ipc/methods/window.test.ts apps/desktop/src/preview/Manager.test.ts apps/server/src/mcp/McpHttpServer.test.ts apps/server/src/mcp/PreviewAutomationBroker.test.ts apps/server/src/mcp/toolkits/preview/tools.test.ts apps/server/src/mcp/toolkits/preview/handlers.test.ts apps/server/src/preview/Manager.test.ts apps/web/src/browser/browserRecording.test.ts apps/web/src/browser/browserRecordingUpload.test.ts apps/web/src/browser/recordingCompositor.test.ts apps/web/src/components/preview/PreviewAutomationHosts.test.tsx apps/web/src/browser/browserSurfaceStore.test.ts apps/web/src/browser/browserTargetResolver.test.ts apps/web/src/browser/hostedBrowserWebviewStyle.test.ts apps/web/src/browser/browserViewportActions.test.ts apps/web/src/browser/browserViewportLayout.test.ts apps/web/src/browser/previewRuntimeTabId.test.ts apps/web/src/components/auth/PairingRouteSurface.logic.test.ts apps/web/src/components/preview/closePreviewAutomationTab.test.ts apps/web/src/components/preview/previewAutomationClientId.test.ts apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts apps/web/src/components/preview/previewAutomationOverlayReadiness.test.ts apps/web/src/components/preview/previewAutomationPresentation.test.ts apps/web/src/components/preview/previewAutomationPresentationSuppression.test.ts apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts apps/web/src/components/preview/previewAutomationTarget.test.ts apps/web/src/components/preview/previewHostRendering.test.ts apps/web/src/components/preview/previewNavigationReadiness.test.ts apps/web/src/components/preview/previewViewportReadiness.test.ts apps/web/src/components/preview/previewViewportRollback.test.ts packages/contracts/src/ipc.test.ts packages/contracts/src/preview.test.ts packages/client-runtime/src/rpc/session.test.ts ``` Current verification: -- The upstream base is `d2c9281b8112dc3b2991642c4bdb985e4b08b9bb`. -- Capture re-registration coverage passes all 117 manager tests, including interruption before and after re-registration, native-tail settlement recovery, and generation replacement during the retry delay. Desktop typechecking passes. The earlier integrated Electron results below remain the runtime evidence; this follow-up changes only the manager's capture lifecycle. -- Desktop, web, server, and contract typechecks pass. The focused list above passes 883 tests with one skipped across 39 files, including the screenshot-unavailable stages in the desktop manager, the broker's eviction grace with late typed-timeout and late-success answers, the MCP snapshot reason text, and the host frame-liveness probe. Recording, compositor, and upload coverage passes, including bounded compositor initialization and late disposal that leaves a newer recording active. -- Targeted lint over the touched files has no errors and only pre-existing warnings; formatting checks pass. -- Display-wake pass on the same isolated stack, driven through its own `/mcp` tools with no session input: with the block held, `preview_status` reported `hostRendering: "active"` and `displaySleepBlocked: true` at 2, 4, 6, and 8 minutes idle (the unblocked host had read `paused` and returned an interrupted screenshot at about 5 minutes), and `preview_snapshot` at 8 minutes idle returned a 1280×800 PNG in 82 ms. After 5.5 minutes with no automation traffic the desktop trace recorded one `PreviewManager.releaseDisplayWake` span (inactivity); the next status request restarted the block, and closing the last automation tab recorded a second release. Desktop, web, server, and contract typechecks pass; the manager suite passes 109 tests including the display-wake cases. +- The upstream base is `c18e5ea6ed741443a8ec4a5d22d4b6939b0ecd21`; the verified source baseline is `1400a6e00ad8429faf4c8d0822d6dcc46e07c2cc`. +- The focused list above passes 606 tests with one skipped across 37 files. Coverage includes capture re-registration and generation replacement, degraded snapshots, broker eviction grace, caller isolation, concurrent RPC delivery, recording finalization, compositor startup, and upload retries. +- Desktop, web, server, contracts, and client-runtime typechecks pass. The desktop auth fixture passes all eight tests in both the branch and the same-host upstream control. Targeted lint on that fixture, commit formatting checks, and `git diff --check` pass. + +Historical integrated runtime evidence, not rerun on the current base: + +- Display-wake pass on the same isolated stack, driven through its own `/mcp` tools with no session input: with the block held, `preview_status` reported `hostRendering: "active"` and `displaySleepBlocked: true` at 2, 4, 6, and 8 minutes idle (the unblocked host had read `paused` and returned an interrupted screenshot at about 5 minutes), and `preview_snapshot` at 8 minutes idle returned a 1280×800 PNG in 82 ms. After 5.5 minutes with no automation traffic the desktop trace recorded one `PreviewManager.releaseDisplayWake` span (inactivity); the next status request restarted the block, and closing the last automation tab recorded a second release. - Isolated Windows Electron pass through the main runner's `dev:desktop` (worktree `.t3` state, own Electron profile, `T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT` for the driver), driving the isolated server's own `/mcp` endpoint with the credential a live Claude turn received: `preview_status` returned `hostRendering: "active"` in 17 ms once the display was awake and `"paused"` while it was idle or the window was minimized; a normal `preview_snapshot` returned a 1280×800 PNG in 82 ms; the idle display produced the `interrupted` reason with `hostRendering: "paused"` in the tool text; and a `preview_evaluate` with `timeoutMs: 1000` fired 838 ms into a 2.5 s renderer main-thread stall failed the caller with `Preview automation evaluate timed out after 1000ms.` at 1016 ms while `preview_list_hosts` still listed the same assigned host 140 ms later, the next `preview_evaluate` succeeded on it, and the server trace showed no `closeConnection` or `disconnect` span and no new `acquireConnection`. -- Caller-context and transport verification passes 204 focused tests, with server, web, client-runtime, and contract typechecks passing. - Isolated Windows Electron verification passes independent caller tab creation, navigation, input, implicit-target preservation, and background snapshots. A real WebSocket experiment inside Electron reproduces cross-request blocking with the original transport and verifies independent delivery with the fix, preserving all 18 stream values in order. The Windows screenshot-save assertion decodes JSON before comparing the path. The original production subscriber slowdown remains unproven; the corrected transport failure is independently reproduced. Shared client transport applies to web, desktop, and mobile; native mobile UI was not exercised for this change. ## Development Ports