Skip to content

Route preview automation through live owner streams#3548

Merged
juliusmarminge merged 12 commits into
mainfrom
t3code/preview-owner-audit
Jun 26, 2026
Merged

Route preview automation through live owner streams#3548
juliusmarminge merged 12 commits into
mainfrom
t3code/preview-owner-audit

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

Replaces preview automation's replicated owner bookkeeping with live, environment-scoped desktop hosts. A host exists only while its request stream is connected; thread selection travels with each request instead of controlling host availability.

Provider sessions are pinned to one desktop runtime so multi-step browser work cannot jump between independent Electron cookie and DOM state. Pinning survives focus changes and fails over only when that host connection disappears.

Also adds durable, per-tab browser viewport sizing for users and agents, including a complete device-toolbar interaction: the preview menu reveals a Chrome-style toolbar and resize rails with responsive sizing, exact dimensions, named device presets, rotation, and a one-click return to Fill panel.

Why

Preview automation could randomly stop accepting navigation or snapshot requests after pairing, reconnecting, switching threads, or moving focus between windows. The development app was still running, but the server had either lost the valid automation host or retained one without a usable transport.

The old design also coupled automation availability to whichever thread a desktop was currently displaying. That meant a phone-originated chat could not use an otherwise healthy Mac Mini Electron runtime unless the Mini happened to have the same thread open. Thread focus is UI state, not host capability, so it should never have been an eligibility requirement.

Browser layout testing had a separate source-of-truth problem: the guest webview always inherited the preview panel's dimensions. Users could not hold a mobile or desktop breakpoint constant while resizing the app, agents had no protocol operation for selecting or verifying a viewport, and the earlier sizing UI did not provide a coherent toolbar/rail workflow. Zoom, cursor overlays, and recordings also assumed that panel bounds and browser-content bounds were identical.

What was broken before

  • The web owner used React.useId(), which is not globally unique across Electron windows or independent mounts.
  • Owner snapshots and live request streams were separate broker state and could disagree.
  • Independent asynchronous reportOwner and clearOwner calls allowed stale mounts to erase or recreate newer ownership.
  • Client timestamps controlled focus ordering and could arrive out of order.
  • Requests and responses were not fully bound to the connection lease that received them.
  • Routing, pending-request insertion, stream registration, and cleanup had interleaving race windows.
  • Broker provisioning was implicit enough that MCP and different WebSocket sessions were not obviously guaranteed to share one state instance.
  • The renderer mounted an owner inside ChatView for only activeThreadRef, and the broker filtered by that thread. A capable desktop became unavailable merely because its UI was showing another conversation.
  • Multiple connected desktops were reselected per request, allowing one agent sequence to jump between physically separate browser runtimes.
  • Viewport size was ephemeral panel layout rather than part of the preview session, so it could not be controlled remotely, persisted per tab, or reported to automation.
  • Content positioning, agent cursors, and recording dimensions treated the outer panel as the page viewport, which becomes incorrect as soon as a fixed viewport is centered or scrollable.
  • Rapid UI resize commits could resolve out of order, allowing an older drag or dimension edit to overwrite a newer one.
  • The old corner-only resize affordance was hard to grab and did not expose a complete device emulation workflow.

How host routing works now

  1. Register the desktop, not the visible thread. Each Electron renderer creates one cryptographically random host identity per environment and keeps the host mounted at the renderer root. Web and iOS clients do not register automation hosts.
  2. Acquire capability through a live stream. Connecting creates one broker connection and a server-issued cryptographic connectionId lease. Stream lifetime is the source of truth for availability.
  3. Carry thread scope on the request. Every automation request includes its threadId. The selected desktop synchronizes that thread's preview sessions and can create a hidden Electron webview even when another thread is visible.
  4. Pin provider sessions to a desktop. The first request chooses the best compatible host for the environment. Later requests from that provider session remain on the same connection regardless of focus changes. The assignment is removed on disconnect, replacement, or credential expiry.
  5. Use focus only as initial preference. Among equally capable hosts, a focused desktop wins initial selection; otherwise the most recently focused or connected host wins. An unfocused desktop remains fully eligible.
  6. Route and reserve atomically. Host selection, affinity assignment, and pending-request insertion happen in one synchronized broker state transition.
  7. Validate every response. Responses must match the exact client, connection lease, and pending request. Stale streams cannot complete newer work.
  8. Let stream finalizers own cleanup. Finalization removes only the exact lease and its pending work. An old stream cannot clear its replacement.
  9. Share one broker explicitly. MCP and every WebSocket session receive the same broker instance at server route assembly.

How the device-toolbar flow works now

  1. Hidden by default. The preview's More menu contains Show device toolbar. In Fill mode the guest continues to track the available panel exactly, with no extra toolbar or rails taking space.
  2. Show without a layout jump. Enabling the toolbar snapshots the guest's currently available CSS-pixel dimensions into Responsive mode, then reveals the host-side toolbar and its resize frame.
  3. Resize from real rails. Forty-pixel left, right, and bottom rails plus both bottom corners provide large pointer and keyboard targets. Drag math tracks the grabbed edge across centered, clipped, zoomed, and panel-boundary states.
  4. Choose or type a size. The toolbar selector groups common desktop, laptop, iPad, iPhone, Pixel, and Galaxy presets. Width and height fields allow exact freeform values; edits commit on Enter or when leaving the control.
  5. Rotate in place. Rotate swaps width and height while preserving the selected preset identity, so portrait and landscape remain the same device selection.
  6. Return to Fill. The toolbar's close button or Hide device toolbar switches the tab back to Fill panel and removes the toolbar and rails.

The toolbar is derived from the tab's viewport setting, not from a separate visibility flag. Therefore an agent calling preview_resize with a preset or freeform size exposes the same UI, while selecting Fill hides it. UI commits are serialized per tab so fast drags, keyboard changes, exact-input edits, and MCP updates cannot settle out of order.

