Route preview automation through live owner streams#3548
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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.
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.
ApprovabilityVerdict: 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. |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
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.
4582dc6 to
06221cb
Compare
There was a problem hiding this comment.
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
preserveActiveTaboption toapplyPreviewServerSnapshotand 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.
- Added a
- ✅ Fixed: Toolbar toggle skips resize queue
- Changed
handleToggleDeviceToolbarto route throughcommitBrowserViewportChangeinstead of callinghandleViewportChangedirectly, ensuring toolbar toggles are serialized with other in-flight viewport changes.
- Changed
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.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
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.
- 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
d15dfcb to
239e5e7
Compare
There was a problem hiding this comment.
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.
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.
- 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
There was a problem hiding this comment.
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.
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.
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>
There was a problem hiding this comment.
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).
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'.
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.shiftedKeyYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit cfbdaf4. Configure here.
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>
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>
Co-authored-by: codex <codex@users.noreply.github.com>
…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
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>


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
React.useId(), which is not globally unique across Electron windows or independent mounts.reportOwnerandclearOwnercalls allowed stale mounts to erase or recreate newer ownership.ChatViewfor onlyactiveThreadRef, and the broker filtered by that thread. A capable desktop became unavailable merely because its UI was showing another conversation.How host routing works now
connectionIdlease. Stream lifetime is the source of truth for availability.threadId. The selected desktop synchronizes that thread's preview sessions and can create a hidden Electron webview even when another thread is visible.How the device-toolbar flow works now
The toolbar is derived from the tab's viewport setting, not from a separate visibility flag. Therefore an agent calling
preview_resizewith 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
resizedevent 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_resizeMCP tool accepts fill, freeform, or preset input. It waits until the renderer has applied the requested mode and the guest reports the expectedwindow.innerWidth/window.innerHeight;preview_statusreports 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
Resulting invariants
Testing
vp check— passed with 0 errors (20 existing repository-wide warnings).vp run typecheck— passed across all 15 packages.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/clearOwnerstate. Desktops connect as environment-scoped hosts over a live stream (connected+requestevents) with a server-issuedconnectionId;focusHostonly affects tie-breaking. The broker pins each provider session to one host, routesthreadIdon each request, validates responses against the pending lease, and is provisioned once for MCP and all WebSocket sessions. Public errors shift towardPreviewAutomationNoAvailableHostError(legacy focused-owner / host-not-connected tags drop out of the union).Viewport sizing becomes server-owned per tab (
fill, freeform, presets) withpreview_resize/ WSpreviewResize,resizedevents, and status that reports setting plus measured CSS size. The web renderer addsPreviewAutomationHosts, a device toolbar and resize rails onHostedBrowserWebview, 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-pageexecCommand(plusPreviewAutomationTargetNotEditableError). Dev refreshes the Mac launcher script env as fallback-only exports and retries transientt3code-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
PreviewAutomationBrokernow returns aStream<PreviewAutomationStreamEvent>(withconnectedandrequestevents) per connected host, and tracks connections via generatedconnectionIdvalues with sticky routing, lease expiry, and capability negotiation.resizeautomation operation end-to-end: new RPC (preview.resize),PreviewAutomationResizeInput/Resultschemas, server-sidePreviewManager.resizehandler, MCP tool, and a device toolbar UI (BrowserDeviceToolbar) with interactive resize handles (BrowserViewportResizeHandles) in the web client.focusHost(replacingreportOwner/clearOwner) for focus updates, scoped per(environmentId, clientId, connectionId)with latest-wins concurrency.PreviewManagernow types into pages via page runtime (avoidingInput.insertText), dispatches Chromium-correct key events with focus emulation and cleanup, and emits a typedPreviewAutomationTargetNotEditableErrorwhen a target is not editable.HostedBrowserWebviewviewport layout, zoom-aware scaling, scroll tracking, and content geometry publishing via a newbrowserSurfaceStorepresentContentmethod.PreviewAutomationHostNotConnectedErrorandPreviewAutomationNoFocusedOwnerErrorare removed from the public error union; callers must handlePreviewAutomationNoAvailableHostErrorandPreviewAutomationTargetNotEditableErrorinstead.Macroscope summarized ba9d8e5.