diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 69df02fb80d..cfe110f0e32 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -100,25 +100,41 @@ function shellSingleQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; } -function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { - const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); +export function makeDevelopmentLauncherScript({ + electronBinaryPath, + mainEntryPath, + desktopRoot, + environment, +}) { const envEntries = [ - ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], - ["T3CODE_PORT", process.env.T3CODE_PORT], - ["T3CODE_HOME", process.env.T3CODE_HOME], - ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH], - ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], - ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], + ["VITE_DEV_SERVER_URL", environment.VITE_DEV_SERVER_URL], + ["T3CODE_PORT", environment.T3CODE_PORT], + ["T3CODE_HOME", environment.T3CODE_HOME], + ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], + ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], + ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); + return [ + "#!/bin/sh", + ...envEntries.map( + ([name, value]) => + `if [ -z "\${${name}:-}" ]; then export ${name}=${shellSingleQuote(value)}; fi`, + ), + `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopRoot)} ${shellSingleQuote(mainEntryPath)} "$@"`, + "", + ].join("\n"); +} + +function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { NodeFS.writeFileSync( targetBinaryPath, - [ - "#!/bin/sh", - ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), - `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, - "", - ].join("\n"), + makeDevelopmentLauncherScript({ + electronBinaryPath, + mainEntryPath: NodePath.join(desktopDir, "dist-electron", "main.cjs"), + desktopRoot: desktopDir, + environment: process.env, + }), ); NodeFS.chmodSync(targetBinaryPath, 0o755); } @@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) { currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { + if (isDevelopment) { + // The launcher also handles protocol activations outside the dev runner, + // so refresh its fallback environment on every launch. Never let a value + // captured by an older parent app override the live dev-runner environment. + writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + } registerMacLauncherBundle(targetAppBundlePath); return targetBinaryPath; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs new file mode 100644 index 00000000000..5a1df0ec667 --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -0,0 +1,28 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { makeDevelopmentLauncherScript } from "./electron-launcher.mjs"; + +describe("electron development launcher", () => { + it("uses captured values only as fallbacks for a live runner environment", () => { + const script = makeDevelopmentLauncherScript({ + electronBinaryPath: "/repo/node_modules/electron/Electron", + mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", + desktopRoot: "/repo/apps/desktop", + environment: { + VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", + T3CODE_PORT: "16566", + T3CODE_HOME: "/tmp/t3", + }, + }); + + assert.include( + script, + "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", + ); + assert.notInclude(script, "\nexport VITE_DEV_SERVER_URL="); + assert.include( + script, + "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"", + ); + }); +}); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index acb0d783a82..ab76e4a3f24 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -18,16 +18,25 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; -const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = - vi.hoisted(() => ({ - createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), - fromId: vi.fn(() => null), - mkdir: vi.fn((_path: string) => undefined), - showItemInFolder: vi.fn(), - webviewSend: vi.fn(), - writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), - writeImage: vi.fn(), - })); +const { + createFromPath, + fromId, + getFocusedWebContents, + mkdir, + showItemInFolder, + webviewSend, + writeFile, + writeImage, +} = vi.hoisted(() => ({ + createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), + fromId: vi.fn(() => null), + getFocusedWebContents: vi.fn(() => null), + mkdir: vi.fn((_path: string) => undefined), + showItemInFolder: vi.fn(), + webviewSend: vi.fn(), + writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), + writeImage: vi.fn(), +})); vi.mock("electron", () => ({ clipboard: { @@ -44,6 +53,7 @@ vi.mock("electron", () => ({ }, webContents: { fromId, + getFocusedWebContents, }, })); @@ -97,6 +107,8 @@ const withManager = ( describe("PreviewManager", () => { beforeEach(() => { fromId.mockClear(); + getFocusedWebContents.mockReset(); + getFocusedWebContents.mockReturnValue(null); mkdir.mockClear(); writeFile.mockClear(); showItemInFolder.mockClear(); @@ -250,6 +262,197 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + withManager((manager) => + Effect.gen(function* () { + let effectiveZoom = 0.9; + let zoomReadable = true; + let url = "https://example.com"; + const listeners = new Map void>(); + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => url, + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => { + if (!zoomReadable) throw new Error("zoom unavailable"); + return effectiveZoom; + }, + setZoomFactor, + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_zoom"); + yield* manager.registerWebview("tab_zoom", 42); + + expect(states.at(-1)?.zoomFactor).toBe(0.9); + expect(setZoomFactor).not.toHaveBeenCalled(); + + effectiveZoom = 1.25; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(setZoomFactor).not.toHaveBeenCalled(); + + zoomReadable = false; + url = "https://example.com/after-zoom-read-failed"; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.navStatus).toEqual({ + kind: "Success", + url, + title: "Example", + }); + expect(states.at(-1)?.zoomFactor).toBe(1.25); + + const replacementSetZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 43, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => url, + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: replacementSetZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.registerWebview("tab_zoom", 43); + + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1.25); + }), + ), + ); + + effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => + withManager((manager) => + Effect.gen(function* () { + const url = "http://localhost:5733/"; + let loading = false; + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => url, + getTitle: () => "localhost:5733", + isLoading: () => loading, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const statuses: PreviewManager.PreviewNavStatus[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + statuses.push(state.navStatus); + }), + ); + yield* manager.createTab("tab_failed"); + yield* manager.registerWebview("tab_failed", 42); + + listeners.get("did-fail-load")?.( + {}, + -105, + "ERR_NAME_NOT_RESOLVED", + "https://missing-frame.example/", + false, + ); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Success"); + + loading = true; + listeners.get("did-start-loading")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Loading"); + + loading = false; + listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); + listeners.get("did-stop-loading")?.(); + listeners.get("page-title-updated")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)).toEqual({ + kind: "LoadFailed", + url, + title: "localhost:5733", + code: -102, + description: "ERR_CONNECTION_REFUSED", + }); + + loading = true; + listeners.get("did-start-loading")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Loading"); + + loading = false; + listeners.get("did-stop-loading")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Success"); + + listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("LoadFailed"); + + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Success"); + }), + ), + ); + effectIt.effect("captures a PNG screenshot into browser artifacts", () => withManager((manager) => Effect.gen(function* () { @@ -511,6 +714,178 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("types in background webviews and enables native key input", () => + withManager((manager) => + Effect.gen(function* () { + let failKeyDown = false; + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if ( + failKeyDown && + method === "Input.dispatchKeyEvent" && + (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown") + ) { + throw new Error("key dispatch failed"); + } + if ( + method === "Input.dispatchKeyEvent" && + (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown") + ) { + humanInput?.( + {}, + { + kind: "key", + key: params["key"], + code: params["code"] ?? "Digit1", + }, + ); + } + return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined; + }); + const restoreFocus = vi.fn(); + const focus = vi.fn(); + getFocusedWebContents.mockReturnValue({ + id: 7, + isDestroyed: () => false, + focus: restoreFocus, + } as never); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + focus, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_input"); + yield* manager.registerWebview("tab_input", 42); + yield* manager.automationType("tab_input", { text: "hello", clear: true }); + yield* manager.automationType("tab_input", { text: "", clear: true }); + yield* manager.automationPress("tab_input", { key: "x" }); + + const calls = sendCommand.mock.calls; + const methods = calls.map(([method]) => method); + const enableIndex = methods.indexOf("Input.setIgnoreInputEvents"); + const focusOnIndex = calls.findIndex( + ([method, params]) => + method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true, + ); + const keyDownIndex = calls.findIndex( + ([method, params]) => + method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown", + ); + const keyUpIndex = calls.findIndex( + ([method, params]) => method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", + ); + const focusOffIndex = calls.findIndex( + ([method, params]) => + method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false, + ); + const typeEvaluation = sendCommand.mock.calls.find( + ([method, params]) => + method === "Runtime.evaluate" && + typeof params === "object" && + params !== null && + "expression" in params && + typeof params.expression === "string" && + params.expression.includes('document.execCommand("insertText"'), + ); + expect(typeEvaluation).toBeDefined(); + const clearOnlyEvaluation = sendCommand.mock.calls.find( + ([method, params]) => + method === "Runtime.evaluate" && + typeof params === "object" && + params !== null && + "expression" in params && + typeof params.expression === "string" && + params.expression.includes('const text = ""') && + params.expression.includes("Object.getOwnPropertyDescriptor"), + ); + expect(clearOnlyEvaluation).toBeDefined(); + expect(methods).not.toContain("Input.insertText"); + expect(enableIndex).toBeGreaterThanOrEqual(0); + expect(focus).toHaveBeenCalledOnce(); + expect(restoreFocus).toHaveBeenCalledOnce(); + expect(methods).toContain("Page.bringToFront"); + expect(enableIndex).toBeLessThan(focusOnIndex); + expect(focusOnIndex).toBeLessThan(keyDownIndex); + expect(keyDownIndex).toBeLessThan(keyUpIndex); + expect(keyUpIndex).toBeLessThan(focusOffIndex); + expect( + calls.filter( + ([method, params]) => + method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", + ), + ).toHaveLength(1); + expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false }); + + sendCommand.mockClear(); + failKeyDown = true; + const failedPress = yield* Effect.exit(manager.automationPress("tab_input", { key: "y" })); + + expect(Exit.isFailure(failedPress)).toBe(true); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { + type: "keyUp", + key: "y", + code: "KeyY", + modifiers: 0, + windowsVirtualKeyCode: 89, + location: 0, + isKeypad: false, + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { + enabled: false, + }); + expect(restoreFocus).toHaveBeenCalledTimes(2); + expect( + sendCommand.mock.calls.filter( + ([method, params]) => + method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", + ), + ).toHaveLength(1); + + sendCommand.mockClear(); + failKeyDown = false; + yield* manager.automationPress("tab_input", { key: "!" }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { + type: "keyDown", + key: "!", + code: "Digit1", + modifiers: 0, + windowsVirtualKeyCode: 49, + location: 0, + isKeypad: false, + text: "!", + unmodifiedText: "!", + }); + expect(restoreFocus).toHaveBeenCalledTimes(3); + }), + ), + ); + effectIt.effect("still interrupts agent control for a different human pointer event", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6fd65cd25b5..43b2259ead0 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -62,6 +62,7 @@ import { } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; +import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function commandParams?: Record, ) => Effect.Effect; + const prepareAutomationInput = Effect.fn("PreviewManager.prepareAutomationInput")(function* ( + send: SendCommand, + enableRuntime: boolean, + ) { + yield* Effect.all( + [ + ...(enableRuntime ? [send("Runtime.enable")] : []), + send("Input.setIgnoreInputEvents", { ignore: false }), + ], + { concurrency: 2, discard: true }, + ); + }); + const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* ( tabId: string, wc: Electron.WebContents, action: string, - use: (send: SendCommand) => Effect.Effect, + use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect, ) { const sequence = yield* nextCounter(actionSequenceRef); const startedAt = yield* currentIso; @@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return result; }, ); - return yield* use(send); + // Cleanup commands must still run after human input invalidates the action's + // control epoch. Otherwise a partially dispatched input can leave Chromium + // with a held key or focus emulation enabled for subsequent actions. + const sendCleanup: SendCommand = Effect.fn("PreviewManager.sendCleanupCommand")( + function* (method, commandParams) { + return yield* attemptPromise( + { + operation: `${action}.cleanup.${method}`, + tabId, + webContentsId: wc.id, + }, + () => wc.debugger.sendCommand(method, commandParams), + ); + }, + ); + return yield* use(send, sendCleanup); }); const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( exit: Exit.Exit, @@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, ) { const scope = yield* Scope.fork(parentScope, "sequential"); - const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { + const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( + preserveLoadFailure: boolean, + ) { if (wc.isDestroyed()) return; - yield* update(tabId, { - navStatus: computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), + const zoomFactor = yield* attempt( + { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, + () => wc.getZoomFactor(), + ).pipe(Effect.option); + const computedNavStatus = computeNavStatus(wc); + const canGoBack = wc.navigationHistory.canGoBack(); + const canGoForward = wc.navigationHistory.canGoForward(); + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none(), tabs] as const; + // Electron emits did-stop-loading after did-fail-load. At that point the + // failed guest is no longer "loading", but it has not successfully + // navigated anywhere. Keep the failure until a new load actually starts. + const navStatus = + preserveLoadFailure && + current.navStatus.kind === "LoadFailed" && + computedNavStatus.kind === "Success" + ? current.navStatus + : computedNavStatus; + const state: PreviewTabState = { + ...current, + navStatus, + canGoBack, + canGoForward, + ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), + updatedAt, + }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; }); + if (Option.isSome(next)) yield* emit(tabId, next.value); }); - const sync = () => runFork(syncState()); - const failed = (_event: Event, code: number, description: string): void => { - if (code === -3) return; + const sync = () => runFork(syncState(true)); + const syncNavigation = () => runFork(syncState(false)); + const failed = ( + _event: Event, + code: number, + description: string, + validatedUrl: string, + isMainFrame: boolean, + ): void => { + if (code === -3 || !isMainFrame) return; runFork( update(tabId, { navStatus: { kind: "LoadFailed", - url: wc.getURL(), + url: validatedUrl || wc.getURL(), title: wc.getTitle(), code, description, @@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { - wc.off("did-navigate", sync); - wc.off("did-navigate-in-page", sync); + wc.off("did-navigate", syncNavigation); + wc.off("did-navigate-in-page", syncNavigation); wc.off("page-title-updated", sync); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); @@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { - wc.on("did-navigate", sync); - wc.on("did-navigate-in-page", sync); + wc.on("did-navigate", syncNavigation); + wc.on("did-navigate-in-page", syncNavigation); wc.on("page-title-updated", sync); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); @@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { + const zoomFactor = yield* attempt( + { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, + () => wc.getZoomFactor(), + ); + yield* update(tabId, { zoomFactor }); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); return; } - if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { + const replacedWebContentsId = + tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null; + if (replacedWebContentsId !== null) { yield* Effect.all( [ - detachControlSession(tab.webContentsId), - detachListeners(tab.webContentsId), + detachControlSession(replacedWebContentsId), + detachListeners(replacedWebContentsId), cancelPickElement(tabId), ], { concurrency: 3, discard: true }, ); } + const zoomFactor = + replacedWebContentsId !== null + ? yield* attempt( + { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, + () => { + wc.setZoomFactor(tab.zoomFactor); + return tab.zoomFactor; + }, + ) + : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => + wc.getZoomFactor(), + ); yield* attachListeners(tabId, wc); runFork(ensureControlSession(wc).pipe(Effect.ignore)); const registeredAt = yield* currentIso; @@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), + zoomFactor, updatedAt: registeredAt, }; return [ @@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const { state: registered, pendingUrl } = registration.value; yield* emit(tabId, registered); - if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () => - wc.setZoomFactor(registered.zoomFactor), - ).pipe(Effect.ignore); - } yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationClickInput, send: SendCommand, ) { - yield* Effect.all( - [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], - { concurrency: 2, discard: true }, - ); + yield* prepareAutomationInput(send, true); const point = yield* resolveClickPoint(tabId, send, input); const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( tabId, @@ -2011,7 +2092,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + const typeIntoAutomationTarget = Effect.fn("PreviewManager.typeIntoAutomationTarget")(function* ( tabId: string, send: SendCommand, input: PreviewAutomationTypeInput, @@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const locatorJson = locator ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) : null; + const textJson = yield* encodeJson( + { operation: "automationType.encodeText", tabId }, + input.text, + ); const result = yield* evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + | { ok: true } + | { invalidSelector: true; message: string } + | { notEditable: true } + | { notFound: true } >( tabId, send, @@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function try { const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; if (!element) return { notFound: true }; + const textControl = + element instanceof HTMLTextAreaElement || + (element instanceof HTMLInputElement && + !new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]).has(element.type)); + const editable = textControl || element.isContentEditable; + if (!editable || element.disabled || element.readOnly) return { notEditable: true }; element.focus(); - if (${input.clear ?? false}) { - if ("value" in element) element.value = ""; - else if (element.isContentEditable) element.textContent = ""; - element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); + if (document.activeElement !== element) return { notEditable: true }; + const clear = ${input.clear ?? false}; + if (clear) { + if (textControl) { + element.select(); + } else { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + const text = ${textJson}; + let inserted = true; + if (text.length > 0) { + inserted = document.execCommand("insertText", false, text); + } else if (clear) { + document.execCommand("delete", false); + const cleared = textControl + ? element.value.length === 0 + : (element.textContent ?? "").length === 0; + if (!cleared) { + if (textControl) { + const prototype = element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (valueSetter) valueSetter.call(element, ""); + else element.value = ""; + } else { + element.replaceChildren(); + } + element.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "deleteContentBackward", + })); + } } + if (!inserted) return { notEditable: true }; + element.dispatchEvent(new Event("change", { bubbles: true })); return { ok: true }; } catch (error) { return { invalidSelector: true, message: String(error) }; @@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ...automationSelectorDiagnostics(input), }); } + if ("notEditable" in result) { + return yield* new PreviewAutomationTargetNotEditableError({ + tabId, + ...automationSelectorDiagnostics(input), + }); + } }); const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( @@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationTypeInput, send: SendCommand, ) { - yield* send("Runtime.enable"); - yield* focusAutomationTarget(tabId, send, input); - yield* send("Input.insertText", { text: input.text }); - const textJson = yield* encodeJson( - { operation: "automationType.encodeText", tabId }, - input.text, - ); - yield* evaluateWithDebugger( - tabId, - send, - `(() => { - const element = document.activeElement; - element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); - element?.dispatchEvent(new Event("change", { bubbles: true })); - })()`, - false, - ); + // CDP Input.insertText silently drops text until Electron has activated a hidden + // guest WebContents with a pointer event. Editing in the page runtime keeps + // background automation deterministic without stealing foreground app focus. + yield* typeIntoAutomationTarget(tabId, send, input); }); const automationType = Effect.fn("PreviewManager.automationType")(function* ( @@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( tabId: string, + wc: Electron.WebContents, input: PreviewAutomationPressInput, send: SendCommand, + sendCleanup: SendCommand, ) { - const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { - switch (modifier) { - case "Alt": - return value | 1; - case "Control": - return value | 2; - case "Meta": - return value | 4; - case "Shift": - return value | 8; + yield* prepareAutomationInput(send, false); + const keySequence = makePreviewAutomationKeySequence(input); + const previouslyFocused = yield* attempt( + { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, + () => webContents.getFocusedWebContents(), + ); + let keyDownAttempted = false; + const releaseInput = Effect.gen(function* () { + if (keyDownAttempted) { + yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore); } - }, 0); - const key = input.key; - const text = key.length === 1 ? key : undefined; - const params = { - key, - code: key.length === 1 ? `Key${key.toUpperCase()}` : key, - modifiers, - ...(text ? { text, unmodifiedText: text } : {}), - }; - yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); - yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); - yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe( + Effect.ignore, + ); + if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) { + yield* attempt( + { + operation: "automationPress.restoreFocusedWebContents", + tabId, + webContentsId: previouslyFocused.id, + }, + () => previouslyFocused.focus(), + ).pipe(Effect.ignore); + } + }); + + // Focus the guest WebContents itself, not its containing BrowserWindow. This + // activates native keyboard behavior for hidden/background previews without + // changing which thread is mounted in the UI. Restore the previous renderer + // after dispatch so automation never leaves the app's input focus behind. + yield* Effect.gen(function* () { + yield* attempt( + { operation: "automationPress.focusWebContents", tabId, webContentsId: wc.id }, + () => wc.focus(), + ); + yield* send("Page.bringToFront"); + yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }); + yield* expectAgentInput(tabId, keySequence.signal); + keyDownAttempted = true; + yield* send("Input.dispatchKeyEvent", keySequence.keyDown); + }).pipe(Effect.ensuring(releaseInput)); }); const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( @@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationPressInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "press", (send) => - performAutomationPress(tabId, input, send), + yield* withControlSession(tabId, wc, "press", (send, sendCleanup) => + performAutomationPress(tabId, wc, input, send, sendCleanup), ); }); @@ -2529,6 +2672,20 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas } } +export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetNotEditableError", + { + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + }, +) { + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation type found ${target}, but it is not editable in tab ${this.tabId}`; + } +} + export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass()( "PreviewAutomationCoordinatesOutsideViewportError", { @@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([ PreviewAutomationDebuggerAttachedError, PreviewAutomationEvaluationError, PreviewAutomationTargetNotFoundError, + PreviewAutomationTargetNotEditableError, PreviewAutomationCoordinatesOutsideViewportError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, diff --git a/apps/desktop/src/preview/PreviewKeyboard.test.ts b/apps/desktop/src/preview/PreviewKeyboard.test.ts new file mode 100644 index 00000000000..3acdcf0d1e5 --- /dev/null +++ b/apps/desktop/src/preview/PreviewKeyboard.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; + +describe("preview keyboard packets", () => { + it("includes the Chromium virtual key code and Enter text", () => { + expect(makePreviewAutomationKeySequence({ key: "Enter" })).toEqual({ + keyDown: { + type: "keyDown", + key: "Enter", + code: "Enter", + modifiers: 0, + windowsVirtualKeyCode: 13, + location: 0, + isKeypad: false, + text: "\r", + unmodifiedText: "\r", + }, + keyUp: { + type: "keyUp", + key: "Enter", + code: "Enter", + modifiers: 0, + windowsVirtualKeyCode: 13, + location: 0, + isKeypad: false, + }, + signal: { kind: "key", key: "Enter", code: "Enter" }, + }); + }); + + it("dispatches printable keys as text key-down events", () => { + const sequence = makePreviewAutomationKeySequence({ key: "z" }); + expect(sequence.keyDown).toMatchObject({ + type: "keyDown", + key: "z", + code: "KeyZ", + windowsVirtualKeyCode: 90, + text: "z", + }); + expect(sequence.keyUp).not.toHaveProperty("text"); + }); + + it("suppresses text and uses raw key-down for shortcuts", () => { + expect(makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown).toEqual({ + type: "rawKeyDown", + key: "a", + code: "KeyA", + modifiers: 4, + windowsVirtualKeyCode: 65, + location: 0, + isKeypad: false, + }); + }); + + it("resolves shifted printable keys to their browser values", () => { + const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] }); + expect(sequence.keyDown).toMatchObject({ + key: "!", + code: "Digit1", + modifiers: 8, + windowsVirtualKeyCode: 49, + text: "!", + }); + expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" }); + }); + + it("keeps shifted key values while suppressing text for modified chords", () => { + const sequence = makePreviewAutomationKeySequence({ + key: "1", + modifiers: ["Control", "Shift"], + }); + expect(sequence.keyDown).toEqual({ + type: "rawKeyDown", + key: "!", + code: "Digit1", + modifiers: 10, + windowsVirtualKeyCode: 49, + location: 0, + isKeypad: false, + }); + expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" }); + }); +}); diff --git a/apps/desktop/src/preview/PreviewKeyboard.ts b/apps/desktop/src/preview/PreviewKeyboard.ts new file mode 100644 index 00000000000..6246cf5e558 --- /dev/null +++ b/apps/desktop/src/preview/PreviewKeyboard.ts @@ -0,0 +1,163 @@ +import type { PreviewAutomationPressInput } from "@t3tools/contracts"; + +interface KeyDefinition { + readonly code: string; + readonly key: string; + readonly keyCode: number; + readonly text?: string; + readonly location?: number; + readonly shiftedKey?: string; +} + +export interface PreviewAutomationKeyEvent { + readonly [key: string]: unknown; + readonly type: "keyDown" | "rawKeyDown" | "keyUp"; + readonly key: string; + readonly code: string; + readonly modifiers: number; + readonly windowsVirtualKeyCode: number; + readonly location: number; + readonly isKeypad: boolean; + readonly text?: string; + readonly unmodifiedText?: string; +} + +export interface PreviewAutomationKeySequence { + readonly keyDown: PreviewAutomationKeyEvent; + readonly keyUp: PreviewAutomationKeyEvent; + readonly signal: { + readonly kind: "key"; + readonly key: string; + readonly code: string; + }; +} + +const NAMED_KEYS: Readonly> = { + Escape: { code: "Escape", key: "Escape", keyCode: 27 }, + Backspace: { code: "Backspace", key: "Backspace", keyCode: 8 }, + Tab: { code: "Tab", key: "Tab", keyCode: 9 }, + Enter: { code: "Enter", key: "Enter", keyCode: 13, text: "\r" }, + Shift: { code: "ShiftLeft", key: "Shift", keyCode: 16, location: 1 }, + Control: { code: "ControlLeft", key: "Control", keyCode: 17, location: 1 }, + Alt: { code: "AltLeft", key: "Alt", keyCode: 18, location: 1 }, + Meta: { code: "MetaLeft", key: "Meta", keyCode: 91, location: 1 }, + CapsLock: { code: "CapsLock", key: "CapsLock", keyCode: 20 }, + Space: { code: "Space", key: " ", keyCode: 32, text: " " }, + PageUp: { code: "PageUp", key: "PageUp", keyCode: 33 }, + PageDown: { code: "PageDown", key: "PageDown", keyCode: 34 }, + End: { code: "End", key: "End", keyCode: 35 }, + Home: { code: "Home", key: "Home", keyCode: 36 }, + ArrowLeft: { code: "ArrowLeft", key: "ArrowLeft", keyCode: 37 }, + ArrowUp: { code: "ArrowUp", key: "ArrowUp", keyCode: 38 }, + ArrowRight: { code: "ArrowRight", key: "ArrowRight", keyCode: 39 }, + ArrowDown: { code: "ArrowDown", key: "ArrowDown", keyCode: 40 }, + Insert: { code: "Insert", key: "Insert", keyCode: 45 }, + Delete: { code: "Delete", key: "Delete", keyCode: 46 }, +}; + +const PRINTABLE_KEYS: ReadonlyArray = [ + { code: "Backquote", key: "`", shiftedKey: "~", keyCode: 192 }, + { code: "Digit1", key: "1", shiftedKey: "!", keyCode: 49 }, + { code: "Digit2", key: "2", shiftedKey: "@", keyCode: 50 }, + { code: "Digit3", key: "3", shiftedKey: "#", keyCode: 51 }, + { code: "Digit4", key: "4", shiftedKey: "$", keyCode: 52 }, + { code: "Digit5", key: "5", shiftedKey: "%", keyCode: 53 }, + { code: "Digit6", key: "6", shiftedKey: "^", keyCode: 54 }, + { code: "Digit7", key: "7", shiftedKey: "&", keyCode: 55 }, + { code: "Digit8", key: "8", shiftedKey: "*", keyCode: 56 }, + { code: "Digit9", key: "9", shiftedKey: "(", keyCode: 57 }, + { code: "Digit0", key: "0", shiftedKey: ")", keyCode: 48 }, + { code: "Minus", key: "-", shiftedKey: "_", keyCode: 189 }, + { code: "Equal", key: "=", shiftedKey: "+", keyCode: 187 }, + { code: "Backslash", key: "\\", shiftedKey: "|", keyCode: 220 }, + { code: "BracketLeft", key: "[", shiftedKey: "{", keyCode: 219 }, + { code: "BracketRight", key: "]", shiftedKey: "}", keyCode: 221 }, + { code: "Semicolon", key: ";", shiftedKey: ":", keyCode: 186 }, + { code: "Quote", key: "'", shiftedKey: '"', keyCode: 222 }, + { code: "Comma", key: ",", shiftedKey: "<", keyCode: 188 }, + { code: "Period", key: ".", shiftedKey: ">", keyCode: 190 }, + { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 }, +]; + +const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number => + (modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + +function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition { + const named = NAMED_KEYS[input.key]; + if (named) return named; + + const functionKey = /^F([1-9]|1[0-2])$/.exec(input.key); + if (functionKey) { + const number = Number(functionKey[1]); + return { code: input.key, key: input.key, keyCode: 111 + number }; + } + + if (/^[a-z]$/i.test(input.key)) { + const upper = input.key.toUpperCase(); + const shifted = input.modifiers?.includes("Shift") ?? false; + const key = shifted || input.key === upper ? upper : input.key; + return { code: `Key${upper}`, key, keyCode: upper.charCodeAt(0), text: key }; + } + + const printable = PRINTABLE_KEYS.find( + (definition) => definition.key === input.key || definition.shiftedKey === input.key, + ); + if (printable) { + const shifted = input.modifiers?.includes("Shift") ?? false; + const key = + printable.shiftedKey && (shifted || input.key === printable.shiftedKey) + ? printable.shiftedKey + : printable.key; + return { ...printable, key, text: key }; + } + + return { + code: input.key.length > 1 ? input.key : "", + key: input.key, + keyCode: 0, + ...(input.key.length === 1 ? { text: input.key } : {}), + }; +} + +/** + * Build Chromium CDP key packets using the same required fields and down-event + * choice as Playwright's pinned Chromium keyboard implementation. + */ +export function makePreviewAutomationKeySequence( + input: PreviewAutomationPressInput, +): PreviewAutomationKeySequence { + const definition = resolveKeyDefinition(input); + const modifiers = modifierMask(input.modifiers); + const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false; + const text = suppressText ? "" : (definition.text ?? ""); + const location = definition.location ?? 0; + const shared = { + key: definition.key, + code: definition.code, + modifiers, + windowsVirtualKeyCode: definition.keyCode, + location, + isKeypad: location === 3, + }; + + return { + keyDown: { + type: text ? "keyDown" : "rawKeyDown", + ...shared, + ...(text ? { text, unmodifiedText: text } : {}), + }, + keyUp: { type: "keyUp", ...shared }, + signal: { kind: "key", key: definition.key, code: definition.code }, + }; +} diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 76413dd0b55..7b9bdaf0886 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import { vi } from "vite-plus/test"; @@ -47,12 +48,14 @@ function makeFakeBrowserWindow() { const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), + getURL: vi.fn(() => "t3code-dev://app/"), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); }), once: vi.fn(), openDevTools: vi.fn(), + reload: vi.fn(), replaceMisspelling: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn(), @@ -79,6 +82,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, + reload: webContents.reload, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, }; @@ -237,6 +241,73 @@ describe("DesktopWindow", () => { }), ); + it.effect("recovers when the development renderer is temporarily unreachable", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady; + + const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); + const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); + if (!didFailLoad || !didFinishLoad) { + return yield* Effect.die("renderer load listeners were not registered"); + } + + didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + assert.equal(fakeWindow.loadURL.mock.calls.length, 1); + + yield* TestClock.adjust(100); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/"], + ["t3code-dev://app/"], + ]); + assert.equal(fakeWindow.reload.mock.calls.length, 0); + + didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFinishLoad(); + yield* TestClock.adjust(250); + assert.equal(fakeWindow.loadURL.mock.calls.length, 2); + assert.equal(fakeWindow.reload.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it("retries only transient failures for the development renderer", () => { + assert.isTrue( + DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ + applicationUrl: "t3code-dev://app/", + errorCode: -102, + isMainFrame: true, + validatedUrl: "t3code-dev://app/", + }), + ); + assert.isFalse( + DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ + applicationUrl: "t3code-dev://app/", + errorCode: -3, + isMainFrame: true, + validatedUrl: "t3code-dev://app/", + }), + ); + assert.isFalse( + DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ + applicationUrl: "t3code-dev://app/", + errorCode: -102, + isMainFrame: true, + validatedUrl: "https://example.com/", + }), + ); + }); + it.effect("opens safe off-origin renderer navigations in the system browser", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e6cfce3c54f..41bbbfd5944 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; +const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ + -2, // ERR_FAILED + -7, // ERR_TIMED_OUT + -9, // ERR_UNEXPECTED (custom protocol handler rejected) + -102, // ERR_CONNECTION_REFUSED + -105, // ERR_NAME_NOT_RESOLVED + -106, // ERR_INTERNET_DISCONNECTED + -118, // ERR_CONNECTION_TIMED_OUT +]); type WindowTitleBarOptions = Pick< Electron.BrowserWindowConstructorOptions, @@ -86,6 +97,22 @@ export function isSameOriginRendererNavigation(input: { } } +export function isRetryableDevelopmentRendererLoadFailure(input: { + readonly applicationUrl: string; + readonly errorCode: number; + readonly isMainFrame: boolean; + readonly validatedUrl: string; +}): boolean { + return ( + input.isMainFrame && + DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES.has(input.errorCode) && + isSameOriginRendererNavigation({ + applicationUrl: input.applicationUrl, + navigationUrl: input.validatedUrl, + }) + ); +} + function getWindowTitleBarOptions( shouldUseDarkColors: boolean, platform: NodeJS.Platform, @@ -152,6 +179,7 @@ export const make = Effect.gen(function* () { const previewManager = yield* PreviewManager.PreviewManager; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< @@ -277,7 +305,61 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + + let developmentLoadRetryIndex = 0; + let developmentLoadRetryFiber: Fiber.Fiber | undefined; + const clearDevelopmentLoadRetry = () => { + if (developmentLoadRetryFiber === undefined) { + return; + } + const retryFiber = developmentLoadRetryFiber; + developmentLoadRetryFiber = undefined; + runFork(Fiber.interrupt(retryFiber)); + }; + const loadApplication = () => { + if (window.isDestroyed()) { + return; + } + void window.loadURL(applicationUrl).catch(() => undefined); + }; + const scheduleDevelopmentLoadRetry = () => { + if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { + return undefined; + } + + const retryIndex = Math.min( + developmentLoadRetryIndex, + DEVELOPMENT_LOAD_RETRY_DELAYS_MS.length - 1, + ); + const retryInMs = DEVELOPMENT_LOAD_RETRY_DELAYS_MS[retryIndex] ?? 2_000; + developmentLoadRetryIndex += 1; + developmentLoadRetryFiber = runFork( + Effect.sleep(retryInMs).pipe( + Effect.andThen( + Effect.sync(() => { + developmentLoadRetryFiber = undefined; + if (!window.isDestroyed()) { + loadApplication(); + } + }), + ), + ), + ); + return retryInMs; + }; + window.webContents.on("did-finish-load", () => { + if ( + environment.isDevelopment && + !isSameOriginRendererNavigation({ + applicationUrl, + navigationUrl: window.webContents.getURL(), + }) + ) { + return; + } + clearDevelopmentLoadRetry(); + developmentLoadRetryIndex = 0; window.setTitle(environment.displayName); }); window.webContents.on( @@ -286,11 +368,22 @@ export const make = Effect.gen(function* () { if (!isMainFrame) { return; } + const retryInMs = + environment.isDevelopment && + isRetryableDevelopmentRendererLoadFailure({ + applicationUrl, + errorCode, + isMainFrame, + validatedUrl: validatedURL, + }) + ? scheduleDevelopmentLoadRetry() + : undefined; void runPromise( logWindowWarning("main window failed to load", { errorCode, errorDescription, url: validatedURL, + ...(retryInMs === undefined ? {} : { retryInMs }), }), ); }, @@ -312,14 +405,13 @@ export const make = Effect.gen(function* () { void runPromise(electronWindow.reveal(window)); }); + loadApplication(); if (environment.isDevelopment) { - void window.loadURL(applicationUrl); window.webContents.openDevTools({ mode: "detach" }); - } else { - void window.loadURL(applicationUrl); } window.on("closed", () => { + clearDevelopmentLoadRetry(); void runPromise(electronWindow.clearMain(Option.some(window))); }); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index f550396c660..14bd4c20576 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({ }); const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge(PreviewAutomationBroker.layer), + Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), ); it("normalizes empty successful notification responses to accepted", () => { @@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const requests = yield* broker.connect({ + const events = yield* broker.connect({ clientId: "mcp-failure-client", environmentId, - threadId, - tabId, - visible: true, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", }); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - requestId: request.requestId, - ok: false, - error: { - _tag: "PreviewAutomationExecutionError", - message: "sensitive renderer failure", - detail: { consoleOutput: "sensitive browser output" }, - }, - }), + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "mcp-failure-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: "sensitive renderer failure", + detail: { consoleOutput: "sensitive browser output" }, + }, + }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "mcp-failure-client", - environmentId, - threadId, - tabId, - visible: true, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", - }); const snapshot = yield* server .callTool({ name: "preview_snapshot", arguments: {} }) @@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const requests = yield* broker.connect({ + const events = yield* broker.connect({ clientId: "mcp-test-client", environmentId, - threadId, - tabId, - visible: true, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", }); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - requestId: request.requestId, - ok: true, - result: - request.operation === "snapshot" - ? { - url: "http://example.test/", - title: "Example", - loading: false, - visibleText: "Example", - interactiveElements: [], - accessibilityTree: {}, - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - screenshot: { - mimeType: "image/png", - data: Buffer.from("png").toString("base64"), - width: 10, - height: 5, - }, - } - : request.operation === "press" - ? undefined - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, - }), + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "mcp-test-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: + event.request.operation === "snapshot" + ? { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, + } + : event.request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, + }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "mcp-test-client", - environmentId, - threadId, - tabId, - visible: true, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", - }); const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index e95662a30f8..6774731a73e 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -214,7 +214,4 @@ const McpTransportLive = McpServer.layerHttp({ path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe( - Layer.provideMerge(McpTransportLive), - Layer.provide(PreviewAutomationBroker.layer), -); +export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 9f7ef2113d7..66003557303 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,20 +1,28 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, PreviewAutomationClientDisconnectedError, PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, - PreviewAutomationNoFocusedOwnerError, + PreviewAutomationNoAvailableHostError, + PreviewAutomationTargetNotEditableError, + PreviewTabId, ProviderInstanceId, ThreadId, - type PreviewAutomationOwner, + type PreviewAutomationHost, + type PreviewAutomationRequest, + type PreviewAutomationStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); + const scope = { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), @@ -22,27 +30,42 @@ const scope = { providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(["preview"] as const), issuedAt: 1, - expiresAt: 2, + expiresAt: Number.MAX_SAFE_INTEGER, }; -const makeOwner = (overrides: Partial = {}): PreviewAutomationOwner => ({ +const makeHost = (overrides: Partial = {}): PreviewAutomationHost => ({ clientId: "client-1", environmentId: scope.environmentId, - threadId: scope.threadId, - tabId: null, - visible: false, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", ...overrides, }); -it.effect("atomically registers a connected owner and correlates its response", () => +type RoutedRequest = PreviewAutomationRequest & { + readonly connectionId: PreviewAutomationStreamEvent["connectionId"]; +}; + +const requestsFrom = ( + events: Stream.Stream, + onConnected: (connectionId: PreviewAutomationStreamEvent["connectionId"]) => void = () => {}, +): Stream.Stream => + events.pipe( + Stream.filterMap((event) => { + if (event.type === "connected") { + onConnected(event.connectionId); + return Result.failVoid; + } + return Result.succeed({ ...event.request, connectionId: event.connectionId }); + }), + ); + +it.effect("atomically registers a connected host and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner()); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, requestId: request.requestId, ok: true, result: { available: true }, @@ -61,6 +84,39 @@ it.effect("atomically registers a connected owner and correlates its response", ), ); +it.effect("announces a live replacement stream before delivering requests", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const events = yield* broker.connect(makeHost()); + const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; + const consumer = yield* events.pipe( + Stream.take(2), + Stream.runForEach((event) => { + receivedTypes.push(event.type); + return event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "client-1", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: "ready", + }); + }), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + yield* Fiber.join(consumer); + + expect(receivedTypes).toEqual(["connected", "request"]); + expect(result).toBe("ready"); + }), + ), +); + it.effect("preserves bounded request and remote selector diagnostics", () => { const locator = "role=button[name='request-secret']"; const remoteMessage = "Unexpected token near remote-secret."; @@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { return Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, requestId: request.requestId, ok: false, error: remoteError, @@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { scope, operation: "click", input: { locator }, + tabId: PreviewTabId.make("tab-1"), timeoutMs: 1_234, }) .pipe(Effect.flip); @@ -121,13 +180,61 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { ); }); +it.effect("classifies a remote non-editable target without collapsing it to execution", () => { + const remoteError = { + _tag: "PreviewAutomationTargetNotEditableError", + message: "remote target details", + detail: { selectorKind: "focused-element" }, + } as const; + + return Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ + scope, + operation: "type", + input: { text: "hello" }, + tabId: PreviewTabId.make("tab-1"), + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationTargetNotEditableError); + expect(error).toMatchObject({ + operation: "type", + tabId: "tab-1", + selectorKind: "focused-element", + remoteTag: "PreviewAutomationTargetNotEditableError", + }); + expect(error.message).toBe("Preview automation type requires an editable focused element."); + }), + ); +}); + it.effect("distinguishes malformed remote failures", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner()); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => - broker.respond({ requestId: request.requestId, ok: false }), + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; @@ -150,13 +257,14 @@ it.effect("distinguishes malformed remote failures", () => ), ); -it.effect("rejects calls when no focused owner exists", () => +it.effect("rejects calls when no connected host exists", () => Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; + const broker = yield* makeBroker; const error = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, @@ -167,97 +275,484 @@ it.effect("rejects calls when no focused owner exists", () => }), ); -it.effect("routes interactive commands to a hidden durable browser host", () => +it.effect("does not create host state from focus updates without a live stream", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + yield* broker.focusHost({ + clientId: "client-1", + environmentId: scope.environmentId, + connectionId: "connection-missing", + focused: true, + }); + + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + }), +); + +it.effect("removes host availability when the authoritative request stream disconnects", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect( - makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), - ); - yield* Stream.runForEach(requests, (request) => - broker.respond({ requestId: request.requestId, ok: true }), - ).pipe(Effect.forkScoped); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + const beforeAcquisition = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + + const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); yield* Effect.yieldNow; + yield* Fiber.interrupt(consumer); - yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); }), ), ); -it.effect("lets the browser host resolve an active tab that has not been reported yet", () => +it.effect("routes requests for background threads through an environment-level host", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner({ tabId: null })); - let routedTabId: string | undefined; + const broker = yield* makeBroker; + const backgroundThreadId = ThreadId.make("thread-background"); + const requests = requestsFrom(yield* broker.connect(makeHost())); + let routedThreadId: string | undefined; yield* Stream.runForEach(requests, (request) => { - routedTabId = request.tabId; - return broker.respond({ requestId: request.requestId, ok: true }); + routedThreadId = request.threadId; + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "background", + }); }).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + const result = yield* broker.invoke({ + scope: { + ...scope, + threadId: backgroundThreadId, + providerSessionId: "provider-session-background", + }, + operation: "status", + input: {}, + }); - expect(routedTabId).toBeUndefined(); + expect(result).toBe("background"); + expect(routedThreadId).toBe(backgroundThreadId); }), ), ); -it.effect("preserves current owner metadata when its request stream reconnects", () => +it.effect("never routes a provider session to a host from another environment", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const firstRequests = yield* broker.connect(makeOwner()); - yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); - yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true })); + const broker = yield* makeBroker; + const matchingRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-matching" })), + ); + const foreignRequests = requestsFrom( + yield* broker.connect( + makeHost({ + clientId: "client-foreign", + environmentId: EnvironmentId.make("environment-foreign"), + }), + ), + ); + yield* Stream.runForEach(matchingRequests, (request) => + broker.respond({ + clientId: "client-matching", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "matching", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(foreignRequests, (request) => + broker.respond({ + clientId: "client-foreign", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "foreign", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "matching", + ); + }), + ), +); - const reconnectedRequests = yield* broker.connect(makeOwner()); +it.effect("pins a provider session to its initial host despite later focus changes", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + let secondConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-second" })), + (connectionId) => { + secondConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ + clientId: "client-first", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "first", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(secondRequests, (request) => + broker.respond({ + clientId: "client-second", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "second", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.focusHost({ + clientId: "client-first", + environmentId: scope.environmentId, + connectionId: "connection-stale", + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "second", + ); + yield* broker.focusHost({ + clientId: "client-first", + environmentId: scope.environmentId, + connectionId: firstConnectionId, + focused: true, + }); + + const firstPinnedScope = { + ...scope, + providerSessionId: "provider-session-first-pinned", + }; + expect( + yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), + ).toBe("first"); + + yield* broker.focusHost({ + clientId: "client-second", + environmentId: scope.environmentId, + connectionId: secondConnectionId, + focused: true, + }); + + expect( + yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), + ).toBe("first"); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "provider-session-second-pinned" }, + operation: "status", + input: {}, + }), + ).toBe("second"); + }), + ), +); + +it.effect("does not route new operations to legacy hosts that did not advertise support", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const legacyEvents = yield* broker.connect(makeHost()); + yield* Stream.runDrain(legacyEvents).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ scope, operation: "resize", input: { mode: "fill" } }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + expect(error).toMatchObject({ operation: "resize", environmentId: scope.environmentId }); + }), + ), +); + +it.effect("routes resize to a capable host instead of a newer legacy connection", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const capableRequests = requestsFrom( + yield* broker.connect( + makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), + ), + ); + const legacyRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-legacy" })), + ); + yield* Stream.runForEach(capableRequests, (request) => + broker.respond({ + clientId: "client-capable", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "capable", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(legacyRequests, (request) => + broker.respond({ + clientId: "client-legacy", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "legacy", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect( + yield* broker.invoke({ scope, operation: "resize", input: { mode: "fill" } }), + ).toBe("capable"); + }), + ), +); + +it.effect("does not move a live legacy assignment to another runtime for resize", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const legacyRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-legacy" })), + ); + yield* Stream.runForEach(legacyRequests, (request) => + broker.respond({ + clientId: "client-legacy", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "legacy", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "legacy", + ); + + const capableRequests = requestsFrom( + yield* broker.connect( + makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), + ), + ); + yield* Stream.runForEach(capableRequests, (request) => + broker.respond({ + clientId: "client-capable", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "capable", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ scope, operation: "resize", input: { mode: "fill" } }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "legacy", + ); + }), + ), +); + +it.effect("ignores stale focus updates for a different environment", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-second" })), + ); + yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ + clientId: "client-first", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "first", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(secondRequests, (request) => + broker.respond({ + clientId: "client-second", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "second", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.focusHost({ + clientId: "client-first", + environmentId: EnvironmentId.make("environment-stale"), + connectionId: firstConnectionId, + focused: true, + }); + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "second", + ); + }), + ), +); + +it.effect("fails over a pinned provider session only after its host disconnects", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-second" })), + ); + const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ + clientId: "client-first", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "first", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(secondRequests, (request) => + broker.respond({ + clientId: "client-second", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "second", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.focusHost({ + clientId: "client-first", + environmentId: scope.environmentId, + connectionId: firstConnectionId, + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("first"); + + yield* Fiber.interrupt(firstConsumer); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "second", + ); + }), + ), +); + +it.effect("lets the browser host resolve an active tab locally", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); let routedTabId: string | undefined; - yield* Stream.runForEach(reconnectedRequests, (request) => { + yield* Stream.runForEach(requests, (request) => { routedTabId = request.tabId; - return broker.respond({ requestId: request.requestId, ok: true }); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + }); }).pipe(Effect.forkScoped); yield* Effect.yieldNow; yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); - expect(routedTabId).toBe("tab-current"); + expect(routedTabId).toBeUndefined(); }), ), ); -it.effect("ignores stale owner cleanup after the client moves to another thread", () => +it.effect("keeps a replacement stream authoritative when the old stream finalizes", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner()); - yield* Stream.runForEach(requests, (request) => - broker.respond({ requestId: request.requestId, ok: true }), - ).pipe(Effect.forkScoped); + const broker = yield* makeBroker; + let firstConnectionId = ""; + let replacementConnectionId = ""; + const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { + firstConnectionId = connectionId; + }); + yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.clearOwner({ - clientId: "client-1", - environmentId: scope.environmentId, - threadId: ThreadId.make("thread-stale"), - }); + const replacementRequests = requestsFrom( + yield* broker.connect(makeHost()), + (connectionId) => { + replacementConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(replacementRequests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "replacement", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - yield* broker.invoke({ scope, operation: "status", input: {} }); + expect(replacementConnectionId).not.toBe(firstConnectionId); + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + expect(result).toBe("replacement"); }), ), ); -it.effect("fails requests assigned to a browser stream when that stream reconnects", () => +it.effect("fails requests assigned to the stream that is replaced", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const _requests = yield* broker.connect(makeOwner()); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runDrain(requests).pipe(Effect.forkScoped); const pending = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip, Effect.forkScoped); yield* Effect.yieldNow; - const _replacementRequests = yield* broker.connect(makeOwner()); + const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); const error = yield* Fiber.join(pending); expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); @@ -275,25 +770,40 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec ), ); -it.effect("falls back to an older connected owner when a newer report is not connected", () => +it.effect("accepts responses only from the host that received the request", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" })); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => - broker.respond({ requestId: request.requestId, ok: true, result: "connected" }), + Effect.gen(function* () { + yield* broker.respond({ + clientId: "client-foreign", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "foreign", + }); + yield* broker.respond({ + clientId: "client-1", + connectionId: "connection-stale", + requestId: request.requestId, + ok: true, + result: "stale", + }); + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "owner", + }); + }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner( - makeOwner({ - clientId: "client-report-only", - focusedAt: "2026-06-11T00:00:01.000Z", - }), - ); const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - - expect(result).toBe("connected"); + expect(result).toBe("owner"); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index a2bdb95f061..688383d7b1c 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -1,26 +1,29 @@ import { + PREVIEW_AUTOMATION_V1_OPERATIONS, PreviewAutomationClientDisconnectedError, PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, - PreviewAutomationHostNotConnectedError, PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, - PreviewAutomationNoFocusedOwnerError, + PreviewAutomationNoAvailableHostError, PreviewAutomationRemoteUnavailableError, PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, + PreviewAutomationTargetNotEditableError, PreviewAutomationTimeoutError, PreviewAutomationUnsupportedClientError, type PreviewAutomationError, type PreviewAutomationOperation, - type PreviewAutomationOwner, - type PreviewAutomationOwnerIdentity, - type PreviewAutomationRequest, + type PreviewAutomationHost, + type PreviewAutomationHostFocus, type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, type PreviewTabId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service< PreviewAutomationBroker, { readonly connect: ( - owner: PreviewAutomationOwner, - ) => Effect.Effect>; - readonly reportOwner: ( - owner: PreviewAutomationOwner, - ) => Effect.Effect; - readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect; + host: PreviewAutomationHost, + ) => Effect.Effect>; + readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect; readonly respond: ( response: PreviewAutomationResponse, ) => Effect.Effect; @@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service< interface ClientConnection { readonly clientId: string; - readonly queue: Queue.Queue; + readonly connectionId: string; + readonly environmentId: PreviewAutomationHost["environmentId"]; + readonly supportedOperations: ReadonlySet; + readonly focused: boolean; + readonly focusOrder: number; + readonly queue: Queue.Queue; } interface PendingRequest { @@ -69,6 +74,13 @@ interface PendingRequest { readonly context: PreviewAutomationRequestErrorContext; } +interface HostAssignment { + readonly clientId: ClientConnection["clientId"]; + readonly connectionId: ClientConnection["connectionId"]; + readonly queue: ClientConnection["queue"]; + readonly expiresAt: number; +} + interface PreviewAutomationRequestErrorContext { readonly operation: PreviewAutomationOperation; readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; @@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext { readonly providerSessionId: string; readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; readonly clientId: string; + readonly connectionId: ClientConnection["connectionId"]; readonly requestId: string; readonly tabId?: PreviewTabId; readonly timeoutMs: number; @@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext { interface BrokerState { readonly clients: ReadonlyMap; - readonly owners: ReadonlyMap; + readonly assignments: ReadonlyMap; readonly pending: ReadonlyMap; readonly requestSequence: number; + readonly focusSequence: number; } +const removeConnectionFromState = ( + current: BrokerState, + clientId: string, + queue: ClientConnection["queue"], +): { readonly state: BrokerState; readonly disconnected: ReadonlyArray } => { + const clients = new Map(current.clients); + const assignments = new Map(current.assignments); + const pending = new Map(current.pending); + const disconnected: PendingRequest[] = []; + if (current.clients.get(clientId)?.queue === queue) clients.delete(clientId); + for (const [assignmentKey, assignment] of assignments) { + if (assignment.queue === queue) assignments.delete(assignmentKey); + } + for (const [requestId, entry] of pending) { + if (entry.queue !== queue) continue; + pending.delete(requestId); + disconnected.push(entry); + } + return { + state: { ...current, clients, assignments, pending }, + disconnected, + }; +}; + const selectorDiagnosticsFromInput = ( input: unknown, ): Pick => { @@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = ( return {}; }; +const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => + `${scope.environmentId}\u0000${scope.providerSessionId}`; + +const supportsOperation = ( + connection: ClientConnection, + operation: PreviewAutomationOperation, +): boolean => connection.supportedOperations.has(operation); + type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; function remoteDetailKind(detail: unknown): RemoteDetailKind { @@ -131,8 +177,8 @@ const classifyResponseError = ( cause: error, }; switch (error._tag) { - case "PreviewAutomationNoFocusedOwnerError": - return new PreviewAutomationNoFocusedOwnerError({ + case "PreviewAutomationNoAvailableHostError": + return new PreviewAutomationNoAvailableHostError({ ...context, ...remoteDiagnostics, }); @@ -162,6 +208,36 @@ const classifyResponseError = ( ...remoteDiagnostics, }); } + case "PreviewAutomationTargetNotEditableError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + const remoteSelectorKind = + detail && + "selectorKind" in detail && + (detail.selectorKind === "focused-element" || + detail.selectorKind === "locator" || + detail.selectorKind === "selector") + ? detail.selectorKind + : undefined; + const remoteSelectorLength = + detail && + "selectorLength" in detail && + typeof detail.selectorLength === "number" && + Number.isInteger(detail.selectorLength) && + detail.selectorLength >= 0 + ? detail.selectorLength + : undefined; + return new PreviewAutomationTargetNotEditableError({ + ...context, + ...remoteDiagnostics, + ...(remoteSelectorKind === undefined && context.selectorKind === undefined + ? {} + : { selectorKind: remoteSelectorKind ?? context.selectorKind }), + ...(remoteSelectorLength === undefined && context.selectorLength === undefined + ? {} + : { selectorLength: remoteSelectorLength ?? context.selectorLength }), + }); + } case "PreviewAutomationResultTooLargeError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; @@ -193,36 +269,21 @@ const classifyResponseError = ( }; export const make = Effect.gen(function* PreviewAutomationBrokerMake() { + const crypto = yield* Crypto.Crypto; const state = yield* SynchronizedRef.make({ clients: new Map(), - owners: new Map(), + assignments: new Map(), pending: new Map(), requestSequence: 0, + focusSequence: 0, }); - const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( - clientId: string, + const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* ( queue: ClientConnection["queue"], + disconnected: ReadonlyArray, ) { - const toFail = yield* SynchronizedRef.modify(state, (current) => { - const clients = new Map(current.clients); - const owners = new Map(current.owners); - const pending = new Map(current.pending); - const disconnected: PendingRequest[] = []; - if (current.clients.get(clientId)?.queue === queue) { - clients.delete(clientId); - owners.delete(clientId); - } - for (const [requestId, entry] of pending) { - if (entry.queue === queue) { - pending.delete(requestId); - disconnected.push(entry); - } - } - return [disconnected, { ...current, clients, owners, pending }] as const; - }); yield* Effect.forEach( - toFail, + disconnected, ({ deferred, context }) => Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), { discard: true }, @@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { yield* Queue.shutdown(queue); }); - const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( - "PreviewAutomationBroker.connect", - )(function* (owner) { - const clientId = owner.clientId; - const queue = yield* Queue.unbounded(); - const previous = yield* SynchronizedRef.modify(state, (current) => { - const clients = new Map(current.clients); - const owners = new Map(current.owners); - const existingOwner = current.owners.get(clientId); - clients.set(clientId, { clientId, queue }); - owners.set( - clientId, - existingOwner?.environmentId === owner.environmentId && - existingOwner.threadId === owner.threadId - ? { ...existingOwner, supportsAutomation: owner.supportsAutomation } - : owner, - ); - return [current.clients.get(clientId), { ...current, clients, owners }] as const; + const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( + clientId: string, + queue: ClientConnection["queue"], + ) { + const disconnected = yield* SynchronizedRef.modify(state, (current) => { + const removed = removeConnectionFromState(current, clientId, queue); + return [removed.disconnected, removed.state] as const; }); - if (previous) yield* disconnect(clientId, previous.queue); - return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); + yield* closeConnection(queue, disconnected); }); - const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( - "PreviewAutomationBroker.reportOwner", - )(function* (owner) { - yield* SynchronizedRef.update(state, (current) => { - const owners = new Map(current.owners); - owners.set(owner.clientId, owner); - return { ...current, owners }; + const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( + host: PreviewAutomationHost, + ) { + const clientId = host.clientId; + const queue = yield* Queue.unbounded(); + const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + yield* Queue.offer(queue, { type: "connected", connectionId }); + const connection: ClientConnection = { + clientId, + connectionId, + environmentId: host.environmentId, + supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), + focused: false, + focusOrder: 0, + queue, + }; + const registration = yield* SynchronizedRef.modify(state, (current) => { + const previousConnection = current.clients.get(clientId); + const removed = previousConnection + ? removeConnectionFromState(current, clientId, previousConnection.queue) + : { state: current, disconnected: [] }; + const clients = new Map(removed.state.clients); + const focusSequence = removed.state.focusSequence + 1; + const registeredConnection = { ...connection, focusOrder: focusSequence }; + clients.set(clientId, registeredConnection); + return [ + { + previousConnection, + disconnected: removed.disconnected, + registeredConnection, + }, + { ...removed.state, clients, focusSequence }, + ] as const; }); + if (registration.previousConnection) { + yield* closeConnection(registration.previousConnection.queue, registration.disconnected); + } + return registration.registeredConnection; }); - const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( - "PreviewAutomationBroker.clearOwner", - )(function* (owner) { + const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( + "PreviewAutomationBroker.connect", + )((host) => + Effect.succeed( + Stream.unwrap( + Effect.acquireRelease(acquireConnection(host), (connection) => + disconnect(connection.clientId, connection.queue), + ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), + ), + ), + ); + + const focusHost: PreviewAutomationBroker["Service"]["focusHost"] = Effect.fn( + "PreviewAutomationBroker.focusHost", + )(function* (host) { yield* SynchronizedRef.update(state, (current) => { - const currentOwner = current.owners.get(owner.clientId); + const currentHost = current.clients.get(host.clientId); if ( - !currentOwner || - currentOwner.environmentId !== owner.environmentId || - currentOwner.threadId !== owner.threadId + !currentHost || + currentHost.environmentId !== host.environmentId || + currentHost.connectionId !== host.connectionId ) { return current; } - const owners = new Map(current.owners); - owners.delete(owner.clientId); - return { ...current, owners }; + const clients = new Map(current.clients); + const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence; + clients.set(host.clientId, { + ...currentHost, + focused: host.focused, + focusOrder: host.focused ? focusSequence : currentHost.focusOrder, + }); + return { ...current, clients, focusSequence }; }); }); @@ -286,7 +382,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { )(function* (response) { const pending = yield* SynchronizedRef.modify(state, (current) => { const entry = current.pending.get(response.requestId); - if (!entry) return [undefined, current] as const; + if ( + !entry || + entry.context.clientId !== response.clientId || + entry.context.connectionId !== response.connectionId + ) { + return [undefined, current] as const; + } const next = new Map(current.pending); next.delete(response.requestId); return [entry, { ...current, pending: next }] as const; @@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( input: Parameters[0], ): Effect.fn.Return { - const current = yield* SynchronizedRef.get(state); - const candidates = Array.from(current.owners.values()) - .filter( - (owner) => - owner.environmentId === input.scope.environmentId && - owner.threadId === input.scope.threadId && - owner.supportsAutomation, - ) - .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); - const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); - if (!owner) { - const disconnectedOwner = candidates[0]; - if (disconnectedOwner) { - return yield* new PreviewAutomationHostNotConnectedError({ - operation: input.operation, - environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, - clientId: disconnectedOwner.clientId, - }); - } - return yield* new PreviewAutomationNoFocusedOwnerError({ - operation: input.operation, - environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, - }); - } - const connection = current.clients.get(owner.clientId); - if (!connection) { - return yield* new PreviewAutomationHostNotConnectedError({ - operation: input.operation, - environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, - clientId: owner.clientId, - }); - } const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); - const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { - const requestId = `preview-${next.requestSequence}`; - const tabId = input.tabId ?? owner.tabId ?? undefined; + const now = yield* Clock.currentTimeMillis; + const route = yield* SynchronizedRef.modify(state, (current) => { + const assignments = new Map( + Array.from(current.assignments).filter(([, assignment]) => { + const connection = current.clients.get(assignment.clientId); + return ( + assignment.expiresAt > now && + connection?.connectionId === assignment.connectionId && + connection.queue === assignment.queue + ); + }), + ); + const assignmentKey = hostAssignmentKey(input.scope); + const assigned = assignments.get(assignmentKey); + const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; + const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; + // Keep one provider session on one physical desktop runtime so a + // multi-step browser interaction cannot jump between independent + // Electron cookie/DOM state. A live assignment that predates an + // operation is not silently moved to a newer client: the caller gets a + // capability failure and can deliberately start a fresh provider + // session. A dead lease is pruned above and may fail over. + const connection = + hasLiveAssignment && supportsOperation(assignedConnection, input.operation) + ? assignedConnection + : hasLiveAssignment + ? undefined + : Array.from(current.clients.values()) + .filter( + (host) => + host.environmentId === input.scope.environmentId && + supportsOperation(host, input.operation), + ) + .sort( + (left, right) => + right.supportedOperations.size - left.supportedOperations.size || + Number(right.focused) - Number(left.focused) || + right.focusOrder - left.focusOrder, + )[0]; + if (!connection) { + if (!hasLiveAssignment) assignments.delete(assignmentKey); + return [undefined, { ...current, assignments }] as const; + } + assignments.set(assignmentKey, { + clientId: connection.clientId, + connectionId: connection.connectionId, + queue: connection.queue, + expiresAt: input.scope.expiresAt, + }); + + const requestId = `preview-${current.requestSequence}`; + const tabId = input.tabId; const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); const context: PreviewAutomationRequestErrorContext = { operation: input.operation, @@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { threadId: input.scope.threadId, providerSessionId: input.scope.providerSessionId, providerInstanceId: input.scope.providerInstanceId, - clientId: owner.clientId, + clientId: connection.clientId, + connectionId: connection.connectionId, requestId, ...(tabId === undefined ? {} : { tabId }), timeoutMs, ...selectorDiagnostics, }; - const pending = new Map(next.pending); + const pending = new Map(current.pending); pending.set(requestId, { queue: connection.queue, deferred, context }); return [ - [requestId, context] as const, - { ...next, pending, requestSequence: next.requestSequence + 1 }, + { connection, requestId, requestContext: context }, + { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, ] as const; }); + if (!route) { + return yield* new PreviewAutomationNoAvailableHostError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + }); + } + const { connection, requestId, requestContext } = route; const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); @@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { const offered = yield* Queue.offer(connection.queue, { - requestId, - threadId: input.scope.threadId, - tabId: requestContext.tabId, - operation: input.operation, - input: input.input, - timeoutMs, + type: "request", + connectionId: connection.connectionId, + request: { + requestId, + threadId: input.scope.threadId, + tabId: requestContext.tabId, + operation: input.operation, + input: input.input, + timeoutMs, + }, }); if (!offered) { const completion = yield* Deferred.poll(deferred); @@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return yield* awaitResponse().pipe(Effect.ensuring(removePending)); }); - return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); + return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke }); }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); export const layer = Layer.effect(PreviewAutomationBroker, make); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 6013b1cac9e..700b81a0bfc 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -3,6 +3,7 @@ import type { PreviewAutomationOperation, PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, + PreviewAutomationResizeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -39,6 +40,8 @@ const handlers = { reuseExistingTab: input.reuseExistingTab ?? true, }), preview_navigate: (input) => invoke("navigate", input, input.timeoutMs), + preview_resize: (input) => + invoke("resize", input, input.timeoutMs), preview_snapshot: () => invoke("snapshot", {}), preview_click: (input) => invoke("click", input, input.timeoutMs).pipe(Effect.as(null)), preview_type: (input) => invoke("type", input, input.timeoutMs).pipe(Effect.as(null)), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index fd2fedbb369..d9567b01c3c 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -7,6 +7,8 @@ import { PreviewAutomationPressInput, PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, + PreviewAutomationResizeInput, + PreviewAutomationResizeResult, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -35,7 +37,7 @@ const readonlyBrowserTool = (tool: T): T => export const PreviewStatusTool = Tool.make("preview_status", { description: - "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", + "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size.", success: PreviewAutomationStatus, failure: PreviewAutomationError, dependencies, @@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool( }).annotate(Tool.Title, "Navigate browser preview"), ); +export const PreviewResizeTool = safeBrowserTool( + Tool.make("preview_resize", { + description: + "Set the active collaborative browser tab to fill-panel sizing, an independently resizable freeform size, or a Chrome-standard device preset. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", + parameters: PreviewAutomationResizeInput, + success: PreviewAutomationResizeResult, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Resize browser viewport") + .annotate(Tool.Idempotent, true), +); + export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: @@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make( PreviewStatusTool, PreviewOpenTool, PreviewNavigateTool, + PreviewResizeTool, PreviewSnapshotTool, PreviewClickTool, PreviewTypeTool, @@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make( PreviewStatusTool, PreviewOpenTool, PreviewNavigateTool, + PreviewResizeTool, PreviewClickTool, PreviewTypeTool, PreviewPressTool, diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index acdfe54301e..693111b578f 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -151,6 +151,61 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("resizes a tab and preserves its viewport across navigation reports", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + + const resized = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: { _tag: "freeform", width: 1024, height: 768 }, + }); + expect(resized.viewport).toEqual({ _tag: "freeform", width: 1024, height: 768 }); + + const navigated = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/resized", + }); + expect(navigated.viewport).toEqual(resized.viewport); + + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { _tag: "Success", url: "http://localhost:5173/resized", title: "Resized" }, + canGoBack: true, + canGoForward: false, + }); + const listed = yield* manager.list({ threadId }); + expect(listed.sessions[0]?.viewport).toEqual(resized.viewport); + + const events = yield* collector.drain; + expect(events.map((event) => event.type)).toEqual([ + "opened", + "resized", + "navigated", + "navigated", + ]); + }), + ); + + it.effect("rejects resize for an unknown tab", () => + Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + const error = yield* Effect.flip( + manager.resize({ + threadId: freshThreadId(), + tabId: "tab_missing", + viewport: { _tag: "fill" }, + }), + ); + expect(error._tag).toBe("PreviewSessionLookupError"); + }), + ); + it.effect("reportStatus emits failed for LoadFailed nav", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index fe3557c157f..193ea85b21b 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -20,6 +20,8 @@ import { type PreviewOpenInput, type PreviewRefreshInput, type PreviewReportStatusInput, + type PreviewResizeInput, + FILL_PREVIEW_VIEWPORT, PreviewSessionLookupError, type PreviewSessionSnapshot, } from "@t3tools/contracts"; @@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service< input: PreviewNavigateInput, ) => Effect.Effect; readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; + readonly resize: ( + input: PreviewResizeInput, + ) => Effect.Effect; readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; readonly close: (input: PreviewCloseInput) => Effect.Effect; readonly list: (input: PreviewListInput) => Effect.Effect; @@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: { navStatus: { _tag: "Loading", url: input.url, title: input.title }, canGoBack: false, canGoForward: false, + viewport: FILL_PREVIEW_VIEWPORT, updatedAt: input.updatedAt, }); @@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: { navStatus: { _tag: "Idle" }, canGoBack: false, canGoForward: false, + viewport: FILL_PREVIEW_VIEWPORT, updatedAt: input.updatedAt, }); @@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { navStatus: { _tag: "Success", url, title: resolvedTitle }, canGoBack: session.snapshot.canGoBack, canGoForward: session.snapshot.canGoForward, + viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, updatedAt, }; return { @@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { navStatus: input.navStatus, canGoBack: input.canGoBack, canGoForward: input.canGoForward, + viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, updatedAt, }; const emit: PreviewEvent = @@ -299,6 +308,34 @@ export const make = Effect.gen(function* PreviewManagerMake() { ); }); + const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")( + function* (input) { + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.resizeSession")(function* (session) { + const updatedAt = yield* currentIsoTimestamp; + const snapshot: PreviewSessionSnapshot = { + ...session.snapshot, + viewport: input.viewport, + updatedAt, + }; + return { + next: { ...session, snapshot }, + emit: { + type: "resized", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }, + result: snapshot, + }; + }), + ); + }, + ); + const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( function* (input) { // Verify the session exists; the desktop bridge handles the actual reload @@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { open, navigate, reportStatus, + resize, refresh, close, list, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 82d168c3c67..6944dd640a8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: { Layer.mock(PreviewManager.PreviewManager)({ open: () => Effect.die("PreviewManager not stubbed in this test"), navigate: () => Effect.die("PreviewManager not stubbed in this test"), + resize: () => Effect.die("PreviewManager not stubbed in this test"), reportStatus: () => Effect.void, refresh: () => Effect.void, close: () => Effect.void, @@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("shares one preview automation broker across websocket sessions", () => + Effect.scoped( + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const firstConnected = yield* Deferred.make(); + const firstClosed = yield* Deferred.make(); + const host = { + clientId: "shared-preview-host", + environmentId: testEnvironmentDescriptor.environmentId, + } as const; + + yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.previewAutomationConnect](host).pipe( + Stream.tap((event) => + event.type === "connected" + ? Deferred.succeed(firstConnected, event.connectionId) + : Effect.void, + ), + Stream.runDrain, + Effect.ensuring(Deferred.succeed(firstClosed, undefined)), + ), + ).pipe(Effect.forkScoped); + + const firstConnectionId = yield* Deferred.await(firstConnected); + const replacementEvent = yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.previewAutomationConnect](host).pipe(Stream.runHead), + ).pipe(Effect.map(Option.getOrThrow)); + const firstStreamClosed = yield* Deferred.await(firstClosed).pipe( + Effect.timeoutOption("2 seconds"), + ); + + assert.equal(replacementEvent.type, "connected"); + assert.notEqual(replacementEvent.connectionId, firstConnectionId); + assert.isTrue(Option.isSome(firstStreamClosed)); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 81d0013b20c..0c632d8486c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), -).pipe(Layer.provide(browserApiCorsLayer)); +).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 554a942d78a..9020e99f670 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], + [WS_METHODS.previewResize, AuthOrchestrationOperateScope], [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], [WS_METHODS.previewClose, AuthOrchestrationOperateScope], [WS_METHODS.previewList, AuthOrchestrationReadScope], [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], @@ -385,7 +385,10 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => +const makeWsRpcLayer = ( + currentSession: EnvironmentAuth.AuthenticatedSession, + previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], +) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; - const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { "rpc.aggregate": "preview", }), + [WS_METHODS.previewResize]: (input) => + observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), { + "rpc.aggregate": "preview", + }), [WS_METHODS.previewRefresh]: (input) => observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { "rpc.aggregate": "preview", @@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => previewAutomationBroker.respond(input), { "rpc.aggregate": "preview-automation" }, ), - [WS_METHODS.previewAutomationReportOwner]: (input) => - observeRpcEffect( - WS_METHODS.previewAutomationReportOwner, - previewAutomationBroker.reportOwner(input), - { "rpc.aggregate": "preview-automation" }, - ), - [WS_METHODS.previewAutomationClearOwner]: (input) => + [WS_METHODS.previewAutomationFocusHost]: (input) => observeRpcEffect( - WS_METHODS.previewAutomationClearOwner, - previewAutomationBroker.clearOwner(input), + WS_METHODS.previewAutomationFocusHost, + previewAutomationBroker.focusHost(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.subscribePreviewEvents]: (_input) => @@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.succeed( - HttpRouter.add( + Effect.gen(function* () { + const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + return HttpRouter.add( "GET", "/ws", Effect.gen(function* () { @@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session).pipe( + makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscovery.layer.pipe( @@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( EnvironmentInternalError: HttpServerRespondable.toResponse, }), ), - ), - ), + ); + }), ); diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index 9112e31cb86..d6d7434769e 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router"; import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; import { AppRoot } from "./AppRoot"; describe("AppRoot", () => { - it("shares the application atom registry with routed UI and the Electron browser host", () => { + it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { const root = AppRoot({ router: {} as AppRouter }); expect(root.type).toBe(AppAtomRegistryProvider); const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(2); + expect(children).toHaveLength(3); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); + expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 1ecb9f6b7b6..b1fd21f84fa 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,6 +1,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { return ( + ); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts new file mode 100644 index 00000000000..ee4987794c3 --- /dev/null +++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts @@ -0,0 +1,49 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + commitViewportAndAspectRatio, + reconcileLockedAspectRatio, +} from "./browserDeviceToolbarState"; + +describe("commitViewportAndAspectRatio", () => { + it("commits the aspect ratio only after the viewport succeeds", async () => { + let resolveChange: (() => void) | undefined; + const onChange = vi.fn( + () => + new Promise((resolve) => { + resolveChange = resolve; + }), + ); + const onAspectRatioChange = vi.fn(); + const setting: PreviewViewportSetting = { _tag: "freeform", width: 900, height: 600 }; + + const commit = commitViewportAndAspectRatio(setting, 1.5, onChange, onAspectRatioChange); + expect(onAspectRatioChange).not.toHaveBeenCalled(); + + resolveChange?.(); + await commit; + expect(onAspectRatioChange).toHaveBeenCalledWith(1.5); + }); + + it("keeps the previous aspect ratio when the viewport commit fails", async () => { + const onAspectRatioChange = vi.fn(); + await expect( + commitViewportAndAspectRatio( + { _tag: "fill" }, + null, + async () => Promise.reject(new Error("resize failed")), + onAspectRatioChange, + ), + ).rejects.toThrow("resize failed"); + expect(onAspectRatioChange).not.toHaveBeenCalled(); + }); +}); + +describe("reconcileLockedAspectRatio", () => { + it("tracks external viewport ratios only while the lock remains active", () => { + expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9); + expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull(); + expect(reconcileLockedAspectRatio(1.5, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx new file mode 100644 index 00000000000..f20ab0b3710 --- /dev/null +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { + PREVIEW_VIEWPORT_MAX_AREA, + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MIN_DIMENSION, + type PreviewViewportSetting, +} from "@t3tools/contracts"; +import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; +import { Link2, X } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { cn } from "~/lib/utils"; + +import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; +import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; + +const RESPONSIVE_VALUE = "responsive"; +const SELECT_ITEMS = [ + { value: RESPONSIVE_VALUE, label: "Responsive" }, + ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), +]; + +function ScreenRotationIcon() { + return ( + + ); +} + +interface Props { + readonly setting: Exclude; + readonly width: number; + readonly aspectRatio: number | null; + readonly onAspectRatioChange: (aspectRatio: number | null) => void; + readonly onChange: (setting: PreviewViewportSetting) => Promise; +} + +export function BrowserDeviceToolbar({ + setting, + width, + aspectRatio, + onAspectRatioChange, + onChange, +}: Props) { + const [pending, setPending] = useState(false); + const [customSize, setCustomSize] = useState<{ + readonly width: string; + readonly height: string; + } | null>(null); + const presentedSize = customSize ?? { + width: String(setting.width), + height: String(setting.height), + }; + const selectedValue = + setting._tag === "preset" && + PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) + ? setting.presetId + : RESPONSIVE_VALUE; + const customWidth = Number(presentedSize.width); + const customHeight = Number(presentedSize.height); + const customValid = + Number.isInteger(customWidth) && + Number.isInteger(customHeight) && + customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && + customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && + customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && + customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && + customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; + + const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { + setPending(true); + void commitViewportAndAspectRatio(next, nextAspectRatio, onChange, onAspectRatioChange).then( + () => { + setPending(false); + setCustomSize(null); + }, + () => setPending(false), + ); + }; + + const applyCustomSize = () => { + if (!customValid || (customWidth === setting.width && customHeight === setting.height)) { + setCustomSize(null); + return; + } + apply({ _tag: "freeform", width: customWidth, height: customHeight }); + }; + + const updateCustomDimension = (axis: "width" | "height", value: string) => { + setCustomSize((current) => { + const next = { + width: axis === "width" ? value : (current?.width ?? String(setting.width)), + height: axis === "height" ? value : (current?.height ?? String(setting.height)), + }; + const numeric = Number(value); + if ( + aspectRatio === null || + !Number.isInteger(numeric) || + numeric < PREVIEW_VIEWPORT_MIN_DIMENSION || + numeric > PREVIEW_VIEWPORT_MAX_DIMENSION + ) { + return next; + } + const resized = resizeFreeformViewport( + setting, + axis === "width" + ? { x: numeric - setting.width, y: 0 } + : { x: 0, y: numeric - setting.height }, + 1, + axis === "width" ? "east" : "south", + aspectRatio, + ); + return { width: String(resized.width), height: String(resized.height) }; + }); + }; + + 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 }); + return; + } + const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); + if (!preset) return; + apply( + resolvePreviewViewport({ mode: "preset", preset: preset.id }), + aspectRatio === null ? null : preset.width / preset.height, + ); + }; + + const rotate = () => { + const hasCustomSize = + customValid && (customWidth !== setting.width || customHeight !== setting.height); + const source = hasCustomSize + ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const) + : setting; + apply( + { ...source, width: source.height, height: source.width }, + aspectRatio === null ? null : 1 / aspectRatio, + ); + }; + + const toggleAspectRatio = () => { + onAspectRatioChange(aspectRatio === null ? customWidth / customHeight : null); + }; + + return ( +
{ + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + const eventTarget = event.target; + if ( + (nextTarget instanceof HTMLElement && + nextTarget.closest('[data-slot="select-positioner"]')) || + (eventTarget instanceof HTMLElement && + eventTarget.closest('[data-slot="select-positioner"]')) + ) { + return; + } + applyCustomSize(); + }} + > + {width >= 560 ? ( + + Dimensions + + ) : null} + + +
{ + event.preventDefault(); + applyCustomSize(); + }} + > + + setCustomSize( + (current) => + current ?? { + width: String(setting.width), + height: String(setting.height), + }, + ) + } + onChange={(event) => updateCustomDimension("width", event.target.value)} + aria-label="Viewport width" + aria-invalid={!customValid} + className={cn( + "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", + width >= 360 ? "w-14" : "w-11", + )} + /> + × + + setCustomSize( + (current) => + current ?? { + width: String(setting.width), + height: String(setting.height), + }, + ) + } + onChange={(event) => updateCustomDimension("height", event.target.value)} + aria-label="Viewport height" + aria-invalid={!customValid} + className={cn( + "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", + width >= 360 ? "w-14" : "w-11", + )} + /> +
+ + + + +
+ ); +} diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx new file mode 100644 index 00000000000..08e69c359a1 --- /dev/null +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type { + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from "react"; + +import { cn } from "~/lib/utils"; + +import { + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + type BrowserViewportLayout, + type BrowserViewportResizeDirection, +} from "./browserViewportLayout"; + +interface Props { + readonly layout: BrowserViewportLayout; + readonly activeDirection: BrowserViewportResizeDirection | null; + readonly onPointerDown: ( + direction: BrowserViewportResizeDirection, + event: ReactPointerEvent, + ) => void; + readonly onKeyDown: ( + direction: BrowserViewportResizeDirection, + event: ReactKeyboardEvent, + ) => void; +} + +type HandleKind = "horizontal" | "vertical" | "corner"; + +const EDGE_BUTTON_CLASS = + "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none before:absolute before:-inset-1 before:content-[''] focus-visible:bg-foreground/[0.04]"; +const EDGE_GRIP_CLASS = + "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground"; + +function ResizeHandle(props: { + readonly direction: BrowserViewportResizeDirection; + readonly label: string; + readonly kind: HandleKind; + readonly cursorClassName: string; + readonly style: CSSProperties; + readonly active: boolean; + readonly mirrorCorner?: boolean; + readonly onPointerDown: Props["onPointerDown"]; + readonly onKeyDown: Props["onKeyDown"]; +}) { + const { + direction, + label, + kind, + cursorClassName, + style, + active, + mirrorCorner = false, + onPointerDown, + onKeyDown, + } = props; + return ( + + ); +} + +export function BrowserViewportResizeHandles({ + layout, + activeDirection, + onPointerDown, + onKeyDown, +}: Props) { + const left = layout.viewportX; + const top = layout.viewportY; + const right = left + layout.viewportWidth; + const bottom = top + layout.viewportHeight; + const railSize = BROWSER_VIEWPORT_RESIZE_RAIL_SIZE; + + const shared = { activeDirection, onPointerDown, onKeyDown }; + return ( + <> + + + + + + + ); +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 205dce73583..51fa73a721f 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,6 +1,7 @@ "use client"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; @@ -22,7 +23,7 @@ export function ElectronBrowserHost() { ? Object.values(previewState.sessions).map((snapshot) => ({ threadRef, snapshot, - active: previewState.activeTabId === snapshot.tabId, + zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, })) : []; }), @@ -73,7 +74,7 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
- {sessions.map(({ threadRef, snapshot }) => { + {sessions.map(({ threadRef, snapshot, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( ); })} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index cdd33fa150d..2907a067aad 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,16 +1,22 @@ "use client"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { cn } from "~/lib/utils"; import { useActiveBrowserRecordingTabId } from "./browserRecording"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { browserViewportSettingKey } from "./browserViewportLayout"; +import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState"; +import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; +import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; +import { useBrowserViewportResize } from "./useBrowserViewportResize"; interface ElectronWebview extends HTMLElement { src: string; @@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement { preload?: string; webpreferences?: string; getWebContentsId: () => number; + executeJavaScript: (code: string, userGesture?: boolean) => Promise; } declare global { @@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: { readonly threadRef: ScopedThreadRef; readonly tabId: string; readonly initialUrl: string | null; + readonly viewport: PreviewViewportSetting; + readonly zoomFactor: number; }) { - const { threadRef, tabId, initialUrl } = props; + const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); - const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); + const wrapperRef = useRef(null); const webviewRef = useRef(null); - const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const [lockedAspectRatio, setLockedAspectRatio] = useState(null); + const presentation = useBrowserSurfaceStore( + useShallow((state) => { + const current = state.byTabId[tabId]; + return { + rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), + visible: current?.visible ?? false, + }; + }), + ); const recording = useActiveBrowserRecordingTabId() === tabId; usePreviewBridge({ threadRef, tabId }); @@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: { }; }, [config, tabId]); + const active = presentation.visible && presentation.rect !== null; + const lastRect = presentation.rect; + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const viewportWidth = viewport._tag === "fill" ? null : viewport.width; + const viewportHeight = viewport._tag === "fill" ? null : viewport.height; + const viewportAspectRatio = + viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; + useEffect(() => { + setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio)); + }, [viewportAspectRatio]); + const hiddenSize = + viewport._tag !== "fill" + ? { + width: viewport.width * normalizedZoomFactor, + height: viewport.height * normalizedZoomFactor, + } + : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; + const containerSize = active && lastRect ? lastRect : hiddenSize; + const deviceToolbarVisible = active && viewport._tag !== "fill"; + const { + activeDrag, + commitViewportChange, + effectiveViewport, + handleResizeKeyDown, + handleResizePointerDown, + layout, + } = useBrowserViewportResize({ + tabId, + viewport, + zoomFactor, + containerSize, + deviceToolbarVisible, + aspectRatio: lockedAspectRatio, + }); + + const syncContentPresentation = useCallback(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + useBrowserSurfaceStore.getState().presentContent(tabId, { + x: layout.viewportX, + y: layout.viewportY, + width: layout.viewportWidth, + height: layout.viewportHeight, + scale: layout.viewportScale, + scrollLeft: wrapper.scrollLeft, + scrollTop: wrapper.scrollTop, + }); + }, [layout, tabId]); + + useEffect(() => { + const frameId = window.requestAnimationFrame(syncContentPresentation); + return () => window.cancelAnimationFrame(frameId); + }, [syncContentPresentation]); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + wrapper.scrollTo({ left: 0, top: 0 }); + }, [tabId, viewport._tag, viewportHeight, viewportWidth]); + if (!config) return null; - const active = presentation?.visible === true && presentation.rect !== null; - const lastRect = presentation?.rect; - const style = + + const wrapperStyle = active && lastRect ? { left: lastRect.x, @@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: { : { left: 0, top: 0, - width: lastRect?.width ?? 1280, - height: lastRect?.height ?? 800, + width: hiddenSize.width, + height: hiddenSize.height, zIndex: recording ? 0 : -1, pointerEvents: "none" as const, }; return ( - +
+
+ {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( + + ) : null} + + {active && effectiveViewport._tag !== "fill" ? ( + <> + + {activeDrag ? ( + + ) : null} + + ) : null} +
+
); } diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts new file mode 100644 index 00000000000..9986ee02282 --- /dev/null +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -0,0 +1,18 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +export function reconcileLockedAspectRatio( + current: number | null, + viewportAspectRatio: number | null, +): number | null { + return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; +} + +export async function commitViewportAndAspectRatio( + setting: PreviewViewportSetting, + aspectRatio: number | null, + onChange: (setting: PreviewViewportSetting) => Promise, + onAspectRatioChange: (aspectRatio: number | null) => void, +): Promise { + await onChange(setting); + onAspectRatioChange(aspectRatio); +} diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 5bb3364807d..f4580628800 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -90,6 +90,10 @@ export function useActiveBrowserRecordingTabId(): string | null { let active: ActiveRecording | null = null; let unsubscribeFrames: (() => void) | null = null; +export function readActiveBrowserRecordingTabId(): string | null { + return active?.tabId ?? null; +} + const preferredMimeType = (): string => { const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; @@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise { activeTabId: active.tabId, }); } - const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; + const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; + const recordingSize = surface?.content ?? surface?.rect; const canvas = document.createElement("canvas"); - canvas.width = Math.max(1, rect?.width ?? 1280); - canvas.height = Math.max(1, rect?.height ?? 800); + canvas.width = Math.max(1, recordingSize?.width ?? 1280); + canvas.height = Math.max(1, recordingSize?.height ?? 800); const context = canvas.getContext("2d", { alpha: false }); if (!context) { throw new BrowserRecordingCanvasUnavailableError({ diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts new file mode 100644 index 00000000000..ae91f1a7fdc --- /dev/null +++ b/apps/web/src/browser/browserRecordingScope.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; + +describe("resolveBrowserRecordingStopTarget", () => { + it("stops the active recording even after the requested tab changes", () => { + expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); + expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); + expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); + }); +}); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts new file mode 100644 index 00000000000..807b5d0c9a6 --- /dev/null +++ b/apps/web/src/browser/browserRecordingScope.ts @@ -0,0 +1,3 @@ +export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null { + return activeTabId; +} diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts new file mode 100644 index 00000000000..1445303b8f7 --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; + +describe("browserSurfaceStore", () => { + it("tracks content dimensions for a browser that has never been visible", () => { + const tabId = "hidden-browser-surface-content-test"; + useBrowserSurfaceStore.getState().presentContent(tabId, { + x: 0, + y: 0, + width: 393, + height: 852, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + rect: null, + visible: false, + content: { width: 393, height: 852 }, + }); + }); + + it("uses the live panel rect for a hidden background tab", () => { + const staleRect = { x: 0, y: 0, width: 500, height: 700 }; + const liveRect = { x: 10, y: 20, width: 900, height: 640 }; + expect( + resolveBrowserSurfacePanelRect( + { + hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 }, + active: { rect: liveRect, visible: true, content: null, updatedAt: 2 }, + }, + "hidden", + ), + ).toEqual(liveRect); + }); +}); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 64fd8e2df2b..79095f0bdbb 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,15 +10,47 @@ export interface BrowserSurfaceRect { export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; + readonly content: BrowserSurfaceContentPresentation | null; readonly updatedAt: number; } +export interface BrowserSurfaceContentPresentation { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly scale: number; + readonly scrollLeft: number; + readonly scrollTop: number; +} + interface BrowserSurfaceStoreState { readonly byTabId: Record; readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly hide: (tabId: string) => void; } +export function resolveBrowserSurfacePanelRect( + byTabId: Readonly>, + tabId: string, +): BrowserSurfaceRect | null { + const current = byTabId[tabId]; + if (current?.visible && current.rect) return current.rect; + + let latestVisible: BrowserSurfacePresentation | undefined; + for (const presentation of Object.values(byTabId)) { + if ( + presentation.visible && + presentation.rect && + (!latestVisible || presentation.updatedAt > latestVisible.updatedAt) + ) { + latestVisible = presentation; + } + } + return latestVisible?.rect ?? current?.rect ?? null; +} + const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => left !== null && left.x === right.x && @@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { rect, visible, updatedAt: Date.now() }, + [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() }, + }, + }; + }), + presentContent: (tabId, content) => + set((state) => { + const current = state.byTabId[tabId]; + if (!current) { + return { + byTabId: { + ...state.byTabId, + [tabId]: { + rect: null, + visible: false, + content, + updatedAt: Date.now(), + }, + }, + }; + } + const previous = current.content; + if ( + previous && + previous.x === content.x && + previous.y === content.y && + previous.width === content.width && + previous.height === content.height && + previous.scale === content.scale && + previous.scrollLeft === content.scrollLeft && + previous.scrollTop === content.scrollTop + ) { + return state; + } + return { + byTabId: { + ...state.byTabId, + [tabId]: { ...current, content, updatedAt: Date.now() }, }, }; }), diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts new file mode 100644 index 00000000000..fd8f7809f30 --- /dev/null +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -0,0 +1,112 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, + commitBrowserViewportChange, + subscribeBrowserViewportChange, +} from "./browserViewportActions"; + +describe("browserViewportActions", () => { + it("routes drag commits to the visible tab handler and cleans up exactly that handler", async () => { + const first = vi.fn(async () => undefined); + const second = vi.fn(async () => undefined); + const unsubscribeFirst = subscribeBrowserViewportChange("tab-1", first); + const unsubscribeSecond = subscribeBrowserViewportChange("tab-1", second); + + unsubscribeFirst(); + await commitBrowserViewportChange("tab-1", { + _tag: "freeform", + width: 900, + height: 700, + }); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 }); + + unsubscribeSecond(); + await expect( + commitBrowserViewportChange("tab-1", { + _tag: "freeform", + width: 800, + height: 600, + }), + ).rejects.toThrow("No visible browser viewport handler"); + }); + + it("commits viewport changes in order for each tab", async () => { + let releaseFirst: (() => void) | undefined; + let markFirstStarted: (() => void) | undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const calls: Array = []; + const unsubscribe = subscribeBrowserViewportChange("tab-serial", async (setting) => { + if (setting._tag === "fill") return; + calls.push(setting.width); + if (setting.width === 800) { + markFirstStarted?.(); + await firstPending; + } + }); + + const first = commitBrowserViewportChange("tab-serial", { + _tag: "freeform", + width: 800, + height: 600, + }); + const second = commitBrowserViewportChange("tab-serial", { + _tag: "freeform", + width: 900, + height: 700, + }); + await firstStarted; + expect(calls).toEqual([800]); + + releaseFirst?.(); + await Promise.all([first, second]); + expect(calls).toEqual([800, 900]); + unsubscribe(); + }); + + it("does not let a timed-out handler overtake a newer viewport commit", async () => { + vi.useFakeTimers(); + try { + let releaseFirst: (() => void) | undefined; + const delayed = new Promise((resolve) => { + releaseFirst = resolve; + }); + const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise => undefined); + handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined); + const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler); + const first = commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 800, + height: 600, + }); + const firstResult = expect(first).rejects.toThrow( + "Timed out committing the browser viewport for tab tab-timeout", + ); + const second = commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 900, + height: 700, + }); + + await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); + await firstResult; + expect(handler).toHaveBeenCalledTimes(1); + + releaseFirst?.(); + await second; + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts new file mode 100644 index 00000000000..eea176ebbff --- /dev/null +++ b/apps/web/src/browser/browserViewportActions.ts @@ -0,0 +1,66 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; + +export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; + +export class BrowserViewportCommitTimeoutError extends Error { + override readonly name = "BrowserViewportCommitTimeoutError"; + + constructor(readonly tabId: string) { + super(`Timed out committing the browser viewport for tab ${tabId}`); + } +} + +const handlers = new Map(); +const commitTails = new Map>(); + +const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise => { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new BrowserViewportCommitTimeoutError(tabId)), + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, + ); + }); + return Promise.race([operation, timeout]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); +}; + +export function subscribeBrowserViewportChange( + tabId: string, + handler: BrowserViewportHandler, +): () => void { + handlers.set(tabId, handler); + return () => { + if (handlers.get(tabId) === handler) handlers.delete(tabId); + }; +} + +export function commitBrowserViewportChange( + tabId: string, + setting: PreviewViewportSetting, +): Promise { + const previous = commitTails.get(tabId) ?? Promise.resolve(); + const started = previous + .catch(() => undefined) + .then(() => { + const handler = handlers.get(tabId); + const operation = handler + ? Promise.resolve().then(() => handler(setting)) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + return { operation }; + }); + // The queue follows the real handler lifetime, not the caller-facing timeout. + // A slow commit therefore cannot time out, release the queue, and overwrite a + // newer viewport after that newer request has already completed. + const execution = started.then(({ operation }) => operation); + const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); + commitTails.set(tabId, execution); + const clear = () => { + if (commitTails.get(tabId) === execution) commitTails.delete(tabId); + }; + void execution.then(clear, clear); + return result; +} diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts new file mode 100644 index 00000000000..edd432a4e95 --- /dev/null +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resizeBrowserViewportFromRail, + resizeFreeformViewport, + resolveBrowserDeviceViewportLayout, + resolveBrowserViewportLayout, + resolveResponsiveBrowserViewportSize, +} from "./browserViewportLayout"; + +describe("resolveBrowserViewportLayout", () => { + it("fills the available surface in fill mode", () => { + expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({ + canvasWidth: 700, + canvasHeight: 500, + viewportX: 0, + viewportY: 0, + viewportWidth: 700, + viewportHeight: 500, + viewportScale: 1, + fillsPanel: true, + }); + }); + + it("centers a smaller fixed viewport", () => { + expect( + resolveBrowserViewportLayout( + { width: 700, height: 1000 }, + { _tag: "freeform", width: 393, height: 852 }, + ), + ).toMatchObject({ + canvasWidth: 700, + canvasHeight: 1000, + viewportX: 154, + viewportY: 74, + viewportWidth: 393, + viewportHeight: 852, + }); + }); + + it("scales a larger fixed viewport down to fit without creating overflow", () => { + const layout = resolveBrowserViewportLayout( + { width: 600, height: 700 }, + { _tag: "freeform", width: 1440, height: 900 }, + ); + expect(layout).toMatchObject({ + canvasWidth: 600, + canvasHeight: 700, + viewportX: 0, + viewportY: 163, + viewportWidth: 600, + viewportHeight: 375, + }); + expect(layout.viewportScale).toBeCloseTo(5 / 12); + }); + + it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => { + expect( + resolveBrowserViewportLayout( + { width: 800, height: 700 }, + { _tag: "freeform", width: 400, height: 300 }, + 1.5, + ), + ).toMatchObject({ + viewportX: 100, + viewportY: 125, + viewportWidth: 600, + viewportHeight: 450, + }); + expect(resizeFreeformViewport({ width: 400, height: 300 }, { x: 150, y: 75 }, 1.5)).toEqual({ + width: 500, + height: 350, + }); + }); + + it("bounds freeform drag sizes and total render area", () => { + expect(resizeFreeformViewport({ width: 1024, height: 768 }, { x: -2000, y: -2000 })).toEqual({ + width: 240, + height: 240, + }); + const large = resizeFreeformViewport({ width: 1920, height: 1080 }, { x: 2000, y: 2000 }); + expect(large.width * large.height).toBeLessThanOrEqual(3840 * 2160); + }); + + it("resizes only the axes controlled by each edge", () => { + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: 500 }, 1, "west"), + ).toEqual({ width: 900, height: 600 }); + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: 500, y: 100 }, 1, "north"), + ).toEqual({ width: 800, height: 500 }); + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: -50 }, 1, "northwest"), + ).toEqual({ width: 900, height: 650 }); + }); + + it("preserves a locked aspect ratio from either axis", () => { + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: 200, y: 0 }, 1, "east", 4 / 3), + ).toEqual({ width: 1000, height: 750 }); + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: 0, y: 150 }, 1, "south", 4 / 3), + ).toEqual({ width: 1000, height: 750 }); + }); + + it("reserves persistent device-toolbar rails around the guest viewport", () => { + expect( + resolveBrowserDeviceViewportLayout( + { width: 1200, height: 900 }, + { _tag: "freeform", width: 1180, height: 858 }, + ), + ).toEqual({ + canvasWidth: 1200, + canvasHeight: 900, + viewportX: 10, + viewportY: 32, + viewportWidth: 1180, + viewportHeight: 858, + viewportScale: 1, + fillsPanel: false, + }); + }); + + it("captures the available framed area when responsive mode is enabled", () => { + expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ + width: 1180, + height: 858, + }); + expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ + width: 590, + height: 429, + }); + }); + + it("keeps the grabbed rail under the pointer across centered layout boundaries", () => { + const available = { width: 1120, height: 818 }; + expect( + resizeBrowserViewportFromRail( + { width: 1120, height: 818 }, + { x: -100, y: -50 }, + available, + 1, + "southeast", + ), + ).toEqual({ width: 920, height: 718 }); + expect( + resizeBrowserViewportFromRail( + { width: 800, height: 600 }, + { x: 300, y: 0 }, + { width: 1200, height: 800 }, + 1, + "east", + ), + ).toEqual({ width: 1300, height: 600 }); + expect( + resizeBrowserViewportFromRail( + { width: 560, height: 409 }, + { x: -100, y: 0 }, + available, + 2, + "east", + ), + ).toEqual({ width: 460, height: 409 }); + }); +}); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts new file mode 100644 index 00000000000..ed5529fe138 --- /dev/null +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -0,0 +1,286 @@ +import { + PREVIEW_VIEWPORT_MAX_AREA, + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MIN_DIMENSION, + type PreviewViewportSetting, + type PreviewViewportSize, +} from "@t3tools/contracts"; + +export interface BrowserViewportLayout { + readonly canvasWidth: number; + readonly canvasHeight: number; + readonly viewportX: number; + readonly viewportY: number; + /** Visible footprint inside the preview panel after fit-to-panel scaling. */ + readonly viewportWidth: number; + readonly viewportHeight: number; + /** Presentation-only scale; the guest keeps its requested CSS viewport. */ + readonly viewportScale: number; + readonly fillsPanel: boolean; +} + +export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32; +export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; + +export type BrowserViewportResizeDirection = + | "north" + | "northeast" + | "east" + | "southeast" + | "south" + | "southwest" + | "west" + | "northwest"; + +export const browserViewportSettingKey = (setting: PreviewViewportSetting): string => + setting._tag === "fill" + ? "fill" + : `${setting._tag}:${setting.width}:${setting.height}:${setting._tag === "preset" ? setting.presetId : ""}`; + +const normalizeZoomFactor = (zoomFactor: number): number => + Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + +export function resolveBrowserDeviceViewportArea(container: { + readonly width: number; + readonly height: number; +}): PreviewViewportSize { + return { + width: Math.max(1, container.width - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2), + height: Math.max( + 1, + container.height - BROWSER_DEVICE_TOOLBAR_HEIGHT - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + ), + }; +} + +export function resolveBrowserViewportLayout( + container: { readonly width: number; readonly height: number }, + setting: PreviewViewportSetting, + zoomFactor = 1, +): BrowserViewportLayout { + const containerWidth = Math.max(1, Math.round(container.width)); + const containerHeight = Math.max(1, Math.round(container.height)); + if (setting._tag === "fill") { + return { + canvasWidth: containerWidth, + canvasHeight: containerHeight, + viewportX: 0, + viewportY: 0, + viewportWidth: containerWidth, + viewportHeight: containerHeight, + viewportScale: 1, + fillsPanel: true, + }; + } + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + const renderedWidth = setting.width * normalizedZoomFactor; + const renderedHeight = setting.height * normalizedZoomFactor; + const viewportScale = Math.min( + 1, + containerWidth / renderedWidth, + containerHeight / renderedHeight, + ); + const viewportWidth = renderedWidth * viewportScale; + const viewportHeight = renderedHeight * viewportScale; + return { + canvasWidth: containerWidth, + canvasHeight: containerHeight, + viewportX: Math.max(0, Math.round((containerWidth - viewportWidth) / 2)), + viewportY: Math.max(0, Math.round((containerHeight - viewportHeight) / 2)), + viewportWidth, + viewportHeight, + viewportScale, + fillsPanel: false, + }; +} + +export function resolveBrowserDeviceViewportLayout( + container: { readonly width: number; readonly height: number }, + setting: Exclude, + zoomFactor = 1, +): BrowserViewportLayout { + const layout = resolveBrowserViewportLayout( + resolveBrowserDeviceViewportArea(container), + setting, + zoomFactor, + ); + return { + ...layout, + canvasWidth: Math.max(1, Math.round(container.width)), + canvasHeight: Math.max(1, Math.round(container.height)), + viewportX: layout.viewportX + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + viewportY: layout.viewportY + BROWSER_DEVICE_TOOLBAR_HEIGHT, + }; +} + +const clampViewportDimension = (value: number): number => + Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value)); + +const validAspectRatio = (aspectRatio: number | undefined): aspectRatio is number => + aspectRatio !== undefined && Number.isFinite(aspectRatio) && aspectRatio > 0; + +function resizeAtAspectRatio( + desired: number, + aspectRatio: number, + primaryAxis: "width" | "height", +): PreviewViewportSize { + if (primaryAxis === "width") { + const minimum = Math.ceil( + Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION * aspectRatio), + ); + const maximum = Math.floor( + Math.min( + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MAX_DIMENSION * aspectRatio, + Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA * aspectRatio), + ), + ); + let width = Math.min(maximum, Math.max(minimum, Math.round(desired))); + let height = Math.round(width / aspectRatio); + while (width * height > PREVIEW_VIEWPORT_MAX_AREA && width > minimum) { + width -= 1; + height = Math.round(width / aspectRatio); + } + return { width, height }; + } + + const minimum = Math.ceil( + Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION / aspectRatio), + ); + const maximum = Math.floor( + Math.min( + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MAX_DIMENSION / aspectRatio, + Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA / aspectRatio), + ), + ); + let height = Math.min(maximum, Math.max(minimum, Math.round(desired))); + let width = Math.round(height * aspectRatio); + while (width * height > PREVIEW_VIEWPORT_MAX_AREA && height > minimum) { + height -= 1; + width = Math.round(height * aspectRatio); + } + return { width, height }; +} + +export function resizeFreeformViewport( + start: PreviewViewportSize, + delta: { readonly x: number; readonly y: number }, + zoomFactor = 1, + direction: BrowserViewportResizeDirection = "southeast", + aspectRatio?: number, +): PreviewViewportSize { + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + const horizontalDelta = direction.includes("east") + ? delta.x + : direction.includes("west") + ? -delta.x + : 0; + const verticalDelta = direction.includes("south") + ? delta.y + : direction.includes("north") + ? -delta.y + : 0; + const desiredWidth = start.width + horizontalDelta / normalizedZoomFactor; + const desiredHeight = start.height + verticalDelta / normalizedZoomFactor; + if (validAspectRatio(aspectRatio)) { + const controlsWidth = horizontalDelta !== 0 || direction === "east" || direction === "west"; + const controlsHeight = verticalDelta !== 0 || direction === "north" || direction === "south"; + const primaryAxis = + controlsWidth && !controlsHeight + ? "width" + : controlsHeight && !controlsWidth + ? "height" + : Math.abs(desiredWidth - start.width) / start.width >= + Math.abs(desiredHeight - start.height) / start.height + ? "width" + : "height"; + return resizeAtAspectRatio( + primaryAxis === "width" ? desiredWidth : desiredHeight, + aspectRatio, + primaryAxis, + ); + } + let width = clampViewportDimension(Math.round(desiredWidth)); + let height = clampViewportDimension(Math.round(desiredHeight)); + if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height }; + if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) { + width = Math.max( + PREVIEW_VIEWPORT_MIN_DIMENSION, + Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height), + ); + } else { + height = Math.max( + PREVIEW_VIEWPORT_MIN_DIMENSION, + Math.floor(PREVIEW_VIEWPORT_MAX_AREA / width), + ); + } + return { width, height }; +} + +const resizeFromEndRail = (start: number, pointerDelta: number, available: number): number => { + const startEdge = start < available ? (available + start) / 2 : start; + const targetEdge = startEdge + pointerDelta; + return targetEdge <= available ? targetEdge * 2 - available : targetEdge; +}; + +const resizeFromStartRail = (start: number, pointerDelta: number, available: number): number => { + if (start > available) { + const distanceToFit = start - available; + return pointerDelta <= distanceToFit + ? start - pointerDelta + : available - (pointerDelta - distanceToFit) * 2; + } + const targetEdge = (available - start) / 2 + pointerDelta; + return targetEdge >= 0 ? available - targetEdge * 2 : available - targetEdge; +}; + +export function resizeBrowserViewportFromRail( + start: PreviewViewportSize, + pointerDelta: { readonly x: number; readonly y: number }, + available: PreviewViewportSize, + zoomFactor = 1, + direction: BrowserViewportResizeDirection = "southeast", + aspectRatio?: number, +): PreviewViewportSize { + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + const startWidth = start.width * normalizedZoomFactor; + const startHeight = start.height * normalizedZoomFactor; + const desiredWidth = direction.includes("east") + ? resizeFromEndRail(startWidth, pointerDelta.x, available.width) + : direction.includes("west") + ? resizeFromStartRail(startWidth, pointerDelta.x, available.width) + : startWidth; + const desiredHeight = direction.includes("south") + ? resizeFromEndRail(startHeight, pointerDelta.y, available.height) + : direction.includes("north") + ? resizeFromStartRail(startHeight, pointerDelta.y, available.height) + : startHeight; + const widthDelta = desiredWidth - startWidth; + const heightDelta = desiredHeight - startHeight; + return resizeFreeformViewport( + start, + { + x: direction.includes("west") ? -widthDelta : widthDelta, + y: direction.includes("north") ? -heightDelta : heightDelta, + }, + normalizedZoomFactor, + direction, + aspectRatio, + ); +} + +export function resolveResponsiveBrowserViewportSize( + container: { readonly width: number; readonly height: number }, + zoomFactor = 1, +): PreviewViewportSize { + const area = resolveBrowserDeviceViewportArea(container); + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + return resizeFreeformViewport( + { + width: area.width / normalizedZoomFactor, + height: area.height / normalizedZoomFactor, + }, + { x: 0, y: 0 }, + ); +} diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts new file mode 100644 index 00000000000..4f05f6dd27d --- /dev/null +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -0,0 +1,260 @@ +"use client"; + +import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts"; +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; + +import { commitBrowserViewportChange } from "./browserViewportActions"; +import { + browserViewportSettingKey, + resizeBrowserViewportFromRail, + resizeFreeformViewport, + resolveBrowserDeviceViewportArea, + resolveBrowserDeviceViewportLayout, + resolveBrowserViewportLayout, + type BrowserViewportResizeDirection, +} from "./browserViewportLayout"; + +interface ViewportDrag extends PreviewViewportSize { + readonly sourceKey: string; + readonly direction: BrowserViewportResizeDirection; +} + +const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150; + +export function useBrowserViewportResize(options: { + readonly tabId: string; + readonly viewport: PreviewViewportSetting; + readonly zoomFactor: number; + readonly containerSize: PreviewViewportSize; + readonly deviceToolbarVisible: boolean; + readonly aspectRatio: number | null; +}) { + const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = options; + const dragCleanupRef = useRef<(() => void) | null>(null); + const dragVersionRef = useRef(0); + const keyboardCommitTimerRef = useRef | null>(null); + const keyboardViewportRef = useRef(null); + const [dragViewport, setDragViewport] = useState(null); + const sourceViewportKey = browserViewportSettingKey(viewport); + const sourceViewportKeyRef = useRef(sourceViewportKey); + sourceViewportKeyRef.current = sourceViewportKey; + const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; + const effectiveViewport = activeDrag + ? ({ + _tag: "freeform", + width: activeDrag.width, + height: activeDrag.height, + } as const satisfies PreviewViewportSetting) + : viewport; + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const viewportContainerSize = deviceToolbarVisible + ? resolveBrowserDeviceViewportArea(containerSize) + : containerSize; + const layout = + deviceToolbarVisible && effectiveViewport._tag !== "fill" + ? resolveBrowserDeviceViewportLayout(containerSize, effectiveViewport, zoomFactor) + : resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor); + + useEffect( + () => () => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + } + keyboardCommitTimerRef.current = null; + keyboardViewportRef.current = null; + }, + [], + ); + + useEffect(() => { + const pending = keyboardViewportRef.current; + if (!pending || pending.sourceKey === sourceViewportKey) return; + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + keyboardCommitTimerRef.current = null; + } + keyboardViewportRef.current = null; + }, [sourceViewportKey]); + + const commitViewportChange = useCallback( + (next: PreviewViewportSetting) => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + keyboardCommitTimerRef.current = null; + } + keyboardViewportRef.current = null; + setDragViewport(null); + return commitBrowserViewportChange(tabId, next); + }, + [tabId], + ); + + const clearDrag = () => setDragViewport(null); + const commitDrag = (next: PreviewViewportSetting) => { + const version = ++dragVersionRef.current; + const clearIfCurrent = () => { + if (dragVersionRef.current === version) clearDrag(); + }; + void commitBrowserViewportChange(tabId, next).then(clearIfCurrent, clearIfCurrent); + }; + + const handleResizeKeyDown = ( + direction: BrowserViewportResizeDirection, + event: ReactKeyboardEvent, + ) => { + if (effectiveViewport._tag === "fill") return; + const controlsWidth = direction.includes("east") || direction.includes("west"); + const controlsHeight = direction.includes("north") || direction.includes("south"); + const step = (event.shiftKey ? 50 : 10) * normalizedZoomFactor; + const delta = + event.key === "ArrowLeft" && controlsWidth + ? { x: -step, y: 0 } + : event.key === "ArrowRight" && controlsWidth + ? { x: step, y: 0 } + : event.key === "ArrowUp" && controlsHeight + ? { x: 0, y: -step } + : event.key === "ArrowDown" && controlsHeight + ? { x: 0, y: step } + : null; + if (!delta) return; + event.preventDefault(); + event.stopPropagation(); + const pending = keyboardViewportRef.current; + const base = pending?.sourceKey === sourceViewportKey ? pending : effectiveViewport; + const next = resizeFreeformViewport( + base, + delta, + zoomFactor, + direction, + aspectRatio ?? undefined, + ); + if (next.width === base.width && next.height === base.height) return; + const keyboardViewport = { sourceKey: sourceViewportKey, ...next, direction }; + keyboardViewportRef.current = keyboardViewport; + setDragViewport(keyboardViewport); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + } + keyboardCommitTimerRef.current = setTimeout(() => { + keyboardCommitTimerRef.current = null; + const latest = keyboardViewportRef.current; + if (!latest || latest.sourceKey !== sourceViewportKeyRef.current) return; + keyboardViewportRef.current = null; + commitDrag({ _tag: "freeform", width: latest.width, height: latest.height }); + }, KEYBOARD_RESIZE_COMMIT_DELAY_MS); + }; + + const handleResizePointerDown = ( + direction: BrowserViewportResizeDirection, + event: ReactPointerEvent, + ) => { + if (effectiveViewport._tag === "fill") return; + event.preventDefault(); + event.stopPropagation(); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + keyboardCommitTimerRef.current = null; + } + keyboardViewportRef.current = null; + dragCleanupRef.current?.(); + dragVersionRef.current += 1; + const pointerId = event.pointerId; + const target = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + const startWidth = effectiveViewport.width; + const startHeight = effectiveViewport.height; + const dragZoomFactor = normalizedZoomFactor * layout.viewportScale; + let latest = { width: startWidth, height: startHeight }; + setDragViewport({ + sourceKey: sourceViewportKey, + width: startWidth, + height: startHeight, + direction, + }); + try { + target.setPointerCapture(pointerId); + } catch { + // Window listeners below keep the drag functional when capture is unavailable. + } + + const sourceChanged = () => sourceViewportKeyRef.current !== sourceViewportKey; + const move = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + if (sourceChanged()) { + cleanup(); + dragVersionRef.current += 1; + clearDrag(); + return; + } + moveEvent.preventDefault(); + const { width, height } = resizeBrowserViewportFromRail( + { width: startWidth, height: startHeight }, + { + x: moveEvent.clientX - startX, + y: moveEvent.clientY - startY, + }, + viewportContainerSize, + dragZoomFactor, + direction, + aspectRatio ?? undefined, + ); + latest = { width, height }; + setDragViewport({ sourceKey: sourceViewportKey, width, height, direction }); + }; + function cleanup() { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", cancel); + dragCleanupRef.current = null; + try { + target.releasePointerCapture(pointerId); + } catch { + // The browser may already have released capture on pointerup. + } + } + function finish(upEvent: PointerEvent) { + if (upEvent.pointerId !== pointerId) return; + cleanup(); + if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { + clearDrag(); + return; + } + commitDrag({ + _tag: "freeform", + width: latest.width, + height: latest.height, + }); + } + function cancel(cancelEvent: PointerEvent) { + if (cancelEvent.pointerId !== pointerId) return; + cleanup(); + dragVersionRef.current += 1; + clearDrag(); + } + dragCleanupRef.current = cleanup; + window.addEventListener("pointermove", move, { passive: false }); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", cancel); + }; + + return { + activeDrag, + commitViewportChange, + effectiveViewport, + handleResizeKeyDown, + handleResizePointerDown, + layout, + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9fb8d647b4e..5249ee0219d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,7 +129,6 @@ import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; -import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; @@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) { return (
- {isElectron && activeThreadRef ? ( - - ) : null} {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
state.byTabId[tabId] ?? null); + const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null); if (!event) return null; @@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: { @@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: { function AgentBrowserCursorEvent(props: { readonly event: DesktopPreviewPointerEvent; + readonly content: { + readonly x: number; + readonly y: number; + readonly scale: number; + readonly scrollLeft: number; + readonly scrollTop: number; + } | null; readonly zoomFactor: number; readonly controller: BrowserController; }) { - const { event, zoomFactor, controller } = props; + const { event, content, zoomFactor, controller } = props; const [active, setActive] = useState(true); useEffect(() => { @@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: { className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" style={{ opacity: agentBrowserCursorOpacity(active, controller), - transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`, + transform: `translate3d(${event.x * zoomFactor * (content?.scale ?? 1) + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor * (content?.scale ?? 1) + (content?.y ?? 0) - (content?.scrollTop ?? 0)}px, 0)`, }} aria-hidden="true" data-agent-browser-cursor diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx new file mode 100644 index 00000000000..2cd8494691f --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -0,0 +1,569 @@ +"use client"; + +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + FILL_PREVIEW_VIEWPORT, + PREVIEW_AUTOMATION_OPERATIONS, + type EnvironmentId, + type PreviewAutomationNavigateInput, + type PreviewAutomationOpenInput, + type PreviewAutomationResizeInput, + type PreviewAutomationResizeResult, + type PreviewAutomationHost as PreviewAutomationHostState, + type PreviewAutomationRequest, + type PreviewAutomationStatus, + type PreviewRenderedViewportSize, + type PreviewViewportSetting, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { Atom } from "effect/unstable/reactivity"; + +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + reconcilePreviewServerSessions, + updatePreviewServerSnapshot, +} from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { + readActiveBrowserRecordingTabId, + startBrowserRecording, + stopBrowserRecording, +} from "~/browser/browserRecording"; +import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; +import { isElectron } from "~/env"; +import { useEnvironments } from "~/state/environments"; +import { previewEnvironment } from "~/state/preview"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationOperationError, + PreviewAutomationOverlayTimeoutError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationTargetUnavailableError, + PreviewAutomationViewportTimeoutError, +} from "./previewAutomationErrors"; +import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; +import { createPreviewAutomationClientId } from "./previewAutomationClientId"; +import { + needsPreviewAutomationSessionSync, + resolvePreviewAutomationTarget, +} from "./previewAutomationTarget"; +import { isPreviewViewportReady } from "./previewViewportReadiness"; + +const waitForDesktopOverlay = async ( + threadRef: ScopedThreadRef, + requestId: string, + tabId: string, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const state = readThreadPreviewState(threadRef); + if (state.desktopByTabId[tabId] && previewBridge) { + const status = await previewBridge.automation.status(tabId); + if (status.available) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationOverlayTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + timeoutMs, + }); +}; + +const waitForNavigationReadiness = async ( + threadRef: ScopedThreadRef, + requestId: string, + tabId: string, + readiness: PreviewAutomationNavigateInput["readiness"], + timeoutMs: number, +): Promise => { + const targetReadiness = readiness ?? "load"; + if (!previewBridge || targetReadiness === "none") return; + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (targetReadiness === "domContentLoaded") { + const readyState = await previewBridge.automation.evaluate(tabId, { + expression: "document.readyState", + }); + if (readyState === "interactive" || readyState === "complete") return; + } else { + const status = await previewBridge.automation.status(tabId); + if (!status.loading) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationNavigationTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + readiness: targetReadiness, + timeoutMs, + }); +}; + +interface ExecutablePreviewWebview extends Element { + readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise; +} + +const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null => + Array.from(document.querySelectorAll("webview[data-preview-tab]")).find( + (candidate) => candidate.getAttribute("data-preview-tab") === tabId, + ) ?? null; + +const readWebviewViewport = async ( + webview: ExecutablePreviewWebview, +): Promise => { + const value = await webview.executeJavaScript( + "({ width: window.innerWidth, height: window.innerHeight })", + ); + if (typeof value !== "object" || value === null) return null; + const { width, height } = value as { readonly width?: unknown; readonly height?: unknown }; + return typeof width === "number" && + Number.isInteger(width) && + width > 0 && + typeof height === "number" && + Number.isInteger(height) && + height > 0 + ? { width, height } + : null; +}; + +const readRenderedViewport = async (tabId: string): Promise => { + const webview = findPreviewWebview(tabId); + if (!webview) return null; + return await readWebviewViewport(webview); +}; + +const readDeclaredViewport = ( + webview: ExecutablePreviewWebview | null, +): PreviewRenderedViewportSize | null => { + const width = Number(webview?.getAttribute("data-preview-css-width")); + const height = Number(webview?.getAttribute("data-preview-css-height")); + return Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0 + ? { width, height } + : null; +}; + +const waitForRenderedViewport = async ( + tabId: string, + setting: PreviewViewportSetting, + timeoutMs: number, + context: { + readonly requestId: PreviewAutomationRequest["requestId"]; + readonly environmentId: EnvironmentId; + readonly threadId: PreviewAutomationRequest["threadId"]; + }, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + try { + const webview = findPreviewWebview(tabId); + const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; + const declaredViewport = readDeclaredViewport(webview); + const renderedViewport = webview ? await readWebviewViewport(webview) : null; + if ( + renderedViewport && + isPreviewViewportReady({ + setting, + appliedSettingKey, + declaredViewport, + renderedViewport, + }) + ) { + return renderedViewport; + } + } catch { + // Registration and navigation can transiently replace the guest while + // React applies the server snapshot. Retry until the operation deadline. + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationViewportTimeoutError({ + ...context, + tabId, + timeoutMs, + }); +}; + +const currentStatus = async ( + threadRef: ScopedThreadRef, + requestedTabId: string | null, +): Promise => { + const state = readThreadPreviewState(threadRef); + const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); + const visible = tabId + ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) + : false; + const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; + const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null; + const viewportStatus = { + ...(viewportSetting === undefined ? {} : { viewportSetting }), + ...(viewport === null ? {} : { viewport }), + }; + if (tabId && previewBridge && state.desktopByTabId[tabId]) { + const status = await previewBridge.automation.status(tabId); + return { ...status, visible, ...viewportStatus }; + } + const navStatus = snapshot?.navStatus; + return { + available: Boolean(previewBridge?.automation), + visible, + tabId, + url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, + title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, + loading: navStatus?._tag === "Loading", + ...viewportStatus, + }; +}; + +export function PreviewAutomationHosts() { + const { environments } = useEnvironments(); + if (!isElectron || !previewBridge?.automation) return null; + return ( + <> + {/* + * Host lifetime follows the desktop runtime's environment connections, + * not the routed thread. This keeps background threads automatable and + * lets the subscription runtime own reconnects for every saved target. + */} + {environments.map((environment) => ( + + ))} + + ); +} + +function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { + const { environmentId } = props; + const registry = useContext(RegistryContext); + const [automationClientId] = useState(createPreviewAutomationClientId); + const initialAutomationHost = useMemo( + () => ({ + clientId: automationClientId, + environmentId, + supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], + }), + [automationClientId, environmentId], + ); + const automationRequestsAtom = previewEnvironment.automationRequests({ + environmentId, + input: initialAutomationHost, + }); + const listPreviews = useAtomQueryRunner(previewEnvironment.list, { + reportFailure: false, + }); + const open = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const resize = useAtomCommand(previewEnvironment.resize, { + reportFailure: false, + }); + const respondToAutomation = useAtomCommand( + previewEnvironment.respondToAutomation, + "preview automation response", + ); + const focusAutomationHost = useAtomCommand( + previewEnvironment.focusAutomationHost, + "preview automation host focus", + ); + const [automationConnectionAtom] = useState(() => Atom.make(null)); + const automationConnectionId = useAtomValue(automationConnectionAtom); + + const handleRequest = useCallback( + async (request: PreviewAutomationRequest): Promise => { + const threadRef: ScopedThreadRef = { + environmentId, + threadId: request.threadId, + }; + let tabId = request.tabId ?? null; + try { + let state = readThreadPreviewState(threadRef); + const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); + if (needsSessionSync) { + const listTarget = { + environmentId, + input: { threadId: request.threadId }, + } as const; + registry.refresh(previewEnvironment.list(listTarget)); + const result = await listPreviews(listTarget); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + reconcilePreviewServerSessions(threadRef, result.value.sessions); + state = readThreadPreviewState(threadRef); + } + tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; + const requireReadyTab = async () => { + const bridge = previewBridge; + const readyTabId = tabId; + if (!bridge || !readyTabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs); + return { bridge, tabId: readyTabId }; + }; + switch (request.operation) { + case "status": + return await currentStatus(threadRef, tabId); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + const reusedExistingTab = activeTabId !== null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId, + input: { + threadId: request.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + tabId = activeTabId; + } + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay( + threadRef, + request.requestId, + activeTabId, + request.timeoutMs, + ); + if (reusedExistingTab && input.url && previewBridge) { + const resolution = resolveBrowserNavigationTarget(environmentId, { + kind: "url", + url: input.url, + }); + await previewBridge.navigate(activeTabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + activeTabId, + "load", + request.timeoutMs, + ); + } + return await currentStatus(threadRef, activeTabId); + } + case "navigate": { + const ready = await requireReadyTab(); + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + environmentId, + input.target ?? { + kind: "url", + url: input.url!, + }, + ); + await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + ready.tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, ready.tabId); + } + case "resize": { + const ready = await requireReadyTab(); + const input = request.input as PreviewAutomationResizeInput; + const setting = resolvePreviewViewport(input); + const result = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: setting, + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + updatePreviewServerSnapshot(threadRef, result.value); + const viewport = await waitForRenderedViewport( + ready.tabId, + setting, + input.timeoutMs ?? request.timeoutMs, + { + requestId: request.requestId, + environmentId, + threadId: request.threadId, + }, + ); + return { + tabId: ready.tabId, + setting, + viewport, + } satisfies PreviewAutomationResizeResult; + } + case "snapshot": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.snapshot(ready.tabId); + } + case "click": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.click( + ready.tabId, + request.input as Parameters[1], + ); + } + case "type": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.type( + ready.tabId, + request.input as Parameters[1], + ); + } + case "press": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.press( + ready.tabId, + request.input as Parameters[1], + ); + } + case "scroll": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.scroll( + ready.tabId, + request.input as Parameters[1], + ); + } + case "evaluate": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.evaluate( + ready.tabId, + request.input as Parameters[1], + ); + } + case "waitFor": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.waitFor( + ready.tabId, + request.input as Parameters[1], + ); + } + case "recordingStart": { + const ready = await requireReadyTab(); + const startedAt = await startBrowserRecording(ready.tabId); + return { + tabId: ready.tabId, + recording: true, + startedAt, + }; + } + case "recordingStop": { + const recordingTabId = readActiveBrowserRecordingTabId(); + const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId); + const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId, + threadId: request.threadId, + tabId, + }); + } + return artifact; + } + } + } catch (cause) { + throw PreviewAutomationOperationError.fromCause({ + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + tabId, + cause, + }); + } + }, + [environmentId, listPreviews, open, registry, resize], + ); + const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); + const setRequestHandler = useAtomSet(requestHandlerAtom); + useEffect(() => { + setRequestHandler({ handle: handleRequest }); + }, [handleRequest, setRequestHandler]); + + const automationRequestConsumerAtom = useMemo( + () => + createPreviewAutomationRequestConsumerAtom({ + requestsAtom: automationRequestsAtom, + clientId: automationClientId, + connectionAtom: automationConnectionAtom, + environmentId, + requestHandlerAtom, + respond: (response) => + respondToAutomation({ + environmentId, + input: response, + }), + label: `preview:automation-host:${environmentId}:${automationClientId}`, + }), + [ + automationClientId, + automationConnectionAtom, + automationRequestsAtom, + requestHandlerAtom, + respondToAutomation, + environmentId, + ], + ); + useAtomValue(automationRequestConsumerAtom); + + useEffect(() => { + const report = () => { + if (!automationConnectionId) return; + void focusAutomationHost({ + environmentId, + input: { + clientId: automationClientId, + environmentId, + connectionId: automationConnectionId, + focused: document.hasFocus(), + }, + }); + }; + report(); + window.addEventListener("focus", report); + window.addEventListener("blur", report); + return () => { + window.removeEventListener("focus", report); + window.removeEventListener("blur", report); + }; + }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); + + return null; +} diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts b/apps/web/src/components/preview/PreviewAutomationOwner.test.ts deleted file mode 100644 index fe11ea75aa6..00000000000 --- a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner"; - -describe("observeAutomationOwnerConnectedGeneration", () => { - it("reports ownership when the initial transport generation connects", () => { - const initial = observeAutomationOwnerConnectedGeneration(null, 1); - expect(initial).toEqual({ - nextGeneration: 1, - shouldReport: true, - }); - - const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null); - expect(disconnected).toEqual({ - nextGeneration: 1, - shouldReport: false, - }); - - expect(observeAutomationOwnerConnectedGeneration(disconnected.nextGeneration, 2)).toEqual({ - nextGeneration: 2, - shouldReport: true, - }); - }); - - it("does not re-report for repeated connected state from the same generation", () => { - expect(observeAutomationOwnerConnectedGeneration(3, 3)).toEqual({ - nextGeneration: 3, - shouldReport: false, - }); - }); -}); diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx deleted file mode 100644 index 2be14363624..00000000000 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ /dev/null @@ -1,426 +0,0 @@ -"use client"; - -import { useAtomValue } from "@effect/atom-react"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { - type PreviewAutomationNavigateInput, - type PreviewAutomationOpenInput, - type PreviewAutomationOwner as PreviewAutomationOwnerState, - type PreviewAutomationRequest, - type PreviewAutomationStatus, - type ScopedThreadRef, -} from "@t3tools/contracts"; -import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; - -import { - applyPreviewServerSnapshot, - readThreadPreviewState, - subscribeThreadPreviewState, -} from "~/previewStateStore"; -import { useRightPanelStore } from "~/rightPanelStore"; -import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; -import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; -import { previewEnvironment } from "~/state/preview"; -import { useEnvironmentConnectionState } from "~/state/environments"; -import { useAtomCommand } from "~/state/use-atom-command"; - -import { previewBridge } from "./previewBridge"; -import { - PreviewAutomationNavigationTimeoutError, - PreviewAutomationOperationError, - PreviewAutomationOverlayTimeoutError, - PreviewAutomationRecordingNotActiveError, - PreviewAutomationStaleOwnerError, - PreviewAutomationTargetUnavailableError, -} from "./previewAutomationErrors"; -import { - createLatestPreviewAutomationRequestHandler, - createPreviewAutomationRequestConsumerAtom, -} from "./previewAutomationRequestConsumer"; - -export function observeAutomationOwnerConnectedGeneration( - previousGeneration: number | null, - connectedGeneration: number | null, -): { - readonly nextGeneration: number | null; - readonly shouldReport: boolean; -} { - if (connectedGeneration === null) { - return { - nextGeneration: previousGeneration, - shouldReport: false, - }; - } - return { - nextGeneration: connectedGeneration, - shouldReport: previousGeneration !== connectedGeneration, - }; -} - -const waitForDesktopOverlay = async ( - threadRef: ScopedThreadRef, - requestId: string, - timeoutMs: number, -): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const state = readThreadPreviewState(threadRef); - const tabId = state.snapshot?.tabId; - if (tabId && state.desktopOverlay && previewBridge) { - const status = await previewBridge.automation.status(tabId); - if (status.available) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationOverlayTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - timeoutMs, - }); -}; - -const waitForNavigationReadiness = async ( - threadRef: ScopedThreadRef, - requestId: string, - tabId: string, - readiness: PreviewAutomationNavigateInput["readiness"], - timeoutMs: number, -): Promise => { - const targetReadiness = readiness ?? "load"; - if (!previewBridge || targetReadiness === "none") return; - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (targetReadiness === "domContentLoaded") { - const readyState = await previewBridge.automation.evaluate(tabId, { - expression: "document.readyState", - }); - if (readyState === "interactive" || readyState === "complete") return; - } else { - const status = await previewBridge.automation.status(tabId); - if (!status.loading) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationNavigationTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - readiness: targetReadiness, - timeoutMs, - }); -}; - -const currentStatus = async ( - threadRef: ScopedThreadRef, - visible: boolean, -): Promise => { - const state = readThreadPreviewState(threadRef); - const tabId = state.snapshot?.tabId ?? null; - if (tabId && previewBridge && state.desktopOverlay) { - const status = await previewBridge.automation.status(tabId); - return { ...status, visible }; - } - const navStatus = state.snapshot?.navStatus; - return { - available: Boolean(previewBridge?.automation), - visible, - tabId, - url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, - title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, - loading: navStatus?._tag === "Loading", - }; -}; - -export function PreviewAutomationOwner(props: { - readonly threadRef: ScopedThreadRef; - readonly visible: boolean; -}) { - const { threadRef, visible } = props; - const automationClientId = useId(); - const initialAutomationOwner = useMemo( - () => ({ - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId: null, - visible: false, - supportsAutomation: Boolean(previewBridge?.automation), - focusedAt: new Date().toISOString(), - }), - [automationClientId, threadRef.environmentId, threadRef.threadId], - ); - const automationRequestsAtom = previewEnvironment.automationRequests({ - environmentId: threadRef.environmentId, - input: initialAutomationOwner, - }); - const connectionState = useEnvironmentConnectionState(threadRef.environmentId).data; - const connectedGeneration = - connectionState?.phase === "connected" ? connectionState.generation : null; - const open = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const respondToAutomation = useAtomCommand( - previewEnvironment.respondToAutomation, - "preview automation response", - ); - const reportAutomationOwner = useAtomCommand( - previewEnvironment.reportAutomationOwner, - "preview automation owner report", - ); - const clearAutomationOwner = useAtomCommand( - previewEnvironment.clearAutomationOwner, - "preview automation owner clear", - ); - const connectedGenerationRef = useRef(null); - const reportCurrentAutomationOwner = useEffectEvent(() => { - const state = readThreadPreviewState(threadRef); - return reportAutomationOwner({ - environmentId: threadRef.environmentId, - input: { - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId: state.snapshot?.tabId ?? null, - visible, - supportsAutomation: Boolean(previewBridge?.automation), - focusedAt: new Date().toISOString(), - }, - }); - }); - useEffect(() => { - void reportCurrentAutomationOwner(); - }, [threadRef, visible]); - - const handleRequest = useCallback( - async (request: PreviewAutomationRequest): Promise => { - let tabId = request.tabId ?? null; - try { - if (request.threadId !== threadRef.threadId) { - throw new PreviewAutomationStaleOwnerError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - expectedThreadId: threadRef.threadId, - requestedThreadId: request.threadId, - }); - } - const state = readThreadPreviewState(threadRef); - tabId = request.tabId ?? state.snapshot?.tabId ?? null; - const unavailableTarget = { - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - bridgeAvailable: Boolean(previewBridge), - }; - switch (request.operation) { - case "status": - return await currentStatus(threadRef, visible); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; - tabId = activeTabId; - if (!activeTabId) { - const result = await open({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - ...(input.url ? { url: input.url } : {}), - }, - }); - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); - } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - tabId = activeTabId; - } else if (input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); - return await currentStatus(threadRef, input.show ?? true); - } - case "navigate": { - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, - ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return await currentStatus(threadRef, visible); - } - case "snapshot": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.snapshot(tabId); - case "click": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.click( - tabId, - request.input as Parameters[1], - ); - case "type": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.type( - tabId, - request.input as Parameters[1], - ); - case "press": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.press( - tabId, - request.input as Parameters[1], - ); - case "scroll": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.scroll( - tabId, - request.input as Parameters[1], - ); - case "evaluate": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.evaluate( - tabId, - request.input as Parameters[1], - ); - case "waitFor": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.waitFor( - tabId, - request.input as Parameters[1], - ); - case "recordingStart": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const startedAt = await startBrowserRecording(tabId); - return { - tabId, - recording: true, - startedAt, - }; - } - case "recordingStop": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const artifact = await stopBrowserRecording(tabId); - if (!artifact) { - throw new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - }); - } - return artifact; - } - } - } catch (cause) { - throw PreviewAutomationOperationError.fromCause({ - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - cause, - }); - } - }, - [open, threadRef, visible], - ); - const [requestHandler] = useState(() => - createLatestPreviewAutomationRequestHandler(handleRequest), - ); - useEffect(() => { - requestHandler.set(handleRequest); - }, [handleRequest, requestHandler]); - - const automationRequestConsumerAtom = useMemo( - () => - createPreviewAutomationRequestConsumerAtom({ - requestsAtom: automationRequestsAtom, - environmentId: threadRef.environmentId, - handleRequest: requestHandler.handle, - respond: (response) => - respondToAutomation({ - environmentId: threadRef.environmentId, - input: response, - }), - label: `preview:automation-request-consumer:${automationClientId}`, - }), - [ - automationClientId, - automationRequestsAtom, - requestHandler, - respondToAutomation, - threadRef.environmentId, - ], - ); - useAtomValue(automationRequestConsumerAtom); - - useEffect(() => { - const observation = observeAutomationOwnerConnectedGeneration( - connectedGenerationRef.current, - connectedGeneration, - ); - connectedGenerationRef.current = observation.nextGeneration; - if (!observation.shouldReport) return; - - void reportCurrentAutomationOwner(); - }, [connectedGeneration]); - - useEffect(() => { - const report = () => void reportCurrentAutomationOwner(); - window.addEventListener("focus", report); - const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => { - if (state.snapshot?.tabId !== previous.snapshot?.tabId) { - report(); - } - }); - return () => { - window.removeEventListener("focus", report); - unsubscribe(); - void clearAutomationOwner({ - environmentId: threadRef.environmentId, - input: { - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }, - }); - }; - }, [automationClientId, clearAutomationOwner, threadRef]); - - return null; -} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index f11ff4d2d30..13ddcf57e9e 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -19,6 +19,10 @@ interface Props { hasWebContents: boolean; /** Current zoom factor as a number (1.0 = 100%). */ zoomFactor: number; + /** Fixed viewport modes expose the device toolbar and resize rails. */ + deviceToolbarVisible: boolean; + /** Switches between fill-panel mode and a fixed responsive viewport. */ + onToggleDeviceToolbar: () => void; } /** @@ -26,7 +30,13 @@ interface Props { * controls, and storage-clearing actions. Only mounted by `PreviewView` * when the desktop bridge is present, so we can call it unconditionally. */ -export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { +export function PreviewMoreMenu({ + tabId, + hasWebContents, + zoomFactor, + deviceToolbarVisible, + onToggleDeviceToolbar, +}: Props) { if (!previewBridge) return null; const bridge = previewBridge; const tabDisabled = !tabId || !hasWebContents; @@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { Open DevTools + + {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} + + {/* Zoom row: label + inline control cluster. `closeOnClick=false` keeps the menu open while the user clicks the +/− buttons. diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 861a8df616b..bb1c4d409eb 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,13 +1,22 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { type ScopedThreadRef } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + FILL_PREVIEW_VIEWPORT, + type PreviewViewportSetting, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; -import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore"; +import { + rememberPreviewUrl, + updatePreviewServerSnapshot, + useThreadPreviewState, +} from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; @@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { + commitBrowserViewportChange, + subscribeBrowserViewportChange, +} from "~/browser/browserViewportActions"; +import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { useLoadingProgress } from "./useLoadingProgress"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; @@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const environment = useEnvironment(threadRef.environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); const open = useAtomCommand(previewEnvironment.open); + const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); usePreviewSession(threadRef); @@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, environmentHttpBaseUrl, }) ?? undefined) : undefined; + const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; + const panelRect = useBrowserSurfaceStore((state) => + tabId ? (state.byTabId[tabId]?.rect ?? null) : null, + ); const handleSubmitUrl = useCallback( async (next: string) => { @@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, if (previewBridge && tabId) void previewBridge.resetZoom(tabId); }, [tabId]); + const handleViewportChange = useCallback( + async (nextViewport: PreviewViewportSetting) => { + if (!tabId) return; + const result = await resize({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + tabId, + viewport: nextViewport, + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Unable to resize browser viewport", + description: error instanceof Error ? error.message : "An error occurred.", + }); + throw error; + } + updatePreviewServerSnapshot(threadRef, result.value); + }, + [resize, tabId, threadRef], + ); + + const handleToggleDeviceToolbar = () => { + if (!tabId) return; + if (viewport._tag !== "fill") { + void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); + return; + } + + const responsiveSize = panelRect + ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) + : { width: 1024, height: 768 }; + void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch( + () => undefined, + ); + }; + + useEffect(() => { + if (!tabId) return; + return subscribeBrowserViewportChange(tabId, handleViewportChange); + }, [handleViewportChange, tabId]); + const handleBack = useCallback(() => { if (previewBridge && tabId) void previewBridge.goBack(tabId); }, [tabId]); @@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, tabId={tabId} hasWebContents={desktopOverlay !== null} zoomFactor={desktopOverlay?.zoomFactor ?? 1} + deviceToolbarVisible={viewport._tag !== "fill"} + onToggleDeviceToolbar={handleToggleDeviceToolbar} /> ) : null } diff --git a/apps/web/src/components/preview/previewAutomationClientId.test.ts b/apps/web/src/components/preview/previewAutomationClientId.test.ts new file mode 100644 index 00000000000..f51732d91a4 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationClientId.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createPreviewAutomationClientId } from "./previewAutomationClientId"; + +describe("createPreviewAutomationClientId", () => { + it("creates bounded cryptographically random identities for independent host lifetimes", () => { + const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId); + + expect(new Set(clientIds).size).toBe(clientIds.length); + expect(clientIds.every((clientId) => clientId.startsWith("preview-"))).toBe(true); + expect(clientIds.every((clientId) => clientId.length <= 128)).toBe(true); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationClientId.ts b/apps/web/src/components/preview/previewAutomationClientId.ts new file mode 100644 index 00000000000..2d243de3031 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationClientId.ts @@ -0,0 +1,4 @@ +export function createPreviewAutomationClientId(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index c4ca445458c..dcf35de53f2 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -1,6 +1,6 @@ import { EnvironmentId, - type PreviewAutomationOwner, + type PreviewAutomationHost, PreviewAutomationOperation, type PreviewAutomationRequest, type PreviewAutomationResponse, @@ -13,7 +13,7 @@ import * as Schema from "effect/Schema"; export interface PreviewAutomationOperationContext { readonly requestId: PreviewAutomationRequest["requestId"]; readonly operation: PreviewAutomationRequest["operation"]; - readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly environmentId: PreviewAutomationHost["environmentId"]; readonly threadId: PreviewAutomationRequest["threadId"]; readonly tabId: Exclude | null; } @@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC } } -export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( - "PreviewAutomationStaleOwnerError", +export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationViewportTimeoutError", { requestId: TrimmedNonEmptyString, environmentId: EnvironmentId, - expectedThreadId: ThreadId, - requestedThreadId: ThreadId, + threadId: ThreadId, + tabId: PreviewTabId, + timeoutMs: Schema.Int, }, ) { get responseTag() { - return "PreviewAutomationUnavailableError" as const; + return "PreviewAutomationTimeoutError" as const; } override get message(): string { - return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`; } } @@ -100,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError requestId: TrimmedNonEmptyString, environmentId: EnvironmentId, threadId: ThreadId, - tabId: PreviewTabId, + tabId: Schema.NullOr(PreviewTabId), }, ) { get responseTag() { @@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError } override get message(): string { - return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId ?? "unassigned"} on environment ${this.environmentId} thread ${this.threadId}.`; } } +export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetNotEditableHostError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + get responseTag() { + return "PreviewAutomationTargetNotEditableError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} requires an editable target in tab ${this.tabId ?? "unassigned"}.`; + } +} + +const targetNotEditableDiagnostics = ( + cause: unknown, +): { + readonly selectorKind?: "focused-element" | "locator" | "selector"; + readonly selectorLength?: number; +} | null => { + if ( + typeof cause !== "object" || + cause === null || + !("_tag" in cause) || + cause._tag !== "PreviewAutomationTargetNotEditableError" + ) { + return null; + } + const selectorKind = + "selectorKind" in cause && + (cause.selectorKind === "focused-element" || + cause.selectorKind === "locator" || + cause.selectorKind === "selector") + ? cause.selectorKind + : undefined; + const selectorLength = + "selectorLength" in cause && + typeof cause.selectorLength === "number" && + Number.isInteger(cause.selectorLength) && + cause.selectorLength >= 0 + ? cause.selectorLength + : undefined; + return { + ...(selectorKind === undefined ? {} : { selectorKind }), + ...(selectorLength === undefined ? {} : { selectorLength }), + }; +}; + export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( "PreviewAutomationOperationError", { @@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass
 {
   const detail = Object.fromEntries(
     Object.entries(error).filter(
diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts
index 905a014d5af..af3a95c32c7 100644
--- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts
+++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts
@@ -2,13 +2,18 @@ import {
   EnvironmentId,
   type PreviewAutomationRequest,
   type PreviewAutomationResponse,
+  type PreviewAutomationStreamEvent,
   PreviewTabId,
   ThreadId,
 } from "@t3tools/contracts";
 import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
 import { describe, expect, it, vi } from "vite-plus/test";
 
-import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
+import {
+  PreviewAutomationRecordingNotActiveError,
+  PreviewAutomationTargetUnavailableError,
+  PreviewAutomationViewportTimeoutError,
+} from "./previewAutomationErrors";
 import {
   createPreviewAutomationRequestConsumerAtom,
   serializePreviewAutomationError,
@@ -17,6 +22,8 @@ import {
 const environmentId = EnvironmentId.make("environment-1");
 const threadId = ThreadId.make("thread-1");
 const tabId = PreviewTabId.make("tab-1");
+const clientId = "client-1";
+const connectionId = "connection-1";
 
 const request = (
   requestId: string,
@@ -30,10 +37,88 @@ const request = (
   ...overrides,
 });
 
+const requestEvent = (
+  requestId: string,
+  overrides: Partial = {},
+  eventConnectionId = connectionId,
+): PreviewAutomationStreamEvent => ({
+  type: "request",
+  connectionId: eventConnectionId,
+  request: request(requestId, overrides),
+});
+
+const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise) => ({
+  connectionAtom: Atom.make(null),
+  requestHandlerAtom: Atom.make({ handle: handleRequest }),
+});
+
 describe("previewAutomationRequestConsumer", () => {
+  it("acknowledges a replacement stream before consuming requests from it", async () => {
+    const requestsAtom = Atom.make(
+      AsyncResult.success({
+        type: "connected",
+        connectionId,
+      }),
+    );
+    const handleRequest = vi.fn(async () => undefined);
+    const respond = vi.fn(async () => undefined);
+    const state = consumerState(handleRequest);
+    const consumerAtom = createPreviewAutomationRequestConsumerAtom({
+      requestsAtom,
+      clientId,
+      connectionAtom: state.connectionAtom,
+      environmentId,
+      requestHandlerAtom: state.requestHandlerAtom,
+      respond,
+      label: "test:preview-automation-connected",
+    });
+    const registry = AtomRegistry.make();
+
+    registry.mount(consumerAtom);
+    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-after-connect")));
+
+    await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe(connectionId));
+    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
+    expect(handleRequest).toHaveBeenCalledTimes(1);
+    registry.dispose();
+  });
+
+  it("drops late requests from an older stream generation", async () => {
+    const requestsAtom = Atom.make(
+      AsyncResult.success({
+        type: "connected",
+        connectionId: "connection-2",
+      }),
+    );
+    const handleRequest = vi.fn(async () => undefined);
+    const respond = vi.fn(async () => undefined);
+    const state = consumerState(handleRequest);
+    const consumerAtom = createPreviewAutomationRequestConsumerAtom({
+      requestsAtom,
+      clientId,
+      connectionAtom: state.connectionAtom,
+      environmentId,
+      requestHandlerAtom: state.requestHandlerAtom,
+      respond,
+      label: "test:preview-automation-stale-generation",
+    });
+    const registry = AtomRegistry.make();
+
+    registry.mount(consumerAtom);
+    registry.set(
+      requestsAtom,
+      AsyncResult.success(requestEvent("request-stale", {}, "connection-1")),
+    );
+
+    await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe("connection-2"));
+    expect(handleRequest).not.toHaveBeenCalled();
+    expect(respond).not.toHaveBeenCalled();
+    registry.dispose();
+  });
+
   it("consumes every request emitted before React can render", async () => {
-    const requestsAtom = Atom.make>(
-      AsyncResult.initial(false),
+    const requestsAtom = Atom.make>(
+      AsyncResult.initial(false),
     );
     const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({
       requestId: value.requestId,
@@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => {
     const respond = vi.fn(async (response: PreviewAutomationResponse) => {
       responses.push(response);
     });
+    const state = consumerState(handleRequest);
     const consumerAtom = createPreviewAutomationRequestConsumerAtom({
       requestsAtom,
+      clientId,
+      connectionAtom: state.connectionAtom,
       environmentId,
-      handleRequest,
+      requestHandlerAtom: state.requestHandlerAtom,
       respond,
       label: "test:preview-automation-consumer",
     });
     const registry = AtomRegistry.make();
     registry.mount(consumerAtom);
 
-    registry.set(requestsAtom, AsyncResult.success(request("request-1")));
-    registry.set(requestsAtom, AsyncResult.success(request("request-2")));
+    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1")));
+    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2")));
 
     await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
     expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([
@@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => {
     registry.dispose();
   });
 
+  it("uses the latest request handler without rebuilding the stream consumer", async () => {
+    const requestsAtom = Atom.make>(
+      AsyncResult.initial(false),
+    );
+    const firstHandler = vi.fn(async () => "first");
+    const secondHandler = vi.fn(async () => "second");
+    const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
+    const state = consumerState(firstHandler);
+    const consumerAtom = createPreviewAutomationRequestConsumerAtom({
+      requestsAtom,
+      clientId,
+      connectionAtom: state.connectionAtom,
+      environmentId,
+      requestHandlerAtom: state.requestHandlerAtom,
+      respond,
+      label: "test:preview-automation-latest-handler",
+    });
+    const registry = AtomRegistry.make();
+    registry.mount(consumerAtom);
+
+    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-first")));
+    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
+    registry.set(state.requestHandlerAtom, { handle: secondHandler });
+    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-second")));
+
+    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
+    expect(firstHandler).toHaveBeenCalledTimes(1);
+    expect(secondHandler).toHaveBeenCalledTimes(1);
+    expect(respond.mock.calls.map(([response]) => response.result)).toEqual(["first", "second"]);
+    registry.dispose();
+  });
+
   it("consumes a request that arrived immediately before the consumer mounted", async () => {
     const requestsAtom = Atom.make(
-      AsyncResult.success(request("request-ready")),
+      AsyncResult.success(requestEvent("request-ready")),
     );
     const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
+    const state = consumerState(async () => undefined);
     const consumerAtom = createPreviewAutomationRequestConsumerAtom({
       requestsAtom,
+      clientId,
+      connectionAtom: state.connectionAtom,
       environmentId,
-      handleRequest: async () => undefined,
+      requestHandlerAtom: state.requestHandlerAtom,
       respond,
       label: "test:preview-automation-initial-request",
     });
@@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => {
     registry.mount(consumerAtom);
 
     await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
-    expect(respond).toHaveBeenCalledWith({ requestId: "request-ready", ok: true });
+    expect(respond).toHaveBeenCalledWith({
+      clientId,
+      connectionId,
+      requestId: "request-ready",
+      ok: true,
+    });
     registry.dispose();
   });
 
@@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => {
     });
   });
 
+  it("reports a missing recording even when no preview tab remains", () => {
+    const error = new PreviewAutomationRecordingNotActiveError({
+      requestId: "request-recording-stop",
+      environmentId,
+      threadId,
+      tabId: null,
+    });
+
+    expect(
+      serializePreviewAutomationError(error, {
+        requestId: "request-recording-stop",
+        operation: "recordingStop",
+        environmentId,
+        threadId,
+        tabId: null,
+      }),
+    ).toMatchObject({
+      _tag: "PreviewAutomationExecutionError",
+      detail: { tabId: null },
+    });
+  });
+
+  it("preserves viewport render timeouts as timeout responses", () => {
+    const error = new PreviewAutomationViewportTimeoutError({
+      requestId: "request-resize",
+      environmentId,
+      threadId,
+      tabId,
+      timeoutMs: 2_500,
+    });
+
+    expect(
+      serializePreviewAutomationError(error, {
+        requestId: "request-resize",
+        operation: "resize",
+        environmentId,
+        threadId,
+        tabId,
+      }),
+    ).toMatchObject({
+      _tag: "PreviewAutomationTimeoutError",
+      detail: { tabId: "tab-1", timeoutMs: 2_500 },
+    });
+  });
+
+  it("maps desktop non-editable targets to the public typed response", () => {
+    expect(
+      serializePreviewAutomationError(
+        {
+          _tag: "PreviewAutomationTargetNotEditableError",
+          tabId: "tab-1",
+          selectorKind: "selector",
+          selectorLength: 6,
+        },
+        {
+          requestId: "request-type",
+          operation: "type",
+          environmentId,
+          threadId,
+          tabId,
+        },
+      ),
+    ).toEqual({
+      _tag: "PreviewAutomationTargetNotEditableError",
+      message:
+        "Preview automation type request request-type requires an editable target in tab tab-1.",
+      detail: {
+        requestId: "request-type",
+        operation: "type",
+        environmentId: "environment-1",
+        threadId: "thread-1",
+        tabId: "tab-1",
+        selectorKind: "selector",
+        selectorLength: 6,
+      },
+    });
+  });
+
   it("correlates unexpected failures without exposing cause details", () => {
     const cause = new Error("private bridge token: preview-secret");
     const context = {
@@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => {
   });
 
   it("sanitizes unexpected handler failures at the response boundary", async () => {
-    const requestsAtom = Atom.make>(
-      AsyncResult.initial(false),
+    const requestsAtom = Atom.make>(
+      AsyncResult.initial(false),
     );
     const responses: PreviewAutomationResponse[] = [];
+    const state = consumerState(async () => {
+      throw new Error("desktop IPC secret: do-not-return");
+    });
     const consumerAtom = createPreviewAutomationRequestConsumerAtom({
       requestsAtom,
+      clientId,
+      connectionAtom: state.connectionAtom,
       environmentId,
-      handleRequest: async () => {
-        throw new Error("desktop IPC secret: do-not-return");
-      },
+      requestHandlerAtom: state.requestHandlerAtom,
       respond: async (response) => {
         responses.push(response);
       },
@@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => {
     registry.set(
       requestsAtom,
       AsyncResult.success(
-        request("request-failed", {
+        requestEvent("request-failed", {
           operation: "click",
           tabId,
         }),
@@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => {
 
     await vi.waitFor(() => expect(responses).toHaveLength(1));
     expect(responses[0]).toEqual({
+      clientId,
+      connectionId,
       requestId: "request-failed",
       ok: false,
       error: {
diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
index 37983b0255e..89a9387e4af 100644
--- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
+++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
@@ -1,87 +1,121 @@
 import type {
-  PreviewAutomationOwner,
+  PreviewAutomationHost,
   PreviewAutomationRequest,
   PreviewAutomationResponse,
+  PreviewAutomationStreamEvent,
 } from "@t3tools/contracts";
 import { AsyncResult, Atom } from "effect/unstable/reactivity";
 
 import {
   PreviewAutomationOperationError,
   type PreviewAutomationOperationContext,
-  serializePreviewAutomationOwnerError,
+  serializePreviewAutomationHostError,
 } from "./previewAutomationErrors";
 
-type AutomationRequestResult = AsyncResult.AsyncResult;
-type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise;
-
-export function createLatestPreviewAutomationRequestHandler(initial: AutomationRequestHandler): {
-  readonly set: (handler: AutomationRequestHandler) => void;
-  readonly handle: AutomationRequestHandler;
-} {
-  let current = initial;
-  return {
-    set: (handler) => {
-      current = handler;
-    },
-    handle: (request) => current(request),
-  };
-}
+type AutomationStreamResult = AsyncResult.AsyncResult;
 
 export function serializePreviewAutomationError(
   error: unknown,
   context: PreviewAutomationOperationContext,
 ): NonNullable {
-  return serializePreviewAutomationOwnerError(
+  return serializePreviewAutomationHostError(
     PreviewAutomationOperationError.fromCause({ ...context, cause: error }),
   );
 }
 
 export function createPreviewAutomationRequestConsumerAtom(options: {
-  readonly requestsAtom: Atom.Atom>;
-  readonly environmentId: PreviewAutomationOwner["environmentId"];
-  readonly handleRequest: (request: PreviewAutomationRequest) => Promise;
+  readonly requestsAtom: Atom.Atom>;
+  readonly clientId: PreviewAutomationHost["clientId"];
+  readonly connectionAtom: Atom.Writable;
+  readonly environmentId: PreviewAutomationHost["environmentId"];
+  readonly requestHandlerAtom: Atom.Atom<{
+    readonly handle: (request: PreviewAutomationRequest) => Promise;
+  }>;
   readonly respond: (response: PreviewAutomationResponse) => Promise;
   readonly label: string;
 }): Atom.Atom {
   return Atom.make((get) => {
+    get.mount(options.connectionAtom);
+    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;
 
-    const consume = (result: AutomationRequestResult) => {
+    const consume = (result: AutomationStreamResult) => {
       if (!AsyncResult.isSuccess(result)) return;
-      const request = result.value;
-      void options.handleRequest(request).then(
-        (value) =>
-          options.respond({
-            requestId: request.requestId,
-            ok: true,
-            ...(value === undefined ? {} : { result: value }),
-          }),
-        (error) =>
-          options.respond({
-            requestId: request.requestId,
-            ok: false,
-            error: serializePreviewAutomationError(error, {
+      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;
+      }
+      if (reportedConnectionId !== event.connectionId) {
+        reportedConnectionId = event.connectionId;
+        get.set(options.connectionAtom, event.connectionId);
+      }
+      if (event.type === "connected") {
+        return;
+      }
+      const request = event.request;
+      void get
+        .once(options.requestHandlerAtom)
+        .handle(request)
+        .then(
+          (value) =>
+            options.respond({
+              clientId: options.clientId,
+              connectionId: event.connectionId,
+              requestId: request.requestId,
+              ok: true,
+              ...(value === undefined ? {} : { result: value }),
+            }),
+          (error) =>
+            options.respond({
+              clientId: options.clientId,
+              connectionId: event.connectionId,
               requestId: request.requestId,
-              operation: request.operation,
-              environmentId: options.environmentId,
-              threadId: request.threadId,
-              tabId: request.tabId ?? null,
+              ok: false,
+              error: serializePreviewAutomationError(error, {
+                requestId: request.requestId,
+                operation: request.operation,
+                environmentId: options.environmentId,
+                threadId: request.threadId,
+                tabId: request.tabId ?? null,
+              }),
             }),
-          }),
-      );
+        );
     };
 
     get.addFinalizer(() => {
       disposed = true;
     });
     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);
+      }
+    }
     get.subscribe(options.requestsAtom, (result) => {
       requestsVersion += 1;
       consume(result);
     });
     queueMicrotask(() => {
-      if (!disposed && requestsVersion === 0) consume(initialRequest);
+      const initialConnectionWasSkipped =
+        AsyncResult.isSuccess(initialRequest) &&
+        initialRequest.value.connectionId === activeConnectionId &&
+        initialRequest.value.connectionId !== reportedConnectionId;
+      if (!disposed && (requestsVersion === 0 || initialConnectionWasSkipped)) {
+        consume(initialRequest);
+      }
     });
   }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label));
 }
diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts
new file mode 100644
index 00000000000..379e3519057
--- /dev/null
+++ b/apps/web/src/components/preview/previewAutomationTarget.test.ts
@@ -0,0 +1,45 @@
+import type { PreviewSessionSnapshot } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+  needsPreviewAutomationSessionSync,
+  resolvePreviewAutomationTarget,
+} from "./previewAutomationTarget";
+
+const snapshot = (tabId: string): PreviewSessionSnapshot => ({
+  threadId: "thread-1",
+  tabId,
+  navStatus: { _tag: "Idle" },
+  canGoBack: false,
+  canGoForward: false,
+  updatedAt: "2026-01-01T00:00:00.000Z",
+});
+
+describe("preview automation target selection", () => {
+  it("refreshes authoritative sessions whenever the caller relies on the active tab", () => {
+    const active = snapshot("tab-active");
+    expect(
+      needsPreviewAutomationSessionSync(
+        { snapshot: active, sessions: { [active.tabId]: active } },
+        undefined,
+      ),
+    ).toBe(true);
+  });
+
+  it("refreshes an explicit tab only when it is absent locally", () => {
+    const active = snapshot("tab-active");
+    const state = { snapshot: active, sessions: { [active.tabId]: active } };
+    expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false);
+    expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true);
+  });
+
+  it("does not report the active tab under an unknown requested tab id", () => {
+    const active = snapshot("tab-active");
+    expect(
+      resolvePreviewAutomationTarget(
+        { snapshot: active, sessions: { [active.tabId]: active } },
+        "tab-missing",
+      ),
+    ).toEqual({ tabId: null, snapshot: null });
+  });
+});
diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts
new file mode 100644
index 00000000000..1dc9f17f78a
--- /dev/null
+++ b/apps/web/src/components/preview/previewAutomationTarget.ts
@@ -0,0 +1,25 @@
+import type { PreviewSessionSnapshot } from "@t3tools/contracts";
+
+interface PreviewAutomationSessionIndex {
+  readonly snapshot: PreviewSessionSnapshot | null;
+  readonly sessions: Readonly>;
+}
+
+export function needsPreviewAutomationSessionSync(
+  state: PreviewAutomationSessionIndex,
+  requestedTabId: string | undefined,
+): boolean {
+  return (
+    Object.keys(state.sessions).length === 0 ||
+    requestedTabId === undefined ||
+    state.sessions[requestedTabId] === undefined
+  );
+}
+
+export function resolvePreviewAutomationTarget(
+  state: PreviewAutomationSessionIndex,
+  requestedTabId: string | null,
+): { readonly tabId: string | null; readonly snapshot: PreviewSessionSnapshot | null } {
+  const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot;
+  return { tabId: snapshot?.tabId ?? null, snapshot };
+}
diff --git a/apps/web/src/components/preview/previewViewportReadiness.test.ts b/apps/web/src/components/preview/previewViewportReadiness.test.ts
new file mode 100644
index 00000000000..c47ffc3ff08
--- /dev/null
+++ b/apps/web/src/components/preview/previewViewportReadiness.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
+
+import { isPreviewViewportReady } from "./previewViewportReadiness";
+
+describe("isPreviewViewportReady", () => {
+  const landscape = {
+    _tag: "preset",
+    width: 844,
+    height: 390,
+    presetId: "iphone-12-pro",
+  } as const;
+
+  it("rejects a stale same-mode preset while React applies the requested orientation", () => {
+    expect(
+      isPreviewViewportReady({
+        setting: landscape,
+        appliedSettingKey: "preset:390:844:iphone-12-pro",
+        declaredViewport: { width: 390, height: 844 },
+        renderedViewport: { width: 390, height: 844 },
+      }),
+    ).toBe(false);
+  });
+
+  it("requires both the declaration and guest viewport to match a fixed request", () => {
+    const appliedSettingKey = browserViewportSettingKey(landscape);
+    expect(
+      isPreviewViewportReady({
+        setting: landscape,
+        appliedSettingKey,
+        declaredViewport: { width: 390, height: 844 },
+        renderedViewport: { width: 844, height: 390 },
+      }),
+    ).toBe(false);
+    expect(
+      isPreviewViewportReady({
+        setting: landscape,
+        appliedSettingKey,
+        declaredViewport: { width: 844, height: 390 },
+        renderedViewport: { width: 844, height: 390 },
+      }),
+    ).toBe(true);
+  });
+
+  it("allows one pixel of Electron rounding tolerance in every mode", () => {
+    expect(
+      isPreviewViewportReady({
+        setting: { _tag: "fill" },
+        appliedSettingKey: "fill",
+        declaredViewport: { width: 500, height: 700 },
+        renderedViewport: { width: 501, height: 699 },
+      }),
+    ).toBe(true);
+    expect(
+      isPreviewViewportReady({
+        setting: landscape,
+        appliedSettingKey: browserViewportSettingKey(landscape),
+        declaredViewport: { width: 844, height: 390 },
+        renderedViewport: { width: 845, height: 389 },
+      }),
+    ).toBe(true);
+    expect(
+      isPreviewViewportReady({
+        setting: landscape,
+        appliedSettingKey: browserViewportSettingKey(landscape),
+        declaredViewport: { width: 844, height: 390 },
+        renderedViewport: { width: 846, height: 390 },
+      }),
+    ).toBe(false);
+  });
+});
diff --git a/apps/web/src/components/preview/previewViewportReadiness.ts b/apps/web/src/components/preview/previewViewportReadiness.ts
new file mode 100644
index 00000000000..5ff963fbceb
--- /dev/null
+++ b/apps/web/src/components/preview/previewViewportReadiness.ts
@@ -0,0 +1,37 @@
+import type { PreviewRenderedViewportSize, PreviewViewportSetting } from "@t3tools/contracts";
+
+import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
+
+export function isPreviewViewportReady(input: {
+  readonly setting: PreviewViewportSetting;
+  readonly appliedSettingKey: string | null;
+  readonly declaredViewport: PreviewRenderedViewportSize | null;
+  readonly renderedViewport: PreviewRenderedViewportSize | null;
+}): boolean {
+  const { setting, appliedSettingKey, declaredViewport, renderedViewport } = input;
+  if (
+    appliedSettingKey !== browserViewportSettingKey(setting) ||
+    declaredViewport === null ||
+    renderedViewport === null
+  ) {
+    return false;
+  }
+
+  const expectedViewport =
+    setting._tag === "fill" ? declaredViewport : { width: setting.width, height: setting.height };
+  if (
+    setting._tag !== "fill" &&
+    (declaredViewport.width !== expectedViewport.width ||
+      declaredViewport.height !== expectedViewport.height)
+  ) {
+    return false;
+  }
+
+  // Electron rounds CSS pixels through the guest's fractional zoom/device scale,
+  // so a successfully applied fixed viewport can measure one pixel either way.
+  const tolerance = 1;
+  return (
+    Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance &&
+    Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance
+  );
+}
diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts
index 2a82f627574..9bc2cc84c37 100644
--- a/apps/web/src/components/preview/usePreviewSession.ts
+++ b/apps/web/src/components/preview/usePreviewSession.ts
@@ -11,6 +11,7 @@ import {
   applyPreviewServerEvent,
   applyPreviewServerSnapshot,
   readThreadPreviewState,
+  reconcilePreviewServerSessions,
 } from "~/previewStateStore";
 import { previewEnvironment } from "~/state/preview";
 
@@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
       if (result.value.sessions.length > 0) {
         recoveringUrl = null;
         recoveryId += 1;
-        for (const snapshot of result.value.sessions) {
-          applyPreviewServerSnapshot(threadRef, snapshot);
-        }
+        reconcilePreviewServerSessions(threadRef, result.value.sessions);
         return;
       }
 
diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts
index d2bf2e7c260..c908e23f9ba 100644
--- a/apps/web/src/previewStateStore.test.ts
+++ b/apps/web/src/previewStateStore.test.ts
@@ -11,10 +11,12 @@ import {
   cancelPreviewSessionClose,
   previewStateAtom,
   readThreadPreviewState,
+  reconcilePreviewServerSessions,
   rememberPreviewUrl,
   removePreviewThread,
   resetPreviewStateForTests,
   setActivePreviewTab,
+  updatePreviewServerSnapshot,
 } from "./previewStateStore";
 
 const environmentId = "env-1" as EnvironmentId;
@@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => {
     }
   });
 
+  it("resized event updates tab viewport without changing the active tab", () => {
+    const active = makeSnapshot({ tabId: "tab_a" });
+    const background = makeSnapshot({ tabId: "tab_b" });
+    applyPreviewServerSnapshot(ref, background);
+    applyPreviewServerSnapshot(ref, active);
+
+    applyPreviewServerEvent(ref, {
+      type: "resized",
+      threadId: "thread-1",
+      tabId: background.tabId,
+      createdAt: "2026-01-01T00:00:01.000Z",
+      snapshot: {
+        ...background,
+        viewport: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
+        updatedAt: "2026-01-01T00:00:01.000Z",
+      },
+    });
+
+    const state = readThreadPreviewState(ref);
+    expect(state.activeTabId).toBe(active.tabId);
+    expect(state.sessions[background.tabId]?.viewport).toEqual({
+      _tag: "preset",
+      presetId: "pixel-8",
+      width: 412,
+      height: 915,
+    });
+  });
+
   it("failed event flips the snapshot to LoadFailed when tabId matches", () => {
     const snapshot = makeSnapshot();
     applyPreviewServerEvent(ref, {
@@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => {
     expect(state.desktopOverlay?.canGoBack).toBe(true);
   });
 
+  it("updates a background snapshot without changing the active tab", () => {
+    const background = makeSnapshot({ tabId: "tab_a" });
+    const active = makeSnapshot({
+      tabId: "tab_b",
+      updatedAt: "2026-01-01T00:00:01.000Z",
+    });
+    applyPreviewServerSnapshot(ref, background);
+    applyPreviewServerSnapshot(ref, active);
+
+    const resized = {
+      ...background,
+      viewport: { _tag: "freeform" as const, width: 900, height: 700 },
+      updatedAt: "2026-01-01T00:00:02.000Z",
+    };
+    updatePreviewServerSnapshot(ref, resized);
+
+    const state = readThreadPreviewState(ref);
+    expect(state.activeTabId).toBe(active.tabId);
+    expect(state.snapshot?.tabId).toBe(active.tabId);
+    expect(state.sessions[background.tabId]).toEqual(resized);
+  });
+
+  it("reconciles an authoritative session list without focusing a background tab", () => {
+    const active = makeSnapshot({ tabId: "tab_a" });
+    const stale = makeSnapshot({
+      tabId: "tab_stale",
+      updatedAt: "2026-01-01T00:00:01.000Z",
+    });
+    applyPreviewServerSnapshot(ref, stale);
+    applyPreviewServerSnapshot(ref, active);
+    applyPreviewDesktopState(ref, stale.tabId, {
+      canGoBack: false,
+      canGoForward: false,
+      loading: false,
+      zoomFactor: 1,
+      controller: "none",
+    });
+
+    reconcilePreviewServerSessions(ref, [active]);
+
+    const state = readThreadPreviewState(ref);
+    expect(Object.keys(state.sessions)).toEqual([active.tabId]);
+    expect(state.activeTabId).toBe(active.tabId);
+    expect(state.snapshot).toEqual(active);
+    expect(state.desktopByTabId[stale.tabId]).toBeUndefined();
+  });
+
+  it("clears stale sessions when an authoritative list is empty", () => {
+    applyPreviewServerSnapshot(ref, makeSnapshot());
+
+    reconcilePreviewServerSessions(ref, []);
+
+    const state = readThreadPreviewState(ref);
+    expect(state.sessions).toEqual({});
+    expect(state.activeTabId).toBeNull();
+    expect(state.snapshot).toBeNull();
+  });
+
   it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
     const snapshot = makeSnapshot();
     applyPreviewServerSnapshot(ref, snapshot);
diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts
index 7f8f8576130..e44a464ed24 100644
--- a/apps/web/src/previewStateStore.ts
+++ b/apps/web/src/previewStateStore.ts
@@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {
   return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
 };
 
+const rememberSnapshotUrl = (
+  recentlySeenUrls: string[],
+  snapshot: PreviewSessionSnapshot,
+): string[] =>
+  snapshot.navStatus._tag === "Idle"
+    ? recentlySeenUrls
+    : dedupeRecentUrls(recentlySeenUrls, snapshot.navStatus.url);
+
+const latestSnapshot = (
+  sessions: Record,
+): PreviewSessionSnapshot | null =>
+  Object.values(sessions)
+    .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
+    .at(-1) ?? null;
+
 const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {
   if (!current.sessions[tabId]) return current;
   const { [tabId]: _closed, ...sessions } = current.sessions;
   const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
-  const nextSnapshot =
-    Object.values(sessions)
-      .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
-      .at(-1) ?? null;
+  const nextSnapshot = latestSnapshot(sessions);
   const activeTabId =
     current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;
   const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
@@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
   updateThreadPreviewState(ref, (current) => {
     switch (event.type) {
       case "opened":
-      case "navigated": {
+      case "navigated":
+      case "resized": {
         const snapshot = event.snapshot;
         if (current.suppressedTabIds.has(snapshot.tabId)) return current;
         const recentlySeenUrls =
@@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot(
     if (current.suppressedTabIds.has(snapshot.tabId)) return current;
     const existing = current.sessions[snapshot.tabId];
     if (existing && existing.updatedAt > snapshot.updatedAt) return current;
-    const recentlySeenUrls =
-      snapshot.navStatus._tag !== "Idle"
-        ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
-        : current.recentlySeenUrls;
+    const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot);
     return {
       ...current,
       snapshot,
@@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot(
   });
 }
 
+/**
+ * Merge a server mutation without changing which tab the user is viewing.
+ *
+ * Commands such as resize can target background tabs. Their response is
+ * authoritative for that tab, but it is not a request to focus the tab.
+ */
+export function updatePreviewServerSnapshot(
+  ref: ScopedThreadRef,
+  snapshot: PreviewSessionSnapshot,
+): void {
+  updateThreadPreviewState(ref, (current) => {
+    if (current.suppressedTabIds.has(snapshot.tabId)) return current;
+    const existing = current.sessions[snapshot.tabId];
+    if (existing && existing.updatedAt > snapshot.updatedAt) return current;
+    const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
+    const activeTabId =
+      current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId;
+    const activeSnapshot = sessions[activeTabId] ?? snapshot;
+    return {
+      ...current,
+      sessions,
+      activeTabId,
+      snapshot: activeSnapshot,
+      desktopOverlay: current.desktopByTabId[activeTabId] ?? null,
+      recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot),
+    };
+  });
+}
+
+/**
+ * Replace the local session index from an authoritative preview.list result.
+ * Missing tabs are removed while the current active tab is preserved whenever
+ * it still exists in the server result.
+ */
+export function reconcilePreviewServerSessions(
+  ref: ScopedThreadRef,
+  snapshots: ReadonlyArray,
+): void {
+  updateThreadPreviewState(ref, (current) => {
+    const sessions: Record = {};
+    let recentlySeenUrls = current.recentlySeenUrls;
+    for (const snapshot of snapshots) {
+      if (current.suppressedTabIds.has(snapshot.tabId)) continue;
+      const existing = current.sessions[snapshot.tabId];
+      const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;
+      sessions[next.tabId] = next;
+      recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);
+    }
+
+    const fallback = latestSnapshot(sessions);
+    const activeTabId =
+      current.activeTabId && sessions[current.activeTabId]
+        ? current.activeTabId
+        : (fallback?.tabId ?? null);
+    const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;
+    const desktopByTabId = Object.fromEntries(
+      Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
+    );
+    return {
+      ...current,
+      sessions,
+      activeTabId,
+      snapshot,
+      desktopByTabId,
+      desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,
+      recentlySeenUrls,
+    };
+  });
+}
+
 export function applyPreviewDesktopState(
   ref: ScopedThreadRef,
   tabId: string,
diff --git a/packages/client-runtime/src/state/preview.test.ts b/packages/client-runtime/src/state/preview.test.ts
new file mode 100644
index 00000000000..7a83bccde98
--- /dev/null
+++ b/packages/client-runtime/src/state/preview.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts";
+
+describe("preview state commands", () => {
+  it("keeps focus updates from replacement host connections independent", () => {
+    const first = previewAutomationHostFocusConcurrencyKey({
+      environmentId: "environment-1",
+      input: { clientId: "client-1", connectionId: "connection-1" },
+    });
+    const replacement = previewAutomationHostFocusConcurrencyKey({
+      environmentId: "environment-1",
+      input: { clientId: "client-1", connectionId: "connection-2" },
+    });
+
+    expect(first).not.toBe(replacement);
+  });
+});
diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts
index 800fc5efac1..f9469ee96a5 100644
--- a/packages/client-runtime/src/state/preview.ts
+++ b/packages/client-runtime/src/state/preview.ts
@@ -9,6 +9,14 @@ import {
   createEnvironmentRpcSubscriptionAtomFamily,
 } from "./runtime.ts";
 
+export const previewAutomationHostFocusConcurrencyKey = (value: {
+  readonly environmentId: string;
+  readonly input: {
+    readonly clientId: string;
+    readonly connectionId: string;
+  };
+}): string => JSON.stringify([value.environmentId, value.input.clientId, value.input.connectionId]);
+
 export function createPreviewEnvironmentAtoms(
   runtime: Atom.AtomRuntime,
 ) {
@@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms(
       scheduler: lifecycleScheduler,
       concurrency: lifecycleConcurrency,
     }),
+    resize: createEnvironmentRpcCommand(runtime, {
+      label: "environment-data:preview:resize",
+      tag: WS_METHODS.previewResize,
+      scheduler: lifecycleScheduler,
+      concurrency: lifecycleConcurrency,
+    }),
     refresh: createEnvironmentRpcCommand(runtime, {
       label: "environment-data:preview:refresh",
       tag: WS_METHODS.previewRefresh,
@@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms(
       scheduler: automationScheduler,
       concurrency: {
         mode: "singleFlight",
-        key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]),
-      },
-    }),
-    reportAutomationOwner: createEnvironmentRpcCommand(runtime, {
-      label: "environment-data:preview:automation-report-owner",
-      tag: WS_METHODS.previewAutomationReportOwner,
-      scheduler: automationScheduler,
-      concurrency: {
-        mode: "serial",
-        key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),
+        key: ({ environmentId, input }) =>
+          JSON.stringify([environmentId, input.connectionId, input.requestId]),
       },
     }),
-    clearAutomationOwner: createEnvironmentRpcCommand(runtime, {
-      label: "environment-data:preview:automation-clear-owner",
-      tag: WS_METHODS.previewAutomationClearOwner,
+    focusAutomationHost: createEnvironmentRpcCommand(runtime, {
+      label: "environment-data:preview:automation-focus-host",
+      tag: WS_METHODS.previewAutomationFocusHost,
       scheduler: automationScheduler,
       concurrency: {
-        mode: "serial",
-        key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),
+        mode: "latest",
+        key: previewAutomationHostFocusConcurrencyKey,
       },
     }),
   };
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 9d6ed04c286..6a8dce87ca3 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -69,19 +69,20 @@ import type {
   PreviewOpenInput,
   PreviewRefreshInput,
   PreviewReportStatusInput,
+  PreviewResizeInput,
   PreviewSessionSnapshot,
 } from "./preview.ts";
 import {
   PreviewAutomationClickInput,
   PreviewAutomationEvaluateInput,
-  PreviewAutomationOwner,
-  PreviewAutomationOwnerIdentity,
+  PreviewAutomationHost,
+  PreviewAutomationHostFocus,
   PreviewAutomationPressInput,
-  PreviewAutomationRequest,
   PreviewAutomationResponse,
   PreviewAutomationScrollInput,
   PreviewAutomationSnapshot,
   PreviewAutomationStatus,
+  PreviewAutomationStreamEvent,
   PreviewAutomationTypeInput,
   PreviewAutomationWaitForInput,
 } from "./previewAutomation.ts";
@@ -1154,19 +1155,19 @@ export interface EnvironmentApi {
   preview: {
     open: (input: typeof PreviewOpenInput.Encoded) => Promise;
     navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise;
+    resize: (input: typeof PreviewResizeInput.Encoded) => Promise;
     refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise;
     close: (input: typeof PreviewCloseInput.Encoded) => Promise;
     list: (input: typeof PreviewListInput.Encoded) => Promise;
     reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise;
     automation: {
       connect: (
-        input: { clientId: string },
-        callback: (request: PreviewAutomationRequest) => void,
+        input: PreviewAutomationHost,
+        callback: (event: PreviewAutomationStreamEvent) => void,
         options?: { onResubscribe?: () => void },
       ) => () => void;
       respond: (response: PreviewAutomationResponse) => Promise;
-      reportOwner: (owner: PreviewAutomationOwner) => Promise;
-      clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise;
+      focusHost: (input: PreviewAutomationHostFocus) => Promise;
     };
     onEvent: (
       callback: (event: PreviewEvent) => void,
diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts
index e4e6757b441..80044793873 100644
--- a/packages/contracts/src/preview.test.ts
+++ b/packages/contracts/src/preview.test.ts
@@ -6,12 +6,26 @@ import {
   PreviewEvent,
   PreviewNavStatus,
   PreviewSessionSnapshot,
+  PreviewViewportSetting,
 } from "./preview.ts";
+import {
+  PreviewAutomationHost,
+  PreviewAutomationError,
+  PreviewAutomationResizeInput,
+  PreviewAutomationResizeResult,
+  PreviewAutomationStatus,
+} from "./previewAutomation.ts";
 
 const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
 const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
 const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
 const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
+const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
+const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
+const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult);
+const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
+const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
+const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
 
 describe("PreviewNavStatus", () => {
   it("decodes Idle", () => {
@@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => {
   });
 });
 
+describe("PreviewViewportSetting", () => {
+  it("decodes fill, freeform, and preset modes", () => {
+    expect(decodeViewport({ _tag: "fill" })).toEqual({ _tag: "fill" });
+    expect(decodeViewport({ _tag: "freeform", width: 1024, height: 768 })).toEqual({
+      _tag: "freeform",
+      width: 1024,
+      height: 768,
+    });
+    expect(
+      decodeViewport({
+        _tag: "preset",
+        presetId: "iphone-15-pro",
+        width: 393,
+        height: 852,
+      }),
+    ).toMatchObject({ _tag: "preset", presetId: "iphone-15-pro" });
+  });
+
+  it("rejects unsafe dimensions and oversized render areas", () => {
+    expect(() => decodeViewport({ _tag: "freeform", width: 100, height: 800 })).toThrow();
+    expect(() => decodeViewport({ _tag: "freeform", width: 3840, height: 3840 })).toThrow();
+  });
+});
+
+describe("PreviewAutomationResizeInput", () => {
+  it("requires fields that match the selected mode", () => {
+    expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" });
+    expect(
+      decodeResizeInput({ mode: "preset", preset: "pixel-7", orientation: "landscape" }),
+    ).toMatchObject({ mode: "preset", preset: "pixel-7" });
+    expect(() => decodeResizeInput({ mode: "preset", preset: "pixel-8" })).toThrow();
+    expect(() => decodeResizeInput({ mode: "freeform", width: 1024 })).toThrow();
+    expect(() => decodeResizeInput({ mode: "fill", width: 1024, height: 768 })).toThrow();
+  });
+
+  it("allows fill-mode measurements below the minimum selectable fixed size", () => {
+    expect(
+      decodeResizeResult({
+        tabId: "preview-t",
+        setting: { _tag: "fill" },
+        viewport: { width: 180, height: 120 },
+      }).viewport,
+    ).toEqual({ width: 180, height: 120 });
+  });
+});
+
+describe("PreviewAutomationHost", () => {
+  it("accepts legacy hosts and current operation advertisements", () => {
+    expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({
+      clientId: "legacy",
+      environmentId: "environment-1",
+    });
+    expect(
+      decodeAutomationHost({
+        clientId: "current",
+        environmentId: "environment-1",
+        supportedOperations: ["status", "resize"],
+      }).supportedOperations,
+    ).toEqual(["status", "resize"]);
+  });
+});
+
+describe("PreviewAutomationError", () => {
+  it("preserves a typed non-editable target failure", () => {
+    const error = decodeAutomationError({
+      _tag: "PreviewAutomationTargetNotEditableError",
+      operation: "type",
+      environmentId: "environment-1",
+      threadId: "thread-1",
+      providerSessionId: "provider-session-1",
+      providerInstanceId: "codex",
+      clientId: "client-1",
+      connectionId: "connection-1",
+      requestId: "request-1",
+      tabId: "tab-1",
+      timeoutMs: 1_000,
+      remoteTag: "PreviewAutomationTargetNotEditableError",
+      remoteMessageLength: 12,
+      cause: {},
+      selectorKind: "focused-element",
+    });
+
+    expect(error._tag).toBe("PreviewAutomationTargetNotEditableError");
+    if (error._tag === "PreviewAutomationTargetNotEditableError") {
+      expect(error.selectorKind).toBe("focused-element");
+      expect(error.message).toBe("Preview automation type requires an editable focused element.");
+    }
+  });
+});
+
+describe("PreviewAutomationStatus", () => {
+  it("accepts old hosts without viewport data and exposes it from current hosts", () => {
+    const base = {
+      available: true,
+      visible: false,
+      tabId: "preview-t",
+      url: "https://example.com",
+      title: "Example",
+      loading: false,
+    };
+    expect(decodeAutomationStatus(base)).toEqual(base);
+    expect(
+      decodeAutomationStatus({
+        ...base,
+        viewportSetting: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
+        viewport: { width: 412, height: 915 },
+      }).viewport,
+    ).toEqual({ width: 412, height: 915 });
+  });
+});
+
 describe("PreviewEvent", () => {
   it("decodes opened", () => {
     const event = decodePreviewEvent({
@@ -104,6 +229,25 @@ describe("PreviewEvent", () => {
     }
   });
 
+  it("decodes resized with tab viewport state", () => {
+    const event = decodePreviewEvent({
+      type: "resized",
+      threadId: "t",
+      tabId: "preview-t",
+      createdAt: "2026-01-01T00:00:00.000Z",
+      snapshot: {
+        threadId: "t",
+        tabId: "preview-t",
+        navStatus: { _tag: "Idle" },
+        canGoBack: false,
+        canGoForward: false,
+        viewport: { _tag: "freeform", width: 1024, height: 768 },
+        updatedAt: "2026-01-01T00:00:00.000Z",
+      },
+    });
+    expect(event.type).toBe("resized");
+  });
+
   it("decodes closed without snapshot", () => {
     const event = decodePreviewEvent({
       type: "closed",
diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts
index 457e66ee07f..c00f878bcbe 100644
--- a/packages/contracts/src/preview.ts
+++ b/packages/contracts/src/preview.ts
@@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512));
 export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
 export type PreviewTabId = typeof PreviewTabId.Type;
 
+export const PREVIEW_VIEWPORT_MIN_DIMENSION = 240;
+export const PREVIEW_VIEWPORT_MAX_DIMENSION = 3840;
+export const PREVIEW_VIEWPORT_MAX_AREA = 3840 * 2160;
+
+const PreviewViewportDimension = Schema.Int.check(
+  Schema.isBetween({
+    minimum: PREVIEW_VIEWPORT_MIN_DIMENSION,
+    maximum: PREVIEW_VIEWPORT_MAX_DIMENSION,
+  }),
+);
+
+const viewportAreaFilter = Schema.makeFilter(
+  ({ width, height }: { readonly width: number; readonly height: number }) =>
+    width * height <= PREVIEW_VIEWPORT_MAX_AREA ||
+    `Viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`,
+);
+
+export const PreviewViewportSize = Schema.Struct({
+  width: PreviewViewportDimension,
+  height: PreviewViewportDimension,
+}).check(viewportAreaFilter);
+export type PreviewViewportSize = typeof PreviewViewportSize.Type;
+
+/**
+ * The page's measured viewport can be smaller than the minimum selectable
+ * fixed size while fill mode follows a narrow panel. Keep measurement
+ * validation separate from the stricter user-selectable size constraints.
+ */
+export const PreviewRenderedViewportSize = Schema.Struct({
+  width: Schema.Int.check(Schema.isGreaterThan(0)),
+  height: Schema.Int.check(Schema.isGreaterThan(0)),
+});
+export type PreviewRenderedViewportSize = typeof PreviewRenderedViewportSize.Type;
+
+export const PREVIEW_VIEWPORT_PRESET_IDS = [
+  "iphone-se",
+  "iphone-xr",
+  "iphone-12-pro",
+  "iphone-14-pro-max",
+  "pixel-7",
+  "samsung-galaxy-s8-plus",
+  "samsung-galaxy-s20-ultra",
+  "ipad-mini",
+  "ipad-air",
+  "ipad-pro",
+  "surface-pro-7",
+  "surface-duo",
+  "galaxy-z-fold-5",
+  "asus-zenbook-fold",
+  "samsung-galaxy-a51-71",
+  "nest-hub",
+  "nest-hub-max",
+] as const;
+
+export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS);
+export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type;
+
+/**
+ * Preset IDs shipped before the Chrome-compatible catalog. Existing sessions
+ * can still reconnect with these values, but new resize requests only expose
+ * PREVIEW_VIEWPORT_PRESET_IDS.
+ */
+const LEGACY_PREVIEW_VIEWPORT_PRESET_IDS = [
+  "desktop-1920x1080",
+  "desktop-1440x900",
+  "laptop-1366x768",
+  "laptop-1280x800",
+  "ipad-pro-11",
+  "iphone-15-pro",
+  "pixel-8",
+  "galaxy-s24",
+] as const;
+
+const StoredPreviewViewportPresetId = Schema.Literals([
+  ...PREVIEW_VIEWPORT_PRESET_IDS,
+  ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS,
+]);
+
+export const PreviewViewportSetting = Schema.Union([
+  Schema.TaggedStruct("fill", {}),
+  Schema.TaggedStruct("freeform", {
+    ...PreviewViewportSize.fields,
+  }).check(viewportAreaFilter),
+  Schema.TaggedStruct("preset", {
+    ...PreviewViewportSize.fields,
+    presetId: StoredPreviewViewportPresetId,
+  }).check(viewportAreaFilter),
+]);
+export type PreviewViewportSetting = typeof PreviewViewportSetting.Type;
+
+export const FILL_PREVIEW_VIEWPORT = {
+  _tag: "fill",
+} as const satisfies PreviewViewportSetting;
+
 export const PreviewNavStatus = Schema.Union([
   Schema.TaggedStruct("Idle", {}),
   Schema.TaggedStruct("Loading", {
@@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({
   navStatus: PreviewNavStatus,
   canGoBack: Schema.Boolean,
   canGoForward: Schema.Boolean,
+  /** Missing snapshots from older servers are treated as fill-panel mode. */
+  viewport: Schema.optional(PreviewViewportSetting),
   updatedAt: Schema.String,
 });
 export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
@@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({
 });
 export type PreviewRefreshInput = typeof PreviewRefreshInput.Type;
 
+export const PreviewResizeInput = Schema.Struct({
+  threadId: ThreadId,
+  tabId: PreviewTabId,
+  viewport: PreviewViewportSetting,
+});
+export type PreviewResizeInput = typeof PreviewResizeInput.Type;
+
 export const PreviewCloseInput = Schema.Struct({
   threadId: ThreadId,
   tabId: Schema.optional(PreviewTabId),
@@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({
   snapshot: PreviewSessionSnapshot,
 });
 
+const PreviewResizedEvent = Schema.Struct({
+  ...PreviewEventBaseSchema.fields,
+  type: Schema.Literal("resized"),
+  snapshot: PreviewSessionSnapshot,
+});
+
 const PreviewFailedEvent = Schema.Struct({
   ...PreviewEventBaseSchema.fields,
   type: Schema.Literal("failed"),
@@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({
 export const PreviewEvent = Schema.Union([
   PreviewOpenedEvent,
   PreviewNavigatedEvent,
+  PreviewResizedEvent,
   PreviewFailedEvent,
   PreviewClosedEvent,
 ]);
diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts
index 118fb892737..47dc7fc249e 100644
--- a/packages/contracts/src/previewAutomation.ts
+++ b/packages/contracts/src/previewAutomation.ts
@@ -1,7 +1,14 @@
 import { Schema } from "effect";
 
 import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
-import { PreviewTabId } from "./preview.ts";
+import {
+  PREVIEW_VIEWPORT_MAX_AREA,
+  PreviewRenderedViewportSize,
+  PreviewTabId,
+  PreviewViewportPresetId,
+  PreviewViewportSetting,
+  PreviewViewportSize,
+} from "./preview.ts";
 import { ProviderInstanceId } from "./providerInstance.ts";
 
 const BoundedUrl = Schema.String.check(Schema.isTrimmed())
@@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional(
     .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }),
 ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." });
 
-export const PreviewAutomationOperation = Schema.Literals([
+/** Operations understood by desktop hosts predating viewport resizing. */
+export const PREVIEW_AUTOMATION_V1_OPERATIONS = [
   "status",
   "open",
   "navigate",
@@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([
   "waitFor",
   "recordingStart",
   "recordingStop",
-]);
+] as const;
+
+/** Advertised by current desktop hosts for mixed-version routing. */
+export const PREVIEW_AUTOMATION_OPERATIONS = [
+  ...PREVIEW_AUTOMATION_V1_OPERATIONS,
+  "resize",
+] as const;
+
+export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);
 export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
 
 export const PreviewAutomationStatus = Schema.Struct({
@@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({
   url: Schema.NullOr(Schema.String),
   title: Schema.NullOr(Schema.String),
   loading: Schema.Boolean,
+  /** Optional for compatibility with desktop hosts predating viewport sizing. */
+  viewportSetting: Schema.optional(PreviewViewportSetting),
+  /** Measured guest-page viewport in CSS pixels when a webview is ready. */
+  viewport: Schema.optional(PreviewRenderedViewportSize),
 });
 export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
 
@@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({
   });
 export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
 
+export const PreviewAutomationResizeInput = Schema.Struct({
+  mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({
+    description:
+      "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.",
+  }),
+  preset: Schema.optional(
+    PreviewViewportPresetId.annotate({
+      description: "Named viewport from Chrome DevTools' standard device catalog.",
+    }),
+  ).annotate({
+    description: "Named device size. Required only when mode is preset.",
+  }),
+  width: Schema.optional(
+    PreviewViewportSize.fields.width.annotate({
+      description: "Freeform viewport width in CSS pixels. Required only in freeform mode.",
+    }),
+  ).annotate({
+    description: "Freeform viewport width in CSS pixels. Required only in freeform mode.",
+  }),
+  height: Schema.optional(
+    PreviewViewportSize.fields.height.annotate({
+      description: "Freeform viewport height in CSS pixels. Required only in freeform mode.",
+    }),
+  ).annotate({
+    description: "Freeform viewport height in CSS pixels. Required only in freeform mode.",
+  }),
+  orientation: Schema.optional(
+    Schema.Literals(["portrait", "landscape"]).annotate({
+      description:
+        "Orientation for a fixed device preset. Defaults to the preset's native orientation.",
+    }),
+  ).annotate({
+    description:
+      "Orientation for a named device preset. It is not accepted in fill or freeform mode.",
+  }),
+  timeoutMs: OptionalTimeoutMs,
+})
+  .check(
+    Schema.makeFilter((input) => {
+      const hasPreset = input.preset !== undefined;
+      const hasWidth = input.width !== undefined;
+      const hasHeight = input.height !== undefined;
+      if (hasWidth !== hasHeight) return "Custom dimensions require both width and height.";
+      if (input.mode === "fill") {
+        return !hasPreset && !hasWidth && input.orientation === undefined
+          ? true
+          : "Fill mode does not accept a preset, dimensions, or orientation.";
+      }
+      if (input.mode === "freeform") {
+        if (!hasWidth || !hasHeight || hasPreset || input.orientation !== undefined) {
+          return "Freeform mode requires width and height and does not accept a preset or orientation.";
+        }
+      } else if (!hasPreset || hasWidth || hasHeight) {
+        return "Preset mode requires a preset and does not accept custom dimensions.";
+      }
+      if (hasWidth && hasHeight && input.width! * input.height! > PREVIEW_VIEWPORT_MAX_AREA) {
+        return `Custom viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`;
+      }
+      return true;
+    }),
+  )
+  .annotate({
+    description:
+      "Sets the active browser tab to fill-panel, independently resizable freeform, or named device-preset sizing.",
+  });
+export type PreviewAutomationResizeInput = typeof PreviewAutomationResizeInput.Type;
+
+export const PreviewAutomationResizeResult = Schema.Struct({
+  tabId: PreviewTabId,
+  setting: PreviewViewportSetting,
+  viewport: PreviewRenderedViewportSize,
+});
+export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type;
+
 const Locator = TrimmedNonEmptyString.annotate({
   description:
     "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",
@@ -411,21 +505,33 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({
 });
 export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type;
 
-export const PreviewAutomationOwnerIdentity = Schema.Struct({
-  clientId: TrimmedNonEmptyString,
+export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
+export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type;
+export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
+export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type;
+
+export const PreviewAutomationHostIdentity = Schema.Struct({
+  clientId: PreviewAutomationClientId,
   environmentId: EnvironmentId,
-  threadId: ThreadId,
 });
-export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdentity.Type;
+export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity.Type;
+
+export const PreviewAutomationHost = Schema.Struct({
+  ...PreviewAutomationHostIdentity.fields,
+  /**
+   * Missing means the pre-capability-negotiation V1 operation set. This lets
+   * a newer server safely coexist with an older desktop during rollout.
+   */
+  supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)),
+});
+export type PreviewAutomationHost = typeof PreviewAutomationHost.Type;
 
-export const PreviewAutomationOwner = Schema.Struct({
-  ...PreviewAutomationOwnerIdentity.fields,
-  tabId: Schema.NullOr(PreviewTabId),
-  visible: Schema.Boolean,
-  supportsAutomation: Schema.Boolean,
-  focusedAt: Schema.String,
+export const PreviewAutomationHostFocus = Schema.Struct({
+  ...PreviewAutomationHostIdentity.fields,
+  connectionId: PreviewAutomationConnectionId,
+  focused: Schema.Boolean,
 });
-export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type;
+export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;
 
 export const PreviewAutomationRequest = Schema.Struct({
   requestId: TrimmedNonEmptyString,
@@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({
 });
 export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type;
 
+export const PreviewAutomationStreamEvent = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("connected"),
+    connectionId: PreviewAutomationConnectionId,
+  }),
+  Schema.Struct({
+    type: Schema.Literal("request"),
+    connectionId: PreviewAutomationConnectionId,
+    request: PreviewAutomationRequest,
+  }),
+]);
+export type PreviewAutomationStreamEvent = typeof PreviewAutomationStreamEvent.Type;
+
 export const PreviewAutomationResponse = Schema.Struct({
+  clientId: PreviewAutomationClientId,
+  connectionId: PreviewAutomationConnectionId,
   requestId: TrimmedNonEmptyString,
   ok: Schema.Boolean,
   result: Schema.optional(Schema.Unknown),
@@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = {
 const PreviewAutomationRequestErrorFields = {
   ...PreviewAutomationScopeErrorFields,
   clientId: TrimmedNonEmptyString,
+  connectionId: PreviewAutomationConnectionId,
   requestId: TrimmedNonEmptyString,
   tabId: Schema.optional(PreviewTabId),
   timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
@@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = {
   cause: Schema.optional(Schema.Defect()),
 };
 
-export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass()(
-  "PreviewAutomationNoFocusedOwnerError",
+export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationNoAvailableHostError",
   {
     ...PreviewAutomationScopeErrorFields,
     clientId: Schema.optional(TrimmedNonEmptyString),
+    connectionId: Schema.optional(PreviewAutomationConnectionId),
     requestId: Schema.optional(TrimmedNonEmptyString),
     tabId: Schema.optional(PreviewTabId),
     timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
@@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas
   },
 ) {
   override get message(): string {
-    const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`;
+    const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`;
     return summary;
   }
 }
@@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla
   }
 }
 
+export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationTargetNotEditableError",
+  {
+    ...PreviewAutomationRequestErrorFields,
+    ...PreviewAutomationRemoteDiagnosticFields,
+    selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])),
+    selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
+  },
+) {
+  override get message(): string {
+    if (this.selectorKind === "focused-element") {
+      return `Preview automation ${this.operation} requires an editable focused element.`;
+    }
+    if (this.selectorKind !== undefined && this.selectorLength !== undefined) {
+      return `Preview automation ${this.operation} requires an editable ${this.selectorKind} (${this.selectorLength} characters).`;
+    }
+    return `Preview automation ${this.operation} requires an editable target.`;
+  }
+}
+
 export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()(
   "PreviewAutomationResultTooLargeError",
   {
@@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas
   }
 }
 
-export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass()(
-  "PreviewAutomationHostNotConnectedError",
-  {
-    ...PreviewAutomationScopeErrorFields,
-    clientId: TrimmedNonEmptyString,
-  },
-) {
-  override get message(): string {
-    return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`;
-  }
-}
-
 export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass()(
   "PreviewAutomationClientDisconnectedError",
   PreviewAutomationRequestErrorFields,
@@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC
 
 export const PreviewAutomationError = Schema.Union([
   PreviewAutomationUnavailableError,
-  PreviewAutomationNoFocusedOwnerError,
+  PreviewAutomationNoAvailableHostError,
   PreviewAutomationUnsupportedClientError,
   PreviewAutomationTabNotFoundError,
   PreviewAutomationTimeoutError,
   PreviewAutomationControlInterruptedError,
   PreviewAutomationExecutionError,
   PreviewAutomationInvalidSelectorError,
+  PreviewAutomationTargetNotEditableError,
   PreviewAutomationResultTooLargeError,
-  PreviewAutomationHostNotConnectedError,
   PreviewAutomationClientDisconnectedError,
   PreviewAutomationRequestQueueClosedError,
   PreviewAutomationRemoteUnavailableError,
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index a2a8e9106aa..48c5d9a774d 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -103,14 +103,15 @@ import {
   PreviewOpenInput,
   PreviewRefreshInput,
   PreviewReportStatusInput,
+  PreviewResizeInput,
   PreviewSessionSnapshot,
 } from "./preview.ts";
 import {
   PreviewAutomationError,
-  PreviewAutomationOwner,
-  PreviewAutomationOwnerIdentity,
-  PreviewAutomationRequest,
+  PreviewAutomationHost,
+  PreviewAutomationHostFocus,
   PreviewAutomationResponse,
+  PreviewAutomationStreamEvent,
 } from "./previewAutomation.ts";
 import {
   ServerConfigStreamEvent,
@@ -190,14 +191,14 @@ export const WS_METHODS = {
   // Preview methods
   previewOpen: "preview.open",
   previewNavigate: "preview.navigate",
+  previewResize: "preview.resize",
   previewRefresh: "preview.refresh",
   previewClose: "preview.close",
   previewList: "preview.list",
   previewReportStatus: "preview.reportStatus",
   previewAutomationConnect: "previewAutomation.connect",
   previewAutomationRespond: "previewAutomation.respond",
-  previewAutomationReportOwner: "previewAutomation.reportOwner",
-  previewAutomationClearOwner: "previewAutomation.clearOwner",
+  previewAutomationFocusHost: "previewAutomation.focusHost",
 
   // Server meta
   serverGetConfig: "server.getConfig",
@@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, {
   error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
 });
 
+export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {
+  payload: PreviewResizeInput,
+  success: PreviewSessionSnapshot,
+  error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
+});
+
 export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
   payload: PreviewRefreshInput,
   error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
@@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,
 });
 
 export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
-  payload: PreviewAutomationOwner,
-  success: PreviewAutomationRequest,
+  payload: PreviewAutomationHost,
+  success: PreviewAutomationStreamEvent,
   error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
   stream: true,
 });
@@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati
   error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
 });
 
-export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, {
-  payload: PreviewAutomationOwner,
-  error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
-});
-
-export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, {
-  payload: PreviewAutomationOwnerIdentity,
-  error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
+export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, {
+  payload: PreviewAutomationHostFocus,
+  error: EnvironmentAuthorizationError,
 });
 
 export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
@@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make(
   WsSubscribeTerminalMetadataRpc,
   WsPreviewOpenRpc,
   WsPreviewNavigateRpc,
+  WsPreviewResizeRpc,
   WsPreviewRefreshRpc,
   WsPreviewCloseRpc,
   WsPreviewListRpc,
   WsPreviewReportStatusRpc,
   WsPreviewAutomationConnectRpc,
   WsPreviewAutomationRespondRpc,
-  WsPreviewAutomationReportOwnerRpc,
-  WsPreviewAutomationClearOwnerRpc,
+  WsPreviewAutomationFocusHostRpc,
   WsSubscribePreviewEventsRpc,
   WsSubscribeDiscoveredLocalServersRpc,
   WsSubscribeServerConfigRpc,
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 5c56aaf6997..f0eab2c8b34 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -163,6 +163,10 @@
       "types": "./src/preview.ts",
       "import": "./src/preview.ts"
     },
+    "./previewViewport": {
+      "types": "./src/previewViewport.ts",
+      "import": "./src/previewViewport.ts"
+    },
     "./filePreview": {
       "types": "./src/filePreview.ts",
       "import": "./src/filePreview.ts"
diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts
new file mode 100644
index 00000000000..3222e90d7be
--- /dev/null
+++ b/packages/shared/src/previewViewport.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+  PREVIEW_VIEWPORT_PRESETS,
+  previewViewportLabel,
+  previewViewportPresetOrientation,
+  resolvePreviewViewport,
+} from "./previewViewport.ts";
+
+describe("previewViewport", () => {
+  it("resolves fill and exact freeform viewports", () => {
+    expect(resolvePreviewViewport({ mode: "fill" })).toEqual({ _tag: "fill" });
+    expect(resolvePreviewViewport({ mode: "freeform", width: 1024, height: 768 })).toEqual({
+      _tag: "freeform",
+      width: 1024,
+      height: 768,
+    });
+  });
+
+  it("resolves device presets in either orientation", () => {
+    expect(resolvePreviewViewport({ mode: "preset", preset: "iphone-12-pro" })).toEqual({
+      _tag: "preset",
+      width: 390,
+      height: 844,
+      presetId: "iphone-12-pro",
+    });
+    expect(
+      resolvePreviewViewport({
+        mode: "preset",
+        preset: "iphone-12-pro",
+        orientation: "landscape",
+      }),
+    ).toEqual({
+      _tag: "preset",
+      width: 844,
+      height: 390,
+      presetId: "iphone-12-pro",
+    });
+  });
+
+  it("matches Chrome's standard device catalog ordering", () => {
+    expect(PREVIEW_VIEWPORT_PRESETS.map((preset) => preset.label)).toEqual([
+      "iPhone SE",
+      "iPhone XR",
+      "iPhone 12 Pro",
+      "iPhone 14 Pro Max",
+      "Pixel 7",
+      "Samsung Galaxy S8+",
+      "Samsung Galaxy S20 Ultra",
+      "iPad Mini",
+      "iPad Air",
+      "iPad Pro",
+      "Surface Pro 7",
+      "Surface Duo",
+      "Galaxy Z Fold 5",
+      "Asus Zenbook Fold",
+      "Samsung Galaxy A51/71",
+      "Nest Hub",
+      "Nest Hub Max",
+    ]);
+  });
+
+  it("formats settings for compact UI", () => {
+    expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel");
+    expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852");
+    expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe(
+      "landscape",
+    );
+  });
+});
diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts
new file mode 100644
index 00000000000..1d70bca5dfb
--- /dev/null
+++ b/packages/shared/src/previewViewport.ts
@@ -0,0 +1,186 @@
+import type {
+  PreviewAutomationResizeInput,
+  PreviewViewportPresetId,
+  PreviewViewportSetting,
+} from "@t3tools/contracts";
+import { PREVIEW_VIEWPORT_PRESET_IDS } from "@t3tools/contracts";
+
+export interface PreviewViewportPreset {
+  readonly id: PreviewViewportPresetId;
+  readonly label: string;
+  readonly category: "Desktop" | "Tablet" | "Phone";
+  readonly detail: string;
+  readonly width: number;
+  readonly height: number;
+}
+
+type PreviewViewportPresetDefinition = Omit;
+
+// Keep this in Chrome DevTools' default-device order. Dimensions are CSS
+// viewport sizes from Chromium's EmulatedDevices.ts standard catalog.
+const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = {
+  "iphone-se": {
+    label: "iPhone SE",
+    category: "Phone",
+    detail: "375 × 667",
+    width: 375,
+    height: 667,
+  },
+  "iphone-xr": {
+    label: "iPhone XR",
+    category: "Phone",
+    detail: "414 × 896",
+    width: 414,
+    height: 896,
+  },
+  "iphone-12-pro": {
+    label: "iPhone 12 Pro",
+    category: "Phone",
+    detail: "390 × 844",
+    width: 390,
+    height: 844,
+  },
+  "iphone-14-pro-max": {
+    label: "iPhone 14 Pro Max",
+    category: "Phone",
+    detail: "430 × 932",
+    width: 430,
+    height: 932,
+  },
+  "pixel-7": {
+    label: "Pixel 7",
+    category: "Phone",
+    detail: "412 × 915",
+    width: 412,
+    height: 915,
+  },
+  "samsung-galaxy-s8-plus": {
+    label: "Samsung Galaxy S8+",
+    category: "Phone",
+    detail: "360 × 740",
+    width: 360,
+    height: 740,
+  },
+  "samsung-galaxy-s20-ultra": {
+    label: "Samsung Galaxy S20 Ultra",
+    category: "Phone",
+    detail: "412 × 915",
+    width: 412,
+    height: 915,
+  },
+  "ipad-mini": {
+    label: "iPad Mini",
+    category: "Tablet",
+    detail: "768 × 1024",
+    width: 768,
+    height: 1024,
+  },
+  "ipad-air": {
+    label: "iPad Air",
+    category: "Tablet",
+    detail: "820 × 1180",
+    width: 820,
+    height: 1180,
+  },
+  "ipad-pro": {
+    label: "iPad Pro",
+    category: "Tablet",
+    detail: "1024 × 1366",
+    width: 1024,
+    height: 1366,
+  },
+  "surface-pro-7": {
+    label: "Surface Pro 7",
+    category: "Tablet",
+    detail: "912 × 1368",
+    width: 912,
+    height: 1368,
+  },
+  "surface-duo": {
+    label: "Surface Duo",
+    category: "Phone",
+    detail: "540 × 720",
+    width: 540,
+    height: 720,
+  },
+  "galaxy-z-fold-5": {
+    label: "Galaxy Z Fold 5",
+    category: "Phone",
+    detail: "344 × 882",
+    width: 344,
+    height: 882,
+  },
+  "asus-zenbook-fold": {
+    label: "Asus Zenbook Fold",
+    category: "Tablet",
+    detail: "853 × 1280",
+    width: 853,
+    height: 1280,
+  },
+  "samsung-galaxy-a51-71": {
+    label: "Samsung Galaxy A51/71",
+    category: "Phone",
+    detail: "412 × 914",
+    width: 412,
+    height: 914,
+  },
+  "nest-hub": {
+    label: "Nest Hub",
+    category: "Tablet",
+    detail: "1024 × 600",
+    width: 1024,
+    height: 600,
+  },
+  "nest-hub-max": {
+    label: "Nest Hub Max",
+    category: "Tablet",
+    detail: "1280 × 800",
+    width: 1280,
+    height: 800,
+  },
+} as const satisfies Record;
+
+export const PREVIEW_VIEWPORT_PRESETS: ReadonlyArray =
+  PREVIEW_VIEWPORT_PRESET_IDS.map((id) => ({
+    id,
+    ...PREVIEW_VIEWPORT_PRESET_DEFINITIONS[id],
+  }));
+
+export function resolvePreviewViewport(
+  input: PreviewAutomationResizeInput,
+): PreviewViewportSetting {
+  if (input.mode === "fill") return { _tag: "fill" };
+  if (input.mode === "preset" && input.preset !== undefined) {
+    const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === input.preset);
+    if (!preset) throw new Error(`Unknown preview viewport preset: ${input.preset}`);
+    const landscape = input.orientation === "landscape";
+    const portrait = input.orientation === "portrait";
+    const nativePortrait = preset.height >= preset.width;
+    const shouldSwap = (landscape && nativePortrait) || (portrait && !nativePortrait);
+    return {
+      _tag: "preset",
+      width: shouldSwap ? preset.height : preset.width,
+      height: shouldSwap ? preset.width : preset.height,
+      presetId: preset.id,
+    };
+  }
+  if (input.width === undefined || input.height === undefined) {
+    throw new Error("Custom preview viewport requires width and height");
+  }
+  return {
+    _tag: "freeform",
+    width: input.width,
+    height: input.height,
+  };
+}
+
+export function previewViewportLabel(viewport: PreviewViewportSetting): string {
+  return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`;
+}
+
+export function previewViewportPresetOrientation(
+  viewport: PreviewViewportSetting,
+): "portrait" | "landscape" | null {
+  if (viewport._tag === "fill" || viewport.width === viewport.height) return null;
+  return viewport.width > viewport.height ? "landscape" : "portrait";
+}
diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts
index 85d57c4181f..432b50cf728 100644
--- a/scripts/dev-runner.test.ts
+++ b/scripts/dev-runner.test.ts
@@ -253,6 +253,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
             T3CODE_MODE: "web",
             T3CODE_NO_BROWSER: "0",
             T3CODE_HOST: "0.0.0.0",
+            VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
             VITE_WS_URL: "ws://localhost:13773",
           },
           serverOffset: 0,
diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts
index fb82310bbd3..2953a2f4dec 100644
--- a/scripts/dev-runner.ts
+++ b/scripts/dev-runner.ts
@@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config
     Config.option,
     Config.map((value) => Option.getOrUndefined(value)),
   );
-const optionalUrlConfig = (name: string): Config.Config =>
-  Config.url(name).pipe(
-    Config.option,
-    Config.map((value) => Option.getOrUndefined(value)),
-  );
-
 const OffsetConfig = Config.all({
   portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
   devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
@@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", {
   ),
   devUrl: Flag.string("dev-url").pipe(
     Flag.withSchema(Schema.URLFromString),
-    Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."),
-    Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")),
+    Flag.withDescription(
+      "Explicit web dev URL override (forwards to VITE_DEV_SERVER_URL). Ambient VITE_DEV_SERVER_URL values are ignored so a parent dev app cannot redirect the child runner.",
+    ),
+    Flag.optional,
+    Flag.map(Option.getOrUndefined),
   ),
   dryRun: Flag.boolean("dry-run").pipe(
     Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),