Fixed Responsive and preset viewports remain unchanged when the right panel is resized; the outer area only changes centering or scrolling. The Electron host converts CSS-pixel targets to rendered element bounds using the tab's current zoom factor. Agent cursors, scrolling, hidden tabs, and recording canvases use the actual content rectangle rather than the outer panel rectangle.

The selected mode and dimensions live in the server's per-thread, per-tab preview snapshot. Navigation and status updates preserve them, and a resized event synchronizes every connected client without activating a background tab. Browser tabs and device sizes therefore remain scoped to their own thread even when another thread is visible.

The preview_resize MCP tool accepts fill, freeform, or preset input. It waits until the renderer has applied the requested mode and the guest reports the expected window.innerWidth/window.innerHeight; preview_status reports both the setting and measured CSS viewport.

Device presets intentionally model viewport dimensions only. They do not spoof a mobile user agent, device-pixel ratio, touch input, or other device capabilities.

Remote behavior

  • A chat sent from iOS can use a connected Mac Mini or MacBook desktop host for that environment, including creating a hidden webview for a thread the desktop is not displaying.
  • The desktop does not need to display the target thread, keep the preview panel open, or hold window focus.
  • Browser tabs and viewport settings remain scoped per thread/tab. Opening or resizing a browser in thread B never makes thread B's preview appear in thread A's right panel.
  • If both Mac Mini and MacBook are eligible and equally capable, focus influences only the initial choice. The provider session then remains pinned to that machine.
  • If the pinned desktop disconnects, the next request selects a remaining compatible host. It does not silently switch machines while the original host is alive.

Resulting invariants

  • No automation host exists without a live request stream.
  • Host capability is environment-scoped and independent of routed thread UI.
  • A stale connection cannot focus, clear, or respond for its replacement.
  • A provider session cannot jump between live desktop runtimes.
  • Pending requests cannot cross connection or server-lifetime boundaries.
  • All MCP and WebSocket routing observes the same broker state.
  • Every preview tab has one server-owned viewport setting; missing legacy data deterministically means Fill panel.
  • Fixed viewport dimensions do not change when the outer panel changes.
  • Toolbar visibility, resize rails, UI actions, and MCP resizing all derive from that same per-tab setting.
  • A successful agent resize result reflects the measured guest CSS viewport, not assumed DOM bounds.

Testing

  • Preview broker, MCP, renderer-root, identity, stream-consumer, cross-WebSocket, resize persistence, compatibility routing, and sticky-assignment coverage remains in place.
  • Focused viewport layout, zoom, rail geometry, responsive capture, serialized action, and client-state suites: 12 tests passed across 3 files.
  • vp check — passed with 0 errors (20 existing repository-wide warnings).
  • vp run typecheck — passed across all 15 packages.
  • React Doctor — no new device-toolbar findings; remaining findings are existing compiler/manual-memoization diagnostics outside this interaction.

Note

High Risk
Large cross-cutting change to MCP/WebSocket preview routing, session affinity, and Electron CDP input; breaking error-tag changes for automation callers.

Overview
Preview automation no longer uses replicated reportOwner / clearOwner state. Desktops connect as environment-scoped hosts over a live stream (connected + request events) with a server-issued connectionId; focusHost only affects tie-breaking. The broker pins each provider session to one host, routes threadId on each request, validates responses against the pending lease, and is provisioned once for MCP and all WebSocket sessions. Public errors shift toward PreviewAutomationNoAvailableHostError (legacy focused-owner / host-not-connected tags drop out of the union).

Viewport sizing becomes server-owned per tab (fill, freeform, presets) with preview_resize / WS previewResize, resized events, and status that reports setting plus measured CSS size. The web renderer adds PreviewAutomationHosts, a device toolbar and resize rails on HostedBrowserWebview, and content vs panel rects so zoom, cursors, and recordings track the guest viewport.

Desktop preview gains zoom sync across navigation/webview replacement, main-frame load failures that persist until a new load, CDP key sequences (PreviewKeyboard) with focus emulation and cleanup on interrupt, and typing via in-page execCommand (plus PreviewAutomationTargetNotEditableError). Dev refreshes the Mac launcher script env as fallback-only exports and retries transient t3code-dev:// renderer load failures.

Reviewed by Cursor Bugbot for commit ba9d8e5. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Route preview automation through live owner streams with connection-aware host tracking

  • Replaces the owner-based automation model with a stream-based host model: PreviewAutomationBroker now returns a Stream<PreviewAutomationStreamEvent> (with connected and request events) per connected host, and tracks connections via generated connectionId values with sticky routing, lease expiry, and capability negotiation.
  • Adds a resize automation operation end-to-end: new RPC (preview.resize), PreviewAutomationResizeInput/Result schemas, server-side PreviewManager.resize handler, MCP tool, and a device toolbar UI (BrowserDeviceToolbar) with interactive resize handles (BrowserViewportResizeHandles) in the web client.
  • Introduces focusHost (replacing reportOwner/clearOwner) for focus updates, scoped per (environmentId, clientId, connectionId) with latest-wins concurrency.
  • The desktop PreviewManager now types into pages via page runtime (avoiding Input.insertText), dispatches Chromium-correct key events with focus emulation and cleanup, and emits a typed PreviewAutomationTargetNotEditableError when a target is not editable.
  • Adds HostedBrowserWebview viewport layout, zoom-aware scaling, scroll tracking, and content geometry publishing via a new browserSurfaceStore presentContent method.
  • Risk: PreviewAutomationHostNotConnectedError and PreviewAutomationNoFocusedOwnerError are removed from the public error union; callers must handle PreviewAutomationNoAvailableHostError and PreviewAutomationTargetNotEditableError instead.

Macroscope summarized ba9d8e5.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c01c7ab5-4800-4ff2-b223-d826cd062a91

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/preview-owner-audit

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Jun 24, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Blurred owner keeps routing priority
    • Reset focusOrder to 0 on blur so a blurred owner no longer outranks other unfocused peers in the invoke routing tiebreak.
  • ✅ Fixed: Stale stream id accepted without connected
    • Changed the consumer to always drop events with a mismatched connectionId regardless of whether a connected event was previously observed, preventing stale generation events from being processed.

Create PR

Or push these changes by commenting:

@cursor push d2173a3230
Preview (d2173a3230)
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts
--- a/apps/server/src/mcp/PreviewAutomationBroker.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.ts
@@ -296,7 +296,7 @@
       clients.set(owner.clientId, {
         ...currentOwner,
         focused: owner.focused,
-        focusOrder: owner.focused ? focusSequence : currentOwner.focusOrder,
+        focusOrder: owner.focused ? focusSequence : 0,
       });
       return { ...current, clients, focusSequence };
     });

diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
--- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
+++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
@@ -39,7 +39,6 @@
     get.mount(options.requestHandlerAtom);
     let disposed = false;
     let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
-    let connectionExplicitlyAnnounced = false;
     let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
     let requestsVersion = 0;
 
@@ -48,12 +47,10 @@
       const event = result.value;
       if (event.type === "connected") {
         activeConnectionId = event.connectionId;
-        connectionExplicitlyAnnounced = true;
       } else if (activeConnectionId === null) {
         activeConnectionId = event.connectionId;
       } else if (activeConnectionId !== event.connectionId) {
-        if (connectionExplicitlyAnnounced) return;
-        activeConnectionId = event.connectionId;
+        return;
       }
       if (reportedConnectionId !== event.connectionId) {
         reportedConnectionId = event.connectionId;
@@ -98,7 +95,6 @@
     const initialRequest = get.once(options.requestsAtom);
     if (AsyncResult.isSuccess(initialRequest)) {
       activeConnectionId = initialRequest.value.connectionId;
-      connectionExplicitlyAnnounced = initialRequest.value.type === "connected";
       if (initialRequest.value.type === "connected") {
         reportedConnectionId = initialRequest.value.connectionId;
         get.set(options.connectionAtom, initialRequest.value.connectionId);

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/mcp/PreviewAutomationBroker.ts Outdated
Comment thread apps/web/src/components/preview/previewAutomationRequestConsumer.ts
@macroscopeapp

macroscopeapp Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jun 25, 2026
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx Outdated
Comment thread packages/client-runtime/src/state/preview.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 6 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: MCP resize fails after success
    • Wrapped waitForRenderedViewport in a try-catch so a committed server-side resize is never reported as a failure to the MCP caller, falling back to readRenderedViewport or the setting dimensions.
  • ✅ Fixed: Hidden fill reports wrong size
    • For fill-mode resizes on hidden tabs, viewport verification is now skipped entirely to avoid confirming placeholder dimensions (1280x800 or stale lastRect) as accurate measurements.
  • ✅ Fixed: Capability size beats focus
    • Reordered the broker host selection sort to prioritize focused status and focusOrder before supportedOperations.size, so a focused legacy host is preferred over an unfocused newer host for operations both support.

Create PR

Or push these changes by commenting:

@cursor push c2e2e2374a
Preview (c2e2e2374a)
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts
--- a/apps/server/src/mcp/PreviewAutomationBroker.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.ts
@@ -393,9 +393,9 @@
                 )
                 .sort(
                   (left, right) =>
-                    right.supportedOperations.size - left.supportedOperations.size ||
                     Number(right.focused) - Number(left.focused) ||
-                    right.focusOrder - left.focusOrder,
+                    right.focusOrder - left.focusOrder ||
+                    right.supportedOperations.size - left.supportedOperations.size,
                 )[0];
       if (!connection) {
         if (!hasLiveAssignment) assignments.delete(assignmentKey);

diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
--- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx
+++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
@@ -375,11 +375,31 @@
               throw squashAtomCommandFailure(result);
             }
             applyPreviewServerSnapshot(threadRef, result.value);
-            const viewport = await waitForRenderedViewport(
-              ready.tabId,
-              setting,
-              input.timeoutMs ?? request.timeoutMs,
-            );
+            // Best-effort viewport verification: the server-side resize has
+            // already committed so we must not fail the MCP request if layout
+            // takes longer than expected. For hidden fill-mode tabs the webview
+            // renders at placeholder dimensions that won't match the eventual
+            // panel size, so skip verification to avoid reporting those values
+            // as confirmed.
+            let viewport: PreviewRenderedViewportSize | null = null;
+            const tabVisible =
+              useBrowserSurfaceStore.getState().byTabId[ready.tabId]?.visible ?? false;
+            if (tabVisible || setting._tag !== "fill") {
+              try {
+                viewport = await waitForRenderedViewport(
+                  ready.tabId,
+                  setting,
+                  input.timeoutMs ?? request.timeoutMs,
+                );
+              } catch {
+                // Verification timed out but server state is already committed.
+              }
+            }
+            viewport ??= await readRenderedViewport(ready.tabId).catch(() => null);
+            viewport ??=
+              setting._tag !== "fill"
+                ? { width: setting.width, height: setting.height }
+                : { width: 1280, height: 800 };
             return {
               tabId: ready.tabId,
               setting,

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/browser/HostedBrowserWebview.tsx Outdated
Comment thread apps/server/src/mcp/PreviewAutomationBroker.ts
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx Outdated
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx Outdated
Comment thread apps/web/src/components/preview/PreviewView.tsx
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/browser/BrowserDeviceToolbar.tsx Outdated
Comment thread apps/web/src/browser/HostedBrowserWebview.tsx Outdated
Comment thread apps/web/src/browser/browserViewportActions.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 9 total unresolved issues (including 6 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Keyboard resize uses stale base
    • Added a ref to accumulate keyboard resize dimensions between renders and debounced the commit with a 150ms timer, so rapid key-repeat events correctly step from the latest accumulated size and only commit once when keys stop.
  • ✅ Fixed: Toolbar blur drops dimension edits
    • Removed the special-case branch that silently discarded custom input via setCustomSize(null) when focus moved to another toolbar control, so applyCustomSize() is now always called when focus leaves the dimension form.
  • ✅ Fixed: Responsive select cannot re-snap
    • Passed a computed responsiveSize prop from HostedBrowserWebview to BrowserDeviceToolbar and used it in selectViewport, removing the early return that made re-selecting Responsive a no-op when already in freeform mode.

Create PR

Or push these changes by commenting:

@cursor push c4c94e8102
Preview (c4c94e8102)
diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx
--- a/apps/web/src/browser/BrowserDeviceToolbar.tsx
+++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx
@@ -5,6 +5,7 @@
   PREVIEW_VIEWPORT_MAX_DIMENSION,
   PREVIEW_VIEWPORT_MIN_DIMENSION,
   type PreviewViewportSetting,
+  type PreviewViewportSize,
 } from "@t3tools/contracts";
 import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport";
 import { RotateCw, X } from "lucide-react";
@@ -38,10 +39,11 @@
 interface Props {
   readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
   readonly width: number;
+  readonly responsiveSize?: PreviewViewportSize | undefined;
   readonly onChange: (setting: PreviewViewportSetting) => Promise<void>;
 }
 
-export function BrowserDeviceToolbar({ setting, width, onChange }: Props) {
+export function BrowserDeviceToolbar({ setting, width, responsiveSize, onChange }: Props) {
   const [pending, setPending] = useState(false);
   const [customSize, setCustomSize] = useState<{
     readonly width: string;
@@ -85,8 +87,9 @@
   const selectViewport = (value: string | null) => {
     if (!value) return;
     if (value === RESPONSIVE_VALUE) {
-      if (setting._tag === "freeform") return;
-      apply({ _tag: "freeform", width: setting.width, height: setting.height });
+      const size = responsiveSize ?? { width: setting.width, height: setting.height };
+      if (size.width === setting.width && size.height === setting.height) return;
+      apply({ _tag: "freeform", ...size });
       return;
     }
     const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
@@ -154,13 +157,6 @@
         onBlur={(event) => {
           const nextTarget = event.relatedTarget;
           if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
-          if (
-            nextTarget instanceof HTMLElement &&
-            nextTarget.closest("[data-browser-device-toolbar]")
-          ) {
-            setCustomSize(null);
-            return;
-          }
           applyCustomSize();
         }}
       >

diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -11,6 +11,7 @@
 import { useActiveBrowserRecordingTabId } from "./browserRecording";
 import { useBrowserSurfaceStore } from "./browserSurfaceStore";
 import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
+import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout";
 import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
 import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
 import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
@@ -193,6 +194,7 @@
           <BrowserDeviceToolbar
             setting={effectiveViewport}
             width={Math.max(1, Math.round(containerSize.width))}
+            responsiveSize={resolveResponsiveBrowserViewportSize(containerSize, zoomFactor)}
             onChange={commitViewportChange}
           />
         ) : null}

diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts
--- a/apps/web/src/browser/useBrowserViewportResize.ts
+++ b/apps/web/src/browser/useBrowserViewportResize.ts
@@ -40,6 +40,8 @@
   const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible } = options;
   const dragCleanupRef = useRef<(() => void) | null>(null);
   const dragVersionRef = useRef(0);
+  const keyboardSizeRef = useRef<PreviewViewportSize | null>(null);
+  const keyboardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null);
   const sourceViewportKey = viewportSettingKey(viewport);
   const sourceViewportKeyRef = useRef(sourceViewportKey);
@@ -65,6 +67,7 @@
     () => () => {
       dragVersionRef.current += 1;
       dragCleanupRef.current?.();
+      if (keyboardTimerRef.current !== null) clearTimeout(keyboardTimerRef.current);
     },
     [],
   );
@@ -73,6 +76,11 @@
     (next: PreviewViewportSetting) => {
       dragVersionRef.current += 1;
       dragCleanupRef.current?.();
+      if (keyboardTimerRef.current !== null) {
+        clearTimeout(keyboardTimerRef.current);
+        keyboardTimerRef.current = null;
+      }
+      keyboardSizeRef.current = null;
       setDragViewport(null);
       return commitBrowserViewportChange(tabId, next);
     },
@@ -109,10 +117,17 @@
     if (!delta) return;
     event.preventDefault();
     event.stopPropagation();
-    const next = resizeFreeformViewport(effectiveViewport, delta, zoomFactor, direction);
-    if (next.width === effectiveViewport.width && next.height === effectiveViewport.height) return;
+    const base = keyboardSizeRef.current ?? effectiveViewport;
+    const next = resizeFreeformViewport(base, delta, zoomFactor, direction);
+    if (next.width === base.width && next.height === base.height) return;
+    keyboardSizeRef.current = next;
     setDragViewport({ sourceKey: sourceViewportKey, ...next, direction });
-    commitDrag({ _tag: "freeform", ...next });
+    if (keyboardTimerRef.current !== null) clearTimeout(keyboardTimerRef.current);
+    keyboardTimerRef.current = setTimeout(() => {
+      keyboardTimerRef.current = null;
+      keyboardSizeRef.current = null;
+      commitDrag({ _tag: "freeform", ...next });
+    }, 150);
   };
 
   const handleResizePointerDown = (

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/browser/useBrowserViewportResize.ts
Comment thread apps/web/src/browser/BrowserDeviceToolbar.tsx Outdated
Comment thread apps/web/src/browser/BrowserDeviceToolbar.tsx
@juliusmarminge
juliusmarminge force-pushed the t3code/preview-owner-audit branch from 4582dc6 to 06221cb Compare June 25, 2026 17:41

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Agent resize switches active tab
    • Added a preserveActiveTab option to applyPreviewServerSnapshot and used it in the automation host's resize and session-sync paths so background-tab operations no longer switch the user's visible preview tab.
  • ✅ Fixed: Toolbar toggle skips resize queue
    • Changed handleToggleDeviceToolbar to route through commitBrowserViewportChange instead of calling handleViewportChange directly, ensuring toolbar toggles are serialized with other in-flight viewport changes.

Create PR

Or push these changes by commenting:

@cursor push 33fdea1d71
Preview (33fdea1d71)
diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
--- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx
+++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
@@ -278,7 +278,7 @@
             throw squashAtomCommandFailure(result);
           }
           for (const snapshot of result.value.sessions) {
-            applyPreviewServerSnapshot(threadRef, snapshot);
+            applyPreviewServerSnapshot(threadRef, snapshot, { preserveActiveTab: true });
           }
           state = readThreadPreviewState(threadRef);
         }
@@ -374,7 +374,7 @@
             if (result._tag === "Failure") {
               throw squashAtomCommandFailure(result);
             }
-            applyPreviewServerSnapshot(threadRef, result.value);
+            applyPreviewServerSnapshot(threadRef, result.value, { preserveActiveTab: true });
             const viewport = await waitForRenderedViewport(
               ready.tabId,
               setting,

diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx
--- a/apps/web/src/components/preview/PreviewView.tsx
+++ b/apps/web/src/components/preview/PreviewView.tsx
@@ -29,7 +29,10 @@
 import { formatPreviewUrl } from "./previewUrlPresentation";
 import { PreviewEmptyState } from "./PreviewEmptyState";
 import { PreviewMoreMenu } from "./PreviewMoreMenu";
-import { subscribeBrowserViewportChange } from "~/browser/browserViewportActions";
+import {
+  commitBrowserViewportChange,
+  subscribeBrowserViewportChange,
+} from "~/browser/browserViewportActions";
 import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
 import { PreviewUnreachable } from "./PreviewUnreachable";
 import { revealInFileExplorerLabel } from "./fileExplorerLabel";
@@ -174,15 +177,18 @@
   );
 
   const handleToggleDeviceToolbar = () => {
+    if (!tabId) return;
     if (viewport._tag !== "fill") {
-      void handleViewportChange(FILL_PREVIEW_VIEWPORT).catch(() => undefined);
+      void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
       return;
     }
 
     const responsiveSize = panelRect
       ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)
       : { width: 1024, height: 768 };
-    void handleViewportChange({ _tag: "freeform", ...responsiveSize }).catch(() => undefined);
+    void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch(
+      () => undefined,
+    );
   };
 
   useEffect(() => {

diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts
--- a/apps/web/src/previewStateStore.ts
+++ b/apps/web/src/previewStateStore.ts
@@ -213,6 +213,7 @@
 export function applyPreviewServerSnapshot(
   ref: ScopedThreadRef,
   snapshot: PreviewSessionSnapshot | null,
+  options?: { preserveActiveTab?: boolean },
 ): void {
   updateThreadPreviewState(ref, (current) => {
     if (!snapshot && current.snapshot === null) return current;
@@ -233,12 +234,16 @@
       snapshot.navStatus._tag !== "Idle"
         ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
         : current.recentlySeenUrls;
+    const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
+    const keepActive = options?.preserveActiveTab && current.activeTabId !== null;
+    const activeTabId = keepActive ? current.activeTabId : snapshot.tabId;
+    const activeSnapshot = sessions[activeTabId!] ?? snapshot;
     return {
       ...current,
-      snapshot,
-      sessions: { ...current.sessions, [snapshot.tabId]: snapshot },
-      activeTabId: snapshot.tabId,
-      desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,
+      snapshot: activeSnapshot,
+      sessions,
+      activeTabId,
+      desktopOverlay: current.desktopByTabId[activeTabId!] ?? null,
       recentlySeenUrls,
     };
   });

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx Outdated
Comment thread apps/web/src/components/preview/PreviewView.tsx
Comment thread apps/web/src/browser/HostedBrowserWebview.tsx

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.

  • ✅ Fixed: Aspect lock survives fill mode
    • Added an AbortSignal parameter to BrowserViewportHandler; runHandlerWithTimeout now creates an AbortController and aborts it on timeout, and handleViewportChange checks signal.aborted before calling updatePreviewServerSnapshot, preventing stale viewport data from overwriting newer state.

Create PR

Or push these changes by commenting:

@cursor push 20d3ecce79
Preview (20d3ecce79)
diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts
--- a/apps/web/src/browser/browserViewportActions.test.ts
+++ b/apps/web/src/browser/browserViewportActions.test.ts
@@ -21,7 +21,10 @@
       height: 700,
     });
     expect(first).not.toHaveBeenCalled();
-    expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 });
+    expect(second).toHaveBeenCalledWith(
+      { _tag: "freeform", width: 900, height: 700 },
+      expect.any(AbortSignal),
+    );
 
     unsubscribeSecond();
     await expect(
@@ -75,7 +78,9 @@
     vi.useFakeTimers();
     try {
       const never = new Promise<void>(() => undefined);
-      const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise<void> => undefined);
+      const handler = vi.fn(
+        async (_setting: PreviewViewportSetting, _signal: AbortSignal): Promise<void> => undefined,
+      );
       handler.mockImplementationOnce(() => never).mockResolvedValueOnce(undefined);
       const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler);
       const first = commitBrowserViewportChange("tab-timeout", {
@@ -97,7 +102,11 @@
       await second;
 
       expect(handler).toHaveBeenCalledTimes(2);
+      expect(handler.mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal);
+      expect((handler.mock.calls[0]?.[1] as AbortSignal).aborted).toBe(true);
       expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 });
+      expect(handler.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal);
+      expect((handler.mock.calls[1]?.[1] as AbortSignal).aborted).toBe(false);
       unsubscribe();
     } finally {
       vi.useRealTimers();

diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts
--- a/apps/web/src/browser/browserViewportActions.ts
+++ b/apps/web/src/browser/browserViewportActions.ts
@@ -1,6 +1,9 @@
 import type { PreviewViewportSetting } from "@t3tools/contracts";
 
-type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise<void>;
+type BrowserViewportHandler = (
+  setting: PreviewViewportSetting,
+  signal: AbortSignal,
+) => Promise<void>;
 
 export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000;
 
@@ -20,14 +23,18 @@
   handler: BrowserViewportHandler,
   setting: PreviewViewportSetting,
 ): Promise<void> => {
+  const controller = new AbortController();
   let timeoutId: ReturnType<typeof setTimeout> | undefined;
   const timeout = new Promise<never>((_resolve, reject) => {
-    timeoutId = setTimeout(
-      () => reject(new BrowserViewportCommitTimeoutError(tabId)),
-      BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
-    );
+    timeoutId = setTimeout(() => {
+      controller.abort();
+      reject(new BrowserViewportCommitTimeoutError(tabId));
+    }, BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS);
   });
-  return Promise.race([Promise.resolve().then(() => handler(setting)), timeout]).finally(() => {
+  return Promise.race([
+    Promise.resolve().then(() => handler(setting, controller.signal)),
+    timeout,
+  ]).finally(() => {
     if (timeoutId !== undefined) clearTimeout(timeoutId);
   });
 };

diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx
--- a/apps/web/src/components/preview/PreviewView.tsx
+++ b/apps/web/src/components/preview/PreviewView.tsx
@@ -152,7 +152,7 @@
   }, [tabId]);
 
   const handleViewportChange = useCallback(
-    async (nextViewport: PreviewViewportSetting) => {
+    async (nextViewport: PreviewViewportSetting, signal: AbortSignal) => {
       if (!tabId) return;
       const result = await resize({
         environmentId: threadRef.environmentId,
@@ -162,6 +162,7 @@
           viewport: nextViewport,
         },
       });
+      if (signal.aborted) return;
       if (result._tag === "Failure") {
         const error = squashAtomCommandFailure(result);
         toastManager.add({

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/browser/BrowserDeviceToolbar.tsx
Comment thread apps/web/src/browser/browserViewportActions.ts
Comment thread apps/web/src/browser/browserViewportActions.ts Outdated
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/browser/BrowserDeviceToolbar.tsx
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/desktop/src/preview/Manager.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 5 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: NotEditable error not surfaced
    • Changed the automationType IPC handler to catch PreviewAutomationTargetNotEditableError and return structured data instead of throwing, added a web-side error class with its own responseTag to the PreviewAutomationHostError union, and updated the renderer to detect and re-throw the structured error.
  • ✅ Fixed: Zoom read blocks nav sync
    • Made the getZoomFactor read non-fatal using Effect.orElseSucceed to fall back to the current tab's zoomFactor, so a zoom read failure no longer aborts navigation status updates.
  • ✅ Fixed: Clear-only type fails incorrectly
    • Replaced the unreliable document.execCommand('delete') call with direct element.value/textContent clearing for the clear-only (empty text) case, so editable fields are properly cleared without false not-editable errors.

Create PR

Or push these changes by commenting:

@cursor push 78f235d537
Preview (78f235d537)
diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts
--- a/apps/desktop/src/ipc/methods/preview.ts
+++ b/apps/desktop/src/ipc/methods/preview.ts
@@ -277,11 +277,17 @@
 export const automationType = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL,
   payload: DesktopPreviewAutomationTypeInputSchema,
-  result: Schema.Void,
-  handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) {
-    const manager = yield* PreviewManager.PreviewManager;
-    yield* manager.automationType(tabId, input);
-  }),
+  result: Schema.NullOr(Schema.Struct({ notEditable: Schema.Literal(true) })),
+  handler: Effect.fn("desktop.ipc.preview.automationType")(
+    function* ({ tabId, input }) {
+      const manager = yield* PreviewManager.PreviewManager;
+      yield* manager.automationType(tabId, input);
+      return null;
+    },
+    Effect.catchTag("PreviewAutomationTargetNotEditableError", () =>
+      Effect.succeed({ notEditable: true as const }),
+    ),
+  ),
 });
 
 export const automationPress = DesktopIpc.makeIpcMethod({

diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -1109,7 +1109,7 @@
       const zoomFactor = yield* attempt(
         { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id },
         () => wc.getZoomFactor(),
-      );
+      ).pipe(Effect.orElseSucceed(() => undefined as number | undefined));
       const computedNavStatus = computeNavStatus(wc);
       const canGoBack = wc.navigationHistory.canGoBack();
       const canGoForward = wc.navigationHistory.canGoForward();
@@ -1129,7 +1129,7 @@
           navStatus,
           canGoBack,
           canGoForward,
-          zoomFactor,
+          zoomFactor: zoomFactor ?? current.zoomFactor,
           updatedAt,
         };
         return [
@@ -2108,9 +2108,12 @@
               }
             }
             const text = ${textJson};
-            const inserted = text.length > 0
-              ? document.execCommand("insertText", false, text)
-              : !clear || document.execCommand("delete", false);
+            let inserted = true;
+            if (text.length > 0) {
+              inserted = document.execCommand("insertText", false, text);
+            } else if (clear) {
+              if (textControl) { element.value = ""; } else { element.textContent = ""; }
+            }
             if (!inserted) return { notEditable: true };
             element.dispatchEvent(new Event("change", { bubbles: true }));
             return { ok: true };

diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
--- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx
+++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
@@ -47,6 +47,7 @@
   PreviewAutomationOperationError,
   PreviewAutomationOverlayTimeoutError,
   PreviewAutomationRecordingNotActiveError,
+  PreviewAutomationTargetNotEditableError,
   PreviewAutomationTargetUnavailableError,
 } from "./previewAutomationErrors";
 import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
@@ -429,10 +430,20 @@
           }
           case "type": {
             const ready = await requireReadyTab();
-            return await ready.bridge.automation.type(
+            const typeResult = await ready.bridge.automation.type(
               ready.tabId,
               request.input as Parameters<typeof ready.bridge.automation.type>[1],
             );
+            if (typeResult && typeof typeResult === "object" && "notEditable" in typeResult) {
+              throw new PreviewAutomationTargetNotEditableError({
+                requestId: request.requestId,
+                operation: request.operation,
+                environmentId,
+                threadId: request.threadId,
+                tabId: ready.tabId,
+              });
+            }
+            return typeResult;
           }
           case "press": {
             const ready = await requireReadyTab();

diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts
--- a/apps/web/src/components/preview/previewAutomationErrors.ts
+++ b/apps/web/src/components/preview/previewAutomationErrors.ts
@@ -122,11 +122,31 @@
   }
 }
 
+export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableError>()(
+  "PreviewAutomationTargetNotEditableError",
+  {
+    requestId: TrimmedNonEmptyString,
+    operation: PreviewAutomationOperation,
+    environmentId: EnvironmentId,
+    threadId: ThreadId,
+    tabId: Schema.NullOr(PreviewTabId),
+  },
+) {
+  get responseTag() {
+    return "PreviewAutomationTargetNotEditableError" as const;
+  }
+
+  override get message(): string {
+    return `Preview automation ${this.operation} target is not editable for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`;
+  }
+}
+
 export const PreviewAutomationHostError = Schema.Union([
   PreviewAutomationOverlayTimeoutError,
   PreviewAutomationNavigationTimeoutError,
   PreviewAutomationTargetUnavailableError,
   PreviewAutomationRecordingNotActiveError,
+  PreviewAutomationTargetNotEditableError,
   PreviewAutomationOperationError,
 ]);
 export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type;

diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -987,7 +987,10 @@
     status: (tabId: string) => Promise<PreviewAutomationStatus>;
     snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>;
     click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>;
-    type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>;
+    type: (
+      tabId: string,
+      input: PreviewAutomationTypeInput,
+    ) => Promise<void | { notEditable: true }>;
     press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>;
     scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>;
     evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>;

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/preview/Manager.ts
Comment thread apps/desktop/src/preview/Manager.ts
Comment thread apps/desktop/src/preview/Manager.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 6 total unresolved issues (including 5 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicate keyUp after interrupt
    • Added an epoch snapshot before the keyUp send and an Effect.tapError handler that detects when the epoch changed during the call (meaning the CDP command was dispatched before the post-check failed), marking keyReleased = true to prevent the cleanup finalizer from issuing a duplicate keyUp.

Create PR

Or push these changes by commenting:

@cursor push aff62c7b84
Preview (aff62c7b84)
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -2226,7 +2226,21 @@
       yield* send("Emulation.setFocusEmulationEnabled", { enabled: true });
       yield* expectAgentInput(tabId, { kind: "key", key, code: params.code });
       yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
-      yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
+      const preKeyUpEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
+      yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }).pipe(
+        Effect.tapError(() =>
+          Ref.get(controlEpochRef).pipe(
+            Effect.map((epochs) => {
+              // If the epoch changed during send, the CDP keyUp was dispatched
+              // (pre-check passed) before the post-check detected the change.
+              // Mark released to prevent a duplicate cleanup keyUp.
+              if ((epochs.get(tabId) ?? 0) !== preKeyUpEpoch) {
+                keyReleased = true;
+              }
+            }),
+          ),
+        ),
+      );
       keyReleased = true;
     }).pipe(Effect.ensuring(releaseInput));
   });

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/preview/Manager.ts
juliusmarminge and others added 8 commits June 25, 2026 14:07
- Replace static owner reporting with live connection/focus tracking
- Correlate requests and responses by connection ID for safer routing
- Update server and web preview automation tests and contracts
- Rename owner concepts to hosts across server and web
- Pin provider sessions to a host and add failover checks
- Add tests for environment scoping, background threads, and disconnects
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
- Add aspect-ratio locking and resize handles for preview device sizing
- Scale oversized fixed viewports to fit the panel
- Add timeout protection for queued viewport commits
- preserve live webview zoom and load failure state
- make background preview typing work via runtime input
- stabilize viewport readiness checks for fixed and fill modes
- add cleanup commands for key-up and focus emulation
- cover failure path in preview manager tests
- Prevent viewport commits from overtaking newer updates after timeout
- Preserve editable-target and viewport timeout errors across hosts
- Restore focus after desktop key dispatch and add coverage
Comment thread apps/web/src/components/preview/PreviewAutomationHosts.tsx
Comment thread apps/web/src/browser/browserRecordingScope.ts Outdated
@juliusmarminge
juliusmarminge force-pushed the t3code/preview-owner-audit branch from d15dfcb to 239e5e7 Compare June 25, 2026 21:18

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Load failure survives successful navigation
    • Added a URL comparison to the LoadFailed preservation guard so it only keeps the stale failure when the computed Success URL matches the failed URL (the did-stop-loading-after-did-fail-load case), allowing navigation to a different working URL to clear the failure.

Create PR

Or push these changes by commenting:

@cursor push b38197eef3
Preview (b38197eef3)
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -1142,7 +1142,9 @@
         // failed guest is no longer "loading", but it has not successfully
         // navigated anywhere. Keep the failure until a new load actually starts.
         const navStatus =
-          current.navStatus.kind === "LoadFailed" && computedNavStatus.kind === "Success"
+          current.navStatus.kind === "LoadFailed" &&
+          computedNavStatus.kind === "Success" &&
+          computedNavStatus.url === current.navStatus.url
             ? current.navStatus
             : computedNavStatus;
         const state: PreviewTabState = {

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/preview/Manager.ts
Comment thread apps/server/src/mcp/PreviewAutomationBroker.ts
Comment thread apps/desktop/src/preview/Manager.ts Outdated
Comment thread apps/web/src/components/preview/PreviewView.tsx Outdated
- Refresh launcher env only as a fallback for live dev runs
- Retry transient renderer load failures in development
- Ignore ambient `VITE_DEV_SERVER_URL` unless explicitly set
Comment thread apps/desktop/src/window/DesktopWindow.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Dev retry uses reload
    • Replaced window.webContents.reload() with loadApplication() in the retry callback so retries use loadURL(applicationUrl) targeting the correct dev URL instead of potentially reloading an error page.

Create PR

Or push these changes by commenting:

@cursor push acf5693faf
Preview (acf5693faf)
diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -266,14 +266,13 @@
         assert.equal(fakeWindow.loadURL.mock.calls.length, 1);
 
         yield* TestClock.adjust(100);
-        assert.deepEqual(fakeWindow.loadURL.mock.calls, [["t3code-dev://app/"]]);
-        assert.equal(fakeWindow.reload.mock.calls.length, 1);
+        assert.equal(fakeWindow.loadURL.mock.calls.length, 2);
+        assert.deepEqual(fakeWindow.loadURL.mock.calls[1], ["t3code-dev://app/"]);
 
         didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true);
         didFinishLoad();
         yield* TestClock.adjust(250);
-        assert.equal(fakeWindow.loadURL.mock.calls.length, 1);
-        assert.equal(fakeWindow.reload.mock.calls.length, 1);
+        assert.equal(fakeWindow.loadURL.mock.calls.length, 2);
       }).pipe(Effect.provide(layer));
     }),
   );

diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -338,9 +338,7 @@
           Effect.andThen(
             Effect.sync(() => {
               developmentLoadRetryFiber = undefined;
-              if (!window.isDestroyed()) {
-                window.webContents.reload();
-              }
+              loadApplication();
             }),
           ),
         ),

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/window/DesktopWindow.ts
Build Chromium-compatible key packets with virtual key codes, distinguish text and raw keydown events, and guarantee keyup cleanup. Add focused coverage for named, printable, shortcut, and shifted keys.

Co-authored-by: codex <codex@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 6 total unresolved issues (including 5 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Shift digit shortcuts use wrong key
    • Fixed by only applying the shift-to-symbol transformation for printable keys when Shift is the sole modifier, preventing chord shortcuts like Ctrl+Shift+1 from incorrectly emitting '!' instead of '1'.

Create PR

Or push these changes by commenting:

@cursor push c698485419
Preview (c698485419)
diff --git a/apps/desktop/src/preview/PreviewKeyboard.ts b/apps/desktop/src/preview/PreviewKeyboard.ts
--- a/apps/desktop/src/preview/PreviewKeyboard.ts
+++ b/apps/desktop/src/preview/PreviewKeyboard.ts
@@ -114,7 +114,8 @@
     (definition) => definition.key === input.key || definition.shiftedKey === input.key,
   );
   if (printable) {
-    const shifted = input.modifiers?.includes("Shift") ?? false;
+    const hasNonShiftModifier = input.modifiers?.some((m) => m !== "Shift") ?? false;
+    const shifted = !hasNonShiftModifier && (input.modifiers?.includes("Shift") ?? false);
     const key =
       printable.shiftedKey && (shifted || input.key === printable.shiftedKey)
         ? printable.shiftedKey

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit cfbdaf4. Configure here.

Comment thread apps/desktop/src/preview/PreviewKeyboard.ts
Close the remaining review gaps around renderer retries, replacement streams, hidden viewport state, webview zoom restoration, navigation failure recovery, clear-only typing, and recording stop behavior.

Co-authored-by: codex <codex@users.noreply.github.com>
Comment thread apps/web/src/browser/HostedBrowserWebview.tsx
Reconcile an active aspect-ratio lock with externally committed viewport dimensions and clear the lock when the browser returns to Fill mode.

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarminge merged commit ffae541 into main Jun 26, 2026
16 checks passed
@juliusmarminge
juliusmarminge deleted the t3code/preview-owner-audit branch June 26, 2026 02:06
aaditagrawal pushed a commit to aaditagrawal/t3code that referenced this pull request Jun 26, 2026
Co-authored-by: codex <codex@users.noreply.github.com>
aaditagrawal added a commit to aaditagrawal/t3code that referenced this pull request Jun 26, 2026
…127)

<!--
⚠️ READ BEFORE OPENING ⚠️

We are not actively accepting contributions right now.

You can still open a PR, but please do so knowing there is a high chance
we may close it without merging it, or never review it.

- Small, focused PRs are strongly preferred. Bug fixes are most likely to be merged.
- New features will most likely just annoy us.
- 1,000+ line PRs with a bunch of new features will probably get you banned from the repo.
-->

## What Changed

<!-- Describe the change clearly and keep scope tight. -->

## Why

<!-- Explain the problem being solved and why this approach is the right one. -->

## UI Changes

<!-- If this PR changes UI, include clear before/after screenshots.
     If the change involves motion or interaction, include a short video.
     Delete this section if not applicable. -->

## Checklist

- [ ] This PR is small and focused
- [ ] I explained what changed and why
- [ ] I included before/after screenshots for any UI changes
- [ ] I included a video for animation/interaction changes
chanyeinthaw pushed a commit to sats-lab/pulse that referenced this pull request Jun 28, 2026
Co-authored-by: codex <codex@users.noreply.github.com>
simonbetton pushed a commit to simonbetton/reviewer that referenced this pull request Jun 29, 2026
Co-authored-by: codex <codex@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant