From c2e135e97f2e1f8f5da8dc093a9f76e07b125844 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 24 Jun 2026 16:20:00 -0700 Subject: [PATCH 01/12] Route preview automation through live owner streams - Replace static owner reporting with live connection/focus tracking - Correlate requests and responses by connection ID for safer routing - Update server and web preview automation tests and contracts --- apps/server/src/mcp/McpHttpServer.test.ts | 127 +++---- apps/server/src/mcp/McpHttpServer.ts | 5 +- .../src/mcp/PreviewAutomationBroker.test.ts | 359 ++++++++++++++---- .../server/src/mcp/PreviewAutomationBroker.ts | 222 ++++++----- apps/server/src/server.test.ts | 42 ++ apps/server/src/server.ts | 3 +- apps/server/src/ws.ts | 33 +- .../preview/PreviewAutomationOwner.test.ts | 32 +- .../preview/PreviewAutomationOwner.tsx | 146 +++---- .../preview/previewAutomationClientId.ts | 4 + .../previewAutomationRequestConsumer.test.ts | 157 +++++++- .../previewAutomationRequestConsumer.ts | 110 ++++-- packages/client-runtime/src/state/preview.ts | 20 +- packages/contracts/src/ipc.ts | 11 +- packages/contracts/src/previewAutomation.ts | 47 ++- packages/contracts/src/rpc.ts | 23 +- 16 files changed, 852 insertions(+), 489 deletions(-) create mode 100644 apps/web/src/components/preview/previewAutomationClientId.ts diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index f550396c660..6a2dc21960a 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,28 @@ 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 +156,52 @@ 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..a4dd5f9ab7a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, @@ -5,16 +6,22 @@ import { PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, + PreviewTabId, ProviderInstanceId, ThreadId, type PreviewAutomationOwner, + 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"), @@ -29,20 +36,37 @@ const makeOwner = (overrides: Partial = {}): PreviewAuto clientId: "client-1", environmentId: scope.environmentId, threadId: scope.threadId, - tabId: null, - visible: false, supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", ...overrides, }); +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 owner 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(makeOwner())); yield* Stream.runForEach(requests, (request) => broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, requestId: request.requestId, ok: true, result: { available: true }, @@ -61,6 +85,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(makeOwner()); + 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 +129,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(makeOwner())); yield* Stream.runForEach(requests, (request) => broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, requestId: request.requestId, ok: false, error: remoteError, @@ -88,6 +147,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); @@ -124,10 +184,15 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { 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(makeOwner())); 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,12 +215,13 @@ it.effect("distinguishes malformed remote failures", () => ), ); -it.effect("rejects calls when no focused owner exists", () => +it.effect("rejects calls when no connected owner 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).toMatchObject({ operation: "status", @@ -167,97 +233,241 @@ 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 owner state from focus updates without a live stream", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + yield* broker.focusOwner({ + clientId: "client-1", + environmentId: scope.environmentId, + threadId: scope.threadId, + connectionId: "connection-missing", + focused: true, + }); + + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + }), +); + +it.effect("removes ownership 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" }), + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeOwner())); + const beforeAcquisition = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + + const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* Fiber.interrupt(consumer); + + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + }), + ), +); + +it.effect("routes to the most recently focused live owner using server ordering", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + let secondConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeOwner({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, ); - yield* Stream.runForEach(requests, (request) => - broker.respond({ requestId: request.requestId, ok: true }), + const secondRequests = requestsFrom( + yield* broker.connect(makeOwner({ 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.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + yield* broker.focusOwner({ + clientId: "client-first", + environmentId: scope.environmentId, + threadId: scope.threadId, + connectionId: "connection-stale", + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "second", + ); + yield* broker.focusOwner({ + clientId: "client-first", + environmentId: scope.environmentId, + threadId: scope.threadId, + connectionId: firstConnectionId, + focused: true, + }); + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("first"); + + yield* broker.focusOwner({ + clientId: "client-second", + environmentId: scope.environmentId, + threadId: scope.threadId, + connectionId: secondConnectionId, + focused: true, + }); + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "second", + ); }), ), ); -it.effect("lets the browser host resolve an active tab that has not been reported yet", () => +it.effect("ignores stale focus updates from a previous thread", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make; - const requests = yield* broker.connect(makeOwner({ tabId: null })); - let routedTabId: string | undefined; - yield* Stream.runForEach(requests, (request) => { - routedTabId = request.tabId; - return broker.respond({ requestId: request.requestId, ok: true }); - }).pipe(Effect.forkScoped); + const broker = yield* makeBroker; + let firstConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeOwner({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeOwner({ 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.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + yield* broker.focusOwner({ + clientId: "client-first", + environmentId: scope.environmentId, + threadId: ThreadId.make("thread-stale"), + connectionId: firstConnectionId, + focused: true, + }); - expect(routedTabId).toBeUndefined(); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "second", + ); }), ), ); -it.effect("preserves current owner metadata when its request stream reconnects", () => +it.effect("lets the browser host resolve an active tab locally", () => 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 reconnectedRequests = yield* broker.connect(makeOwner()); + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeOwner())); 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(makeOwner()), (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(makeOwner()), + (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(makeOwner())); + 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(makeOwner())); + yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); const error = yield* Fiber.join(pending); expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); @@ -275,25 +485,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 owner 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(makeOwner())); 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..866aa795166 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -2,7 +2,6 @@ import { PreviewAutomationClientDisconnectedError, PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, - PreviewAutomationHostNotConnectedError, PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, @@ -15,12 +14,13 @@ import { type PreviewAutomationError, type PreviewAutomationOperation, type PreviewAutomationOwner, - type PreviewAutomationOwnerIdentity, - type PreviewAutomationRequest, + type PreviewAutomationOwnerFocus, type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, type PreviewTabId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -44,11 +44,8 @@ export class PreviewAutomationBroker extends Context.Service< { readonly connect: ( owner: PreviewAutomationOwner, - ) => Effect.Effect>; - readonly reportOwner: ( - owner: PreviewAutomationOwner, - ) => Effect.Effect; - readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect; + ) => Effect.Effect>; + readonly focusOwner: (owner: PreviewAutomationOwnerFocus) => Effect.Effect; readonly respond: ( response: PreviewAutomationResponse, ) => Effect.Effect; @@ -60,7 +57,13 @@ export class PreviewAutomationBroker extends Context.Service< interface ClientConnection { readonly clientId: string; - readonly queue: Queue.Queue; + readonly connectionId: string; + readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly threadId: PreviewAutomationOwner["threadId"]; + readonly supportsAutomation: boolean; + readonly focused: boolean; + readonly focusOrder: number; + readonly queue: Queue.Queue; } interface PendingRequest { @@ -76,6 +79,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,9 +89,9 @@ interface PreviewAutomationRequestErrorContext { interface BrokerState { readonly clients: ReadonlyMap; - readonly owners: ReadonlyMap; readonly pending: ReadonlyMap; readonly requestSequence: number; + readonly focusSequence: number; } const selectorDiagnosticsFromInput = ( @@ -193,11 +197,12 @@ 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(), pending: new Map(), requestSequence: 0, + focusSequence: 0, }); const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( @@ -206,12 +211,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ) { 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) { @@ -219,7 +222,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { disconnected.push(entry); } } - return [disconnected, { ...current, clients, owners, pending }] as const; + return [disconnected, { ...current, clients, pending }] as const; }); yield* Effect.forEach( toFail, @@ -230,54 +233,72 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { yield* Queue.shutdown(queue); }); - const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( - "PreviewAutomationBroker.connect", - )(function* (owner) { + const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( + owner: PreviewAutomationOwner, + ) { const clientId = owner.clientId; - const queue = yield* Queue.unbounded(); - const previous = yield* SynchronizedRef.modify(state, (current) => { + 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: owner.environmentId, + threadId: owner.threadId, + supportsAutomation: owner.supportsAutomation, + focused: false, + focusOrder: 0, + queue, + }; + const registration = 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 focusSequence = current.focusSequence + 1; + const registeredConnection = { ...connection, focusOrder: focusSequence }; + clients.set(clientId, registeredConnection); + return [ + { previousConnection: current.clients.get(clientId), registeredConnection }, + { ...current, clients, focusSequence }, + ] as const; }); - if (previous) yield* disconnect(clientId, previous.queue); - return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); + if (registration.previousConnection) { + yield* disconnect(clientId, registration.previousConnection.queue); + } + return registration.registeredConnection; }); - 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 connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( + "PreviewAutomationBroker.connect", + )((owner) => + Effect.succeed( + Stream.unwrap( + Effect.acquireRelease(acquireConnection(owner), (connection) => + disconnect(connection.clientId, connection.queue), + ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), + ), + ), + ); - const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( - "PreviewAutomationBroker.clearOwner", + const focusOwner: PreviewAutomationBroker["Service"]["focusOwner"] = Effect.fn( + "PreviewAutomationBroker.focusOwner", )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { - const currentOwner = current.owners.get(owner.clientId); + const currentOwner = current.clients.get(owner.clientId); if ( !currentOwner || currentOwner.environmentId !== owner.environmentId || - currentOwner.threadId !== owner.threadId + currentOwner.threadId !== owner.threadId || + currentOwner.connectionId !== owner.connectionId ) { return current; } - const owners = new Map(current.owners); - owners.delete(owner.clientId); - return { ...current, owners }; + const clients = new Map(current.clients); + const focusSequence = owner.focused ? current.focusSequence + 1 : current.focusSequence; + clients.set(owner.clientId, { + ...currentOwner, + focused: owner.focused, + focusOrder: owner.focused ? focusSequence : currentOwner.focusOrder, + }); + return { ...current, clients, focusSequence }; }); }); @@ -286,7 +307,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 +334,24 @@ 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 route = yield* SynchronizedRef.modify(state, (current) => { + const connection = Array.from(current.clients.values()) + .filter( + (owner) => + owner.environmentId === input.scope.environmentId && + owner.threadId === input.scope.threadId && + owner.supportsAutomation, + ) + .sort( + (left, right) => + Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, + )[0]; + if (!connection) return [undefined, current] as const; + + const requestId = `preview-${current.requestSequence}`; + const tabId = input.tabId; const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); const context: PreviewAutomationRequestErrorContext = { operation: input.operation, @@ -360,19 +359,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, pending, requestSequence: current.requestSequence + 1 }, ] as const; }); + if (!route) { + 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, requestId, requestContext } = route; const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); @@ -381,12 +391,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 +418,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, focusOwner, respond, invoke }); }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); export const layer = Layer.effect(PreviewAutomationBroker, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 82d168c3c67..4fcf7ad7773 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4125,6 +4125,48 @@ 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 owner = { + clientId: "shared-preview-owner", + environmentId: testEnvironmentDescriptor.environmentId, + threadId: defaultThreadId, + supportsAutomation: true, + } as const; + + yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.previewAutomationConnect](owner).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](owner).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..80876747146 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -336,8 +336,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationFocusOwner, AuthOrchestrationOperateScope], [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], @@ -385,7 +384,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 +402,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; @@ -1650,16 +1651,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => previewAutomationBroker.respond(input), { "rpc.aggregate": "preview-automation" }, ), - [WS_METHODS.previewAutomationReportOwner]: (input) => + [WS_METHODS.previewAutomationFocusOwner]: (input) => observeRpcEffect( - WS_METHODS.previewAutomationReportOwner, - previewAutomationBroker.reportOwner(input), - { "rpc.aggregate": "preview-automation" }, - ), - [WS_METHODS.previewAutomationClearOwner]: (input) => - observeRpcEffect( - WS_METHODS.previewAutomationClearOwner, - previewAutomationBroker.clearOwner(input), + WS_METHODS.previewAutomationFocusOwner, + previewAutomationBroker.focusOwner(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.subscribePreviewEvents]: (_input) => @@ -1791,8 +1786,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 +1807,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 +1845,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( EnvironmentInternalError: HttpServerRespondable.toResponse, }), ), - ), - ), + ); + }), ); diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts b/apps/web/src/components/preview/PreviewAutomationOwner.test.ts index fe11ea75aa6..b69f148c4bd 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts +++ b/apps/web/src/components/preview/PreviewAutomationOwner.test.ts @@ -1,31 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner"; +import { createPreviewAutomationClientId } from "./previewAutomationClientId"; -describe("observeAutomationOwnerConnectedGeneration", () => { - it("reports ownership when the initial transport generation connects", () => { - const initial = observeAutomationOwnerConnectedGeneration(null, 1); - expect(initial).toEqual({ - nextGeneration: 1, - shouldReport: true, - }); +describe("createPreviewAutomationClientId", () => { + it("creates bounded cryptographically random identities for independent owner lifetimes", () => { + const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId); - 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, - }); + 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/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index 2be14363624..ffc0f2e0330 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useAtomValue } from "@effect/atom-react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { type PreviewAutomationNavigateInput, @@ -10,18 +10,14 @@ import { type PreviewAutomationStatus, type ScopedThreadRef, } from "@t3tools/contracts"; -import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Atom } from "effect/unstable/reactivity"; -import { - applyPreviewServerSnapshot, - readThreadPreviewState, - subscribeThreadPreviewState, -} from "~/previewStateStore"; +import { applyPreviewServerSnapshot, readThreadPreviewState } 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"; @@ -33,29 +29,8 @@ import { 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, - }; -} +import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; +import { createPreviewAutomationClientId } from "./previewAutomationClientId"; const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, @@ -136,18 +111,27 @@ const currentStatus = async ( export function PreviewAutomationOwner(props: { readonly threadRef: ScopedThreadRef; readonly visible: boolean; +}) { + return ( + + ); +} + +function PreviewAutomationOwnerLifetime(props: { + readonly threadRef: ScopedThreadRef; + readonly visible: boolean; }) { const { threadRef, visible } = props; - const automationClientId = useId(); + const [automationClientId] = useState(createPreviewAutomationClientId); 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], ); @@ -155,9 +139,6 @@ export function PreviewAutomationOwner(props: { 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, }); @@ -165,33 +146,12 @@ export function PreviewAutomationOwner(props: { previewEnvironment.respondToAutomation, "preview automation response", ); - const reportAutomationOwner = useAtomCommand( - previewEnvironment.reportAutomationOwner, - "preview automation owner report", - ); - const clearAutomationOwner = useAtomCommand( - previewEnvironment.clearAutomationOwner, - "preview automation owner clear", + const focusAutomationOwner = useAtomCommand( + previewEnvironment.focusAutomationOwner, + "preview automation owner focus", ); - 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 [automationConnectionAtom] = useState(() => Atom.make(null)); + const automationConnectionId = useAtomValue(automationConnectionAtom); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { @@ -359,19 +319,20 @@ export function PreviewAutomationOwner(props: { }, [open, threadRef, visible], ); - const [requestHandler] = useState(() => - createLatestPreviewAutomationRequestHandler(handleRequest), - ); + const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); + const setRequestHandler = useAtomSet(requestHandlerAtom); useEffect(() => { - requestHandler.set(handleRequest); - }, [handleRequest, requestHandler]); + setRequestHandler({ handle: handleRequest }); + }, [handleRequest, setRequestHandler]); const automationRequestConsumerAtom = useMemo( () => createPreviewAutomationRequestConsumerAtom({ requestsAtom: automationRequestsAtom, + clientId: automationClientId, + connectionAtom: automationConnectionAtom, environmentId: threadRef.environmentId, - handleRequest: requestHandler.handle, + requestHandlerAtom, respond: (response) => respondToAutomation({ environmentId: threadRef.environmentId, @@ -381,8 +342,9 @@ export function PreviewAutomationOwner(props: { }), [ automationClientId, + automationConnectionAtom, automationRequestsAtom, - requestHandler, + requestHandlerAtom, respondToAutomation, threadRef.environmentId, ], @@ -390,37 +352,33 @@ export function PreviewAutomationOwner(props: { 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({ + const report = () => { + if (!automationConnectionId) return; + void focusAutomationOwner({ environmentId: threadRef.environmentId, input: { clientId: automationClientId, environmentId: threadRef.environmentId, threadId: threadRef.threadId, + connectionId: automationConnectionId, + focused: document.hasFocus(), }, }); }; - }, [automationClientId, clearAutomationOwner, threadRef]); + report(); + window.addEventListener("focus", report); + window.addEventListener("blur", report); + return () => { + window.removeEventListener("focus", report); + window.removeEventListener("blur", report); + }; + }, [ + automationClientId, + automationConnectionId, + focusAutomationOwner, + threadRef.environmentId, + threadRef.threadId, + ]); return null; } 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/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index 905a014d5af..f16dbe1b212 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type PreviewAutomationRequest, type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, PreviewTabId, ThreadId, } from "@t3tools/contracts"; @@ -17,6 +18,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 +33,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 +123,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 +148,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 +200,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(); }); @@ -145,16 +269,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 +293,7 @@ describe("previewAutomationRequestConsumer", () => { registry.set( requestsAtom, AsyncResult.success( - request("request-failed", { + requestEvent("request-failed", { operation: "click", tabId, }), @@ -175,6 +302,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..d5b6b16e58c 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -2,6 +2,7 @@ import type { PreviewAutomationOwner, PreviewAutomationRequest, PreviewAutomationResponse, + PreviewAutomationStreamEvent, } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -11,21 +12,7 @@ import { serializePreviewAutomationOwnerError, } 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, @@ -37,51 +24,98 @@ export function serializePreviewAutomationError( } export function createPreviewAutomationRequestConsumerAtom(options: { - readonly requestsAtom: Atom.Atom>; + readonly requestsAtom: Atom.Atom>; + readonly clientId: PreviewAutomationOwner["clientId"]; + readonly connectionAtom: Atom.Writable; readonly environmentId: PreviewAutomationOwner["environmentId"]; - readonly handleRequest: (request: PreviewAutomationRequest) => Promise; + 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/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 800fc5efac1..fcc884070e1 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -82,24 +82,16 @@ 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, + focusAutomationOwner: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-focus-owner", + tag: WS_METHODS.previewAutomationFocusOwner, scheduler: automationScheduler, concurrency: { - mode: "serial", + mode: "latest", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), }, }), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9d6ed04c286..92068a37c38 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -75,13 +75,13 @@ import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationOwner, - PreviewAutomationOwnerIdentity, + PreviewAutomationOwnerFocus, PreviewAutomationPressInput, - PreviewAutomationRequest, PreviewAutomationResponse, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, + PreviewAutomationStreamEvent, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "./previewAutomation.ts"; @@ -1160,13 +1160,12 @@ export interface EnvironmentApi { reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise; automation: { connect: ( - input: { clientId: string }, - callback: (request: PreviewAutomationRequest) => void, + input: PreviewAutomationOwner, + callback: (event: PreviewAutomationStreamEvent) => void, options?: { onResubscribe?: () => void }, ) => () => void; respond: (response: PreviewAutomationResponse) => Promise; - reportOwner: (owner: PreviewAutomationOwner) => Promise; - clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise; + focusOwner: (input: PreviewAutomationOwnerFocus) => Promise; }; onEvent: ( callback: (event: PreviewEvent) => void, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 118fb892737..d4036c7ea69 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -411,8 +411,13 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ }); export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; +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 PreviewAutomationOwnerIdentity = Schema.Struct({ - clientId: TrimmedNonEmptyString, + clientId: PreviewAutomationClientId, environmentId: EnvironmentId, threadId: ThreadId, }); @@ -420,13 +425,17 @@ export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdenti export const PreviewAutomationOwner = Schema.Struct({ ...PreviewAutomationOwnerIdentity.fields, - tabId: Schema.NullOr(PreviewTabId), - visible: Schema.Boolean, supportsAutomation: Schema.Boolean, - focusedAt: Schema.String, }); export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; +export const PreviewAutomationOwnerFocus = Schema.Struct({ + ...PreviewAutomationOwnerIdentity.fields, + connectionId: PreviewAutomationConnectionId, + focused: Schema.Boolean, +}); +export type PreviewAutomationOwnerFocus = typeof PreviewAutomationOwnerFocus.Type; + export const PreviewAutomationRequest = Schema.Struct({ requestId: TrimmedNonEmptyString, threadId: ThreadId, @@ -437,7 +446,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 +501,7 @@ const PreviewAutomationScopeErrorFields = { const PreviewAutomationRequestErrorFields = { ...PreviewAutomationScopeErrorFields, clientId: TrimmedNonEmptyString, + connectionId: PreviewAutomationConnectionId, requestId: TrimmedNonEmptyString, tabId: Schema.optional(PreviewTabId), timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), @@ -505,6 +530,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas { ...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))), @@ -615,18 +641,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, @@ -676,7 +690,6 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, - PreviewAutomationHostNotConnectedError, PreviewAutomationClientDisconnectedError, PreviewAutomationRequestQueueClosedError, PreviewAutomationRemoteUnavailableError, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a2a8e9106aa..ed592d33c0d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -108,9 +108,9 @@ import { import { PreviewAutomationError, PreviewAutomationOwner, - PreviewAutomationOwnerIdentity, - PreviewAutomationRequest, + PreviewAutomationOwnerFocus, PreviewAutomationResponse, + PreviewAutomationStreamEvent, } from "./previewAutomation.ts"; import { ServerConfigStreamEvent, @@ -196,8 +196,7 @@ export const WS_METHODS = { previewReportStatus: "preview.reportStatus", previewAutomationConnect: "previewAutomation.connect", previewAutomationRespond: "previewAutomation.respond", - previewAutomationReportOwner: "previewAutomation.reportOwner", - previewAutomationClearOwner: "previewAutomation.clearOwner", + previewAutomationFocusOwner: "previewAutomation.focusOwner", // Server meta serverGetConfig: "server.getConfig", @@ -551,7 +550,7 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { payload: PreviewAutomationOwner, - success: PreviewAutomationRequest, + success: PreviewAutomationStreamEvent, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), stream: true, }); @@ -561,14 +560,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 WsPreviewAutomationFocusOwnerRpc = Rpc.make(WS_METHODS.previewAutomationFocusOwner, { + payload: PreviewAutomationOwnerFocus, + error: EnvironmentAuthorizationError, }); export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { @@ -734,8 +728,7 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewReportStatusRpc, WsPreviewAutomationConnectRpc, WsPreviewAutomationRespondRpc, - WsPreviewAutomationReportOwnerRpc, - WsPreviewAutomationClearOwnerRpc, + WsPreviewAutomationFocusOwnerRpc, WsSubscribePreviewEventsRpc, WsSubscribeDiscoveredLocalServersRpc, WsSubscribeServerConfigRpc, From 5b1739a6f3ae8103fea58eace8161971b5d9ffd8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 24 Jun 2026 17:04:31 -0700 Subject: [PATCH 02/12] Route preview automation through host leases - Rename owner concepts to hosts across server and web - Pin provider sessions to a host and add failover checks - Add tests for environment scoping, background threads, and disconnects --- apps/server/src/mcp/McpHttpServer.test.ts | 4 - .../src/mcp/PreviewAutomationBroker.test.ts | 223 ++++++++++--- .../server/src/mcp/PreviewAutomationBroker.ts | 132 +++++--- apps/server/src/server.test.ts | 10 +- apps/server/src/ws.ts | 8 +- apps/web/src/AppRoot.test.tsx | 8 +- apps/web/src/AppRoot.tsx | 2 + apps/web/src/components/ChatView.tsx | 4 - ...onOwner.tsx => PreviewAutomationHosts.tsx} | 298 ++++++++++-------- ...t.ts => previewAutomationClientId.test.ts} | 2 +- .../preview/previewAutomationErrors.ts | 37 +-- .../previewAutomationRequestConsumer.ts | 10 +- packages/client-runtime/src/state/preview.ts | 6 +- packages/contracts/src/ipc.ts | 8 +- packages/contracts/src/previewAutomation.ts | 26 +- packages/contracts/src/rpc.ts | 14 +- 16 files changed, 484 insertions(+), 308 deletions(-) rename apps/web/src/components/preview/{PreviewAutomationOwner.tsx => PreviewAutomationHosts.tsx} (53%) rename apps/web/src/components/preview/{PreviewAutomationOwner.test.ts => previewAutomationClientId.test.ts} (94%) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 6a2dc21960a..14bd4c20576 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -58,8 +58,6 @@ it.effect("returns bounded structural preview snapshot failures", () => const events = yield* broker.connect({ clientId: "mcp-failure-client", environmentId, - threadId, - supportsAutomation: true, }); yield* Stream.runForEach(events, (event) => event.type === "connected" @@ -159,8 +157,6 @@ it.effect("registers annotated tools and preserves authenticated request context const events = yield* broker.connect({ clientId: "mcp-test-client", environmentId, - threadId, - supportsAutomation: true, }); yield* Stream.runForEach(events, (event) => event.type === "connected" diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index a4dd5f9ab7a..8408c59b435 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -5,11 +5,11 @@ import { PreviewAutomationClientDisconnectedError, PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, - PreviewAutomationNoFocusedOwnerError, + PreviewAutomationNoAvailableHostError, PreviewTabId, ProviderInstanceId, ThreadId, - type PreviewAutomationOwner, + type PreviewAutomationHost, type PreviewAutomationRequest, type PreviewAutomationStreamEvent, } from "@t3tools/contracts"; @@ -29,14 +29,12 @@ 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, - supportsAutomation: true, ...overrides, }); @@ -58,11 +56,11 @@ const requestsFrom = ( }), ); -it.effect("atomically registers a connected owner and correlates its response", () => +it.effect("atomically registers a connected host and correlates its response", () => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeOwner())); + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => broker.respond({ clientId: "client-1", @@ -89,7 +87,7 @@ it.effect("announces a live replacement stream before delivering requests", () = Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const events = yield* broker.connect(makeOwner()); + const events = yield* broker.connect(makeHost()); const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; const consumer = yield* events.pipe( Stream.take(2), @@ -130,7 +128,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { return Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeOwner())); + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => broker.respond({ clientId: "client-1", @@ -185,7 +183,7 @@ it.effect("distinguishes malformed remote failures", () => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeOwner())); + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => broker.respond({ clientId: "client-1", @@ -215,14 +213,14 @@ it.effect("distinguishes malformed remote failures", () => ), ); -it.effect("rejects calls when no connected owner exists", () => +it.effect("rejects calls when no connected host exists", () => Effect.gen(function* () { 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, @@ -233,13 +231,12 @@ it.effect("rejects calls when no connected owner exists", () => }), ); -it.effect("does not create owner state from focus updates without a live stream", () => +it.effect("does not create host state from focus updates without a live stream", () => Effect.gen(function* () { const broker = yield* makeBroker; - yield* broker.focusOwner({ + yield* broker.focusHost({ clientId: "client-1", environmentId: scope.environmentId, - threadId: scope.threadId, connectionId: "connection-missing", focused: true, }); @@ -247,19 +244,19 @@ it.effect("does not create owner state from focus updates without a live stream" const error = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); }), ); -it.effect("removes ownership when the authoritative request stream disconnects", () => +it.effect("removes host availability when the authoritative request stream disconnects", () => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeOwner())); + const requests = requestsFrom(yield* broker.connect(makeHost())); const beforeAcquisition = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); - expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); yield* Effect.yieldNow; @@ -268,25 +265,102 @@ it.effect("removes ownership when the authoritative request stream disconnects", const error = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + }), + ), +); + +it.effect("routes requests for background threads through an environment-level host", () => + Effect.scoped( + Effect.gen(function* () { + 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) => { + 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; + + const result = yield* broker.invoke({ + scope: { + ...scope, + threadId: backgroundThreadId, + providerSessionId: "provider-session-background", + }, + operation: "status", + input: {}, + }); + + expect(result).toBe("background"); + expect(routedThreadId).toBe(backgroundThreadId); + }), + ), +); + +it.effect("never routes a provider session to a host from another environment", () => + Effect.scoped( + Effect.gen(function* () { + 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", + ); }), ), ); -it.effect("routes to the most recently focused live owner using server ordering", () => +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(makeOwner({ clientId: "client-first" })), + yield* broker.connect(makeHost({ clientId: "client-first" })), (connectionId) => { firstConnectionId = connectionId; }, ); const secondRequests = requestsFrom( - yield* broker.connect(makeOwner({ clientId: "client-second" })), + yield* broker.connect(makeHost({ clientId: "client-second" })), (connectionId) => { secondConnectionId = connectionId; }, @@ -311,34 +385,92 @@ it.effect("routes to the most recently focused live owner using server ordering" ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.focusOwner({ + yield* broker.focusHost({ clientId: "client-first", environmentId: scope.environmentId, - threadId: scope.threadId, connectionId: "connection-stale", focused: true, }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", ); - yield* broker.focusOwner({ + yield* broker.focusHost({ clientId: "client-first", environmentId: scope.environmentId, - threadId: scope.threadId, connectionId: firstConnectionId, focused: true, }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("first"); + const firstPinnedScope = { + ...scope, + providerSessionId: "provider-session-first-pinned", + }; + expect( + yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), + ).toBe("first"); - yield* broker.focusOwner({ + yield* broker.focusHost({ clientId: "client-second", environmentId: scope.environmentId, - threadId: scope.threadId, 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("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", ); @@ -346,21 +478,21 @@ it.effect("routes to the most recently focused live owner using server ordering" ), ); -it.effect("ignores stale focus updates from a previous thread", () => +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(makeOwner({ clientId: "client-first" })), + yield* broker.connect(makeHost({ clientId: "client-first" })), (connectionId) => { firstConnectionId = connectionId; }, ); const secondRequests = requestsFrom( - yield* broker.connect(makeOwner({ clientId: "client-second" })), + yield* broker.connect(makeHost({ clientId: "client-second" })), ); - yield* Stream.runForEach(firstRequests, (request) => + const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => broker.respond({ clientId: "client-first", connectionId: request.connectionId, @@ -380,13 +512,16 @@ it.effect("ignores stale focus updates from a previous thread", () => ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.focusOwner({ + yield* broker.focusHost({ clientId: "client-first", environmentId: scope.environmentId, - threadId: ThreadId.make("thread-stale"), 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", @@ -399,7 +534,7 @@ 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(makeOwner())); + const requests = requestsFrom(yield* broker.connect(makeHost())); let routedTabId: string | undefined; yield* Stream.runForEach(requests, (request) => { routedTabId = request.tabId; @@ -425,14 +560,14 @@ it.effect("keeps a replacement stream authoritative when the old stream finalize const broker = yield* makeBroker; let firstConnectionId = ""; let replacementConnectionId = ""; - const firstRequests = requestsFrom(yield* broker.connect(makeOwner()), (connectionId) => { + const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { firstConnectionId = connectionId; }); yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); yield* Effect.yieldNow; const replacementRequests = requestsFrom( - yield* broker.connect(makeOwner()), + yield* broker.connect(makeHost()), (connectionId) => { replacementConnectionId = connectionId; }, @@ -459,14 +594,14 @@ it.effect("fails requests assigned to the stream that is replaced", () => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeOwner())); + 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 = requestsFrom(yield* broker.connect(makeOwner())); + const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); const error = yield* Fiber.join(pending); @@ -485,11 +620,11 @@ it.effect("fails requests assigned to the stream that is replaced", () => ), ); -it.effect("accepts responses only from the owner that received the request", () => +it.effect("accepts responses only from the host that received the request", () => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeOwner())); + const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => Effect.gen(function* () { yield* broker.respond({ diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 866aa795166..c5bee957092 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -4,7 +4,7 @@ import { PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, - PreviewAutomationNoFocusedOwnerError, + PreviewAutomationNoAvailableHostError, PreviewAutomationRemoteUnavailableError, PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, @@ -13,13 +13,14 @@ import { PreviewAutomationUnsupportedClientError, type PreviewAutomationError, type PreviewAutomationOperation, - type PreviewAutomationOwner, - type PreviewAutomationOwnerFocus, + 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"; @@ -43,9 +44,9 @@ export class PreviewAutomationBroker extends Context.Service< PreviewAutomationBroker, { readonly connect: ( - owner: PreviewAutomationOwner, + host: PreviewAutomationHost, ) => Effect.Effect>; - readonly focusOwner: (owner: PreviewAutomationOwnerFocus) => Effect.Effect; + readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect; readonly respond: ( response: PreviewAutomationResponse, ) => Effect.Effect; @@ -58,9 +59,7 @@ export class PreviewAutomationBroker extends Context.Service< interface ClientConnection { readonly clientId: string; readonly connectionId: string; - readonly environmentId: PreviewAutomationOwner["environmentId"]; - readonly threadId: PreviewAutomationOwner["threadId"]; - readonly supportsAutomation: boolean; + readonly environmentId: PreviewAutomationHost["environmentId"]; readonly focused: boolean; readonly focusOrder: number; readonly queue: Queue.Queue; @@ -72,6 +71,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"]; @@ -89,6 +95,7 @@ interface PreviewAutomationRequestErrorContext { interface BrokerState { readonly clients: ReadonlyMap; + readonly assignments: ReadonlyMap; readonly pending: ReadonlyMap; readonly requestSequence: number; readonly focusSequence: number; @@ -107,6 +114,9 @@ const selectorDiagnosticsFromInput = ( return {}; }; +const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => + `${scope.environmentId}\u0000${scope.providerSessionId}`; + type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; function remoteDetailKind(detail: unknown): RemoteDetailKind { @@ -135,8 +145,8 @@ const classifyResponseError = ( cause: error, }; switch (error._tag) { - case "PreviewAutomationNoFocusedOwnerError": - return new PreviewAutomationNoFocusedOwnerError({ + case "PreviewAutomationNoAvailableHostError": + return new PreviewAutomationNoAvailableHostError({ ...context, ...remoteDiagnostics, }); @@ -200,6 +210,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const crypto = yield* Crypto.Crypto; const state = yield* SynchronizedRef.make({ clients: new Map(), + assignments: new Map(), pending: new Map(), requestSequence: 0, focusSequence: 0, @@ -211,18 +222,24 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ) { const toFail = yield* SynchronizedRef.modify(state, (current) => { 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) { pending.delete(requestId); disconnected.push(entry); } } - return [disconnected, { ...current, clients, pending }] as const; + return [disconnected, { ...current, clients, assignments, pending }] as const; }); yield* Effect.forEach( toFail, @@ -234,18 +251,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( - owner: PreviewAutomationOwner, + host: PreviewAutomationHost, ) { - const clientId = owner.clientId; + 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: owner.environmentId, - threadId: owner.threadId, - supportsAutomation: owner.supportsAutomation, + environmentId: host.environmentId, focused: false, focusOrder: 0, queue, @@ -268,35 +283,34 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( "PreviewAutomationBroker.connect", - )((owner) => + )((host) => Effect.succeed( Stream.unwrap( - Effect.acquireRelease(acquireConnection(owner), (connection) => + Effect.acquireRelease(acquireConnection(host), (connection) => disconnect(connection.clientId, connection.queue), ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), ), ), ); - const focusOwner: PreviewAutomationBroker["Service"]["focusOwner"] = Effect.fn( - "PreviewAutomationBroker.focusOwner", - )(function* (owner) { + const focusHost: PreviewAutomationBroker["Service"]["focusHost"] = Effect.fn( + "PreviewAutomationBroker.focusHost", + )(function* (host) { yield* SynchronizedRef.update(state, (current) => { - const currentOwner = current.clients.get(owner.clientId); + const currentHost = current.clients.get(host.clientId); if ( - !currentOwner || - currentOwner.environmentId !== owner.environmentId || - currentOwner.threadId !== owner.threadId || - currentOwner.connectionId !== owner.connectionId + !currentHost || + currentHost.environmentId !== host.environmentId || + currentHost.connectionId !== host.connectionId ) { return current; } const clients = new Map(current.clients); - const focusSequence = owner.focused ? current.focusSequence + 1 : current.focusSequence; - clients.set(owner.clientId, { - ...currentOwner, - focused: owner.focused, - focusOrder: owner.focused ? focusSequence : currentOwner.focusOrder, + 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 }; }); @@ -336,19 +350,45 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ): Effect.fn.Return { const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); + const now = yield* Clock.currentTimeMillis; const route = yield* SynchronizedRef.modify(state, (current) => { - const connection = Array.from(current.clients.values()) - .filter( - (owner) => - owner.environmentId === input.scope.environmentId && - owner.threadId === input.scope.threadId && - owner.supportsAutomation, - ) - .sort( - (left, right) => - Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, - )[0]; - if (!connection) return [undefined, current] as const; + 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; + // Keep one provider session on one physical desktop runtime so a + // multi-step browser interaction cannot jump between independent + // Electron cookie/DOM state. A dead lease is pruned above and may then + // fail over to the best remaining host. + const connection = + assignedConnection?.environmentId === input.scope.environmentId + ? assignedConnection + : Array.from(current.clients.values()) + .filter((host) => host.environmentId === input.scope.environmentId) + .sort( + (left, right) => + Number(right.focused) - Number(left.focused) || + right.focusOrder - left.focusOrder, + )[0]; + if (!connection) { + 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; @@ -370,11 +410,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { pending.set(requestId, { queue: connection.queue, deferred, context }); return [ { connection, requestId, requestContext: context }, - { ...current, pending, requestSequence: current.requestSequence + 1 }, + { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, ] as const; }); if (!route) { - return yield* new PreviewAutomationNoFocusedOwnerError({ + return yield* new PreviewAutomationNoAvailableHostError({ operation: input.operation, environmentId: input.scope.environmentId, threadId: input.scope.threadId, @@ -418,7 +458,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return yield* awaitResponse().pipe(Effect.ensuring(removePending)); }); - return PreviewAutomationBroker.of({ connect, focusOwner, 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/server.test.ts b/apps/server/src/server.test.ts index 4fcf7ad7773..79e2d7ad768 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4133,15 +4133,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const firstConnected = yield* Deferred.make(); const firstClosed = yield* Deferred.make(); - const owner = { - clientId: "shared-preview-owner", + const host = { + clientId: "shared-preview-host", environmentId: testEnvironmentDescriptor.environmentId, - threadId: defaultThreadId, - supportsAutomation: true, } as const; yield* withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.previewAutomationConnect](owner).pipe( + client[WS_METHODS.previewAutomationConnect](host).pipe( Stream.tap((event) => event.type === "connected" ? Deferred.succeed(firstConnected, event.connectionId) @@ -4154,7 +4152,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const firstConnectionId = yield* Deferred.await(firstConnected); const replacementEvent = yield* withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.previewAutomationConnect](owner).pipe(Stream.runHead), + client[WS_METHODS.previewAutomationConnect](host).pipe(Stream.runHead), ).pipe(Effect.map(Option.getOrThrow)); const firstStreamClosed = yield* Deferred.await(firstClosed).pipe( Effect.timeoutOption("2 seconds"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 80876747146..fce468329f4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -336,7 +336,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationFocusOwner, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], @@ -1651,10 +1651,10 @@ const makeWsRpcLayer = ( previewAutomationBroker.respond(input), { "rpc.aggregate": "preview-automation" }, ), - [WS_METHODS.previewAutomationFocusOwner]: (input) => + [WS_METHODS.previewAutomationFocusHost]: (input) => observeRpcEffect( - WS_METHODS.previewAutomationFocusOwner, - previewAutomationBroker.focusOwner(input), + WS_METHODS.previewAutomationFocusHost, + previewAutomationBroker.focusHost(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.subscribePreviewEvents]: (_input) => 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/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}
=> { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const state = readThreadPreviewState(threadRef); - const tabId = state.snapshot?.tabId; - if (tabId && state.desktopOverlay && previewBridge) { + if (state.desktopByTabId[tabId] && previewBridge) { const status = await previewBridge.automation.status(tabId); if (status.available) return; } @@ -89,15 +93,19 @@ const waitForNavigationReadiness = async ( const currentStatus = async ( threadRef: ScopedThreadRef, - visible: boolean, + requestedTabId: string | null, ): Promise => { const state = readThreadPreviewState(threadRef); - const tabId = state.snapshot?.tabId ?? null; - if (tabId && previewBridge && state.desktopOverlay) { + const tabId = requestedTabId ?? state.snapshot?.tabId ?? null; + const snapshot = (tabId ? state.sessions[tabId] : null) ?? state.snapshot; + const visible = tabId + ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) + : false; + if (tabId && previewBridge && state.desktopByTabId[tabId]) { const status = await previewBridge.automation.status(tabId); return { ...status, visible }; } - const navStatus = state.snapshot?.navStatus; + const navStatus = snapshot?.navStatus; return { available: Boolean(previewBridge?.automation), visible, @@ -108,36 +116,43 @@ const currentStatus = async ( }; }; -export function PreviewAutomationOwner(props: { - readonly threadRef: ScopedThreadRef; - readonly visible: boolean; -}) { +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 PreviewAutomationOwnerLifetime(props: { - readonly threadRef: ScopedThreadRef; - readonly visible: boolean; -}) { - const { threadRef, visible } = props; +function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { + const { environmentId } = props; + const registry = useContext(RegistryContext); const [automationClientId] = useState(createPreviewAutomationClientId); - const initialAutomationOwner = useMemo( + const initialAutomationHost = useMemo( () => ({ clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - supportsAutomation: Boolean(previewBridge?.automation), + environmentId, }), - [automationClientId, threadRef.environmentId, threadRef.threadId], + [automationClientId, environmentId], ); const automationRequestsAtom = previewEnvironment.automationRequests({ - environmentId: threadRef.environmentId, - input: initialAutomationOwner, + environmentId, + input: initialAutomationHost, + }); + const listPreviews = useAtomQueryRunner(previewEnvironment.list, { + reportFailure: false, }); const open = useAtomCommand(previewEnvironment.open, { reportFailure: false, @@ -146,48 +161,72 @@ function PreviewAutomationOwnerLifetime(props: { previewEnvironment.respondToAutomation, "preview automation response", ); - const focusAutomationOwner = useAtomCommand( - previewEnvironment.focusAutomationOwner, - "preview automation owner focus", + 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 { - if (request.threadId !== threadRef.threadId) { - throw new PreviewAutomationStaleOwnerError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - expectedThreadId: threadRef.threadId, - requestedThreadId: request.threadId, - }); + let state = readThreadPreviewState(threadRef); + const needsSessionSync = + Object.keys(state.sessions).length === 0 || + (request.tabId !== undefined && state.sessions[request.tabId] === undefined); + 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); + } + for (const snapshot of result.value.sessions) { + applyPreviewServerSnapshot(threadRef, snapshot); + } + state = readThreadPreviewState(threadRef); } - 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, + 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, visible); + 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: threadRef.environmentId, + environmentId, input: { - threadId: threadRef.threadId, + threadId: request.threadId, ...(input.url ? { url: input.url } : {}), }, }); @@ -198,109 +237,105 @@ function PreviewAutomationOwnerLifetime(props: { 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); + await waitForDesktopOverlay( + threadRef, + request.requestId, + activeTabId, + request.timeoutMs, + ); + if (reusedExistingTab && input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); + } + return await currentStatus(threadRef, activeTabId); } case "navigate": { - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } + const ready = await requireReadyTab(); const input = request.input as PreviewAutomationNavigateInput; const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, + environmentId, + input.target ?? { + kind: "url", + url: input.url!, + }, ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); + await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); await waitForNavigationReadiness( threadRef, request.requestId, - tabId, + ready.tabId, input.readiness ?? "load", input.timeoutMs ?? request.timeoutMs, ); - return await currentStatus(threadRef, visible); + return await currentStatus(threadRef, ready.tabId); } - 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 "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": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.type( - 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": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.press( - 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": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.scroll( - 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": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.evaluate( - 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": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.waitFor( - 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": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const startedAt = await startBrowserRecording(tabId); + const ready = await requireReadyTab(); + const startedAt = await startBrowserRecording(ready.tabId); return { - tabId, + tabId: ready.tabId, recording: true, startedAt, }; } case "recordingStop": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const artifact = await stopBrowserRecording(tabId); + const ready = await requireReadyTab(); + const artifact = await stopBrowserRecording(ready.tabId); if (!artifact) { throw new PreviewAutomationRecordingNotActiveError({ requestId: request.requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, + environmentId, + threadId: request.threadId, + tabId: ready.tabId, }); } return artifact; @@ -310,14 +345,14 @@ function PreviewAutomationOwnerLifetime(props: { throw PreviewAutomationOperationError.fromCause({ requestId: request.requestId, operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + environmentId, + threadId: request.threadId, tabId, cause, }); } }, - [open, threadRef, visible], + [environmentId, listPreviews, open, registry], ); const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); const setRequestHandler = useAtomSet(requestHandlerAtom); @@ -331,14 +366,14 @@ function PreviewAutomationOwnerLifetime(props: { requestsAtom: automationRequestsAtom, clientId: automationClientId, connectionAtom: automationConnectionAtom, - environmentId: threadRef.environmentId, + environmentId, requestHandlerAtom, respond: (response) => respondToAutomation({ - environmentId: threadRef.environmentId, + environmentId, input: response, }), - label: `preview:automation-request-consumer:${automationClientId}`, + label: `preview:automation-host:${environmentId}:${automationClientId}`, }), [ automationClientId, @@ -346,7 +381,7 @@ function PreviewAutomationOwnerLifetime(props: { automationRequestsAtom, requestHandlerAtom, respondToAutomation, - threadRef.environmentId, + environmentId, ], ); useAtomValue(automationRequestConsumerAtom); @@ -354,12 +389,11 @@ function PreviewAutomationOwnerLifetime(props: { useEffect(() => { const report = () => { if (!automationConnectionId) return; - void focusAutomationOwner({ - environmentId: threadRef.environmentId, + void focusAutomationHost({ + environmentId, input: { clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + environmentId, connectionId: automationConnectionId, focused: document.hasFocus(), }, @@ -372,13 +406,7 @@ function PreviewAutomationOwnerLifetime(props: { window.removeEventListener("focus", report); window.removeEventListener("blur", report); }; - }, [ - automationClientId, - automationConnectionId, - focusAutomationOwner, - threadRef.environmentId, - threadRef.threadId, - ]); + }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); return null; } diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts b/apps/web/src/components/preview/previewAutomationClientId.test.ts similarity index 94% rename from apps/web/src/components/preview/PreviewAutomationOwner.test.ts rename to apps/web/src/components/preview/previewAutomationClientId.test.ts index b69f148c4bd..f51732d91a4 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts +++ b/apps/web/src/components/preview/previewAutomationClientId.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { createPreviewAutomationClientId } from "./previewAutomationClientId"; describe("createPreviewAutomationClientId", () => { - it("creates bounded cryptographically random identities for independent owner lifetimes", () => { + 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); diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index c4ca445458c..20d96f197e3 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,24 +56,6 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC } } -export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( - "PreviewAutomationStaleOwnerError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - expectedThreadId: ThreadId, - requestedThreadId: ThreadId, - }, -) { - get responseTag() { - return "PreviewAutomationUnavailableError" 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}.`; - } -} - export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationTargetUnavailableError", { @@ -125,8 +107,8 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass
 {
   const detail = Object.fromEntries(
     Object.entries(error).filter(
diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
index d5b6b16e58c..89a9387e4af 100644
--- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
+++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
@@ -1,5 +1,5 @@
 import type {
-  PreviewAutomationOwner,
+  PreviewAutomationHost,
   PreviewAutomationRequest,
   PreviewAutomationResponse,
   PreviewAutomationStreamEvent,
@@ -9,7 +9,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
 import {
   PreviewAutomationOperationError,
   type PreviewAutomationOperationContext,
-  serializePreviewAutomationOwnerError,
+  serializePreviewAutomationHostError,
 } from "./previewAutomationErrors";
 
 type AutomationStreamResult = AsyncResult.AsyncResult;
@@ -18,16 +18,16 @@ 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 clientId: PreviewAutomationOwner["clientId"];
+  readonly clientId: PreviewAutomationHost["clientId"];
   readonly connectionAtom: Atom.Writable;
-  readonly environmentId: PreviewAutomationOwner["environmentId"];
+  readonly environmentId: PreviewAutomationHost["environmentId"];
   readonly requestHandlerAtom: Atom.Atom<{
     readonly handle: (request: PreviewAutomationRequest) => Promise;
   }>;
diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts
index fcc884070e1..831cea05be7 100644
--- a/packages/client-runtime/src/state/preview.ts
+++ b/packages/client-runtime/src/state/preview.ts
@@ -86,9 +86,9 @@ export function createPreviewEnvironmentAtoms(
           JSON.stringify([environmentId, input.connectionId, input.requestId]),
       },
     }),
-    focusAutomationOwner: createEnvironmentRpcCommand(runtime, {
-      label: "environment-data:preview:automation-focus-owner",
-      tag: WS_METHODS.previewAutomationFocusOwner,
+    focusAutomationHost: createEnvironmentRpcCommand(runtime, {
+      label: "environment-data:preview:automation-focus-host",
+      tag: WS_METHODS.previewAutomationFocusHost,
       scheduler: automationScheduler,
       concurrency: {
         mode: "latest",
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 92068a37c38..144095ee25b 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -74,8 +74,8 @@ import type {
 import {
   PreviewAutomationClickInput,
   PreviewAutomationEvaluateInput,
-  PreviewAutomationOwner,
-  PreviewAutomationOwnerFocus,
+  PreviewAutomationHost,
+  PreviewAutomationHostFocus,
   PreviewAutomationPressInput,
   PreviewAutomationResponse,
   PreviewAutomationScrollInput,
@@ -1160,12 +1160,12 @@ export interface EnvironmentApi {
     reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise;
     automation: {
       connect: (
-        input: PreviewAutomationOwner,
+        input: PreviewAutomationHost,
         callback: (event: PreviewAutomationStreamEvent) => void,
         options?: { onResubscribe?: () => void },
       ) => () => void;
       respond: (response: PreviewAutomationResponse) => Promise;
-      focusOwner: (input: PreviewAutomationOwnerFocus) => Promise;
+      focusHost: (input: PreviewAutomationHostFocus) => Promise;
     };
     onEvent: (
       callback: (event: PreviewEvent) => void,
diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts
index d4036c7ea69..1cf4dcf461f 100644
--- a/packages/contracts/src/previewAutomation.ts
+++ b/packages/contracts/src/previewAutomation.ts
@@ -416,25 +416,23 @@ export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type;
 export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
 export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type;
 
-export const PreviewAutomationOwnerIdentity = Schema.Struct({
+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 PreviewAutomationOwner = Schema.Struct({
-  ...PreviewAutomationOwnerIdentity.fields,
-  supportsAutomation: Schema.Boolean,
+export const PreviewAutomationHost = Schema.Struct({
+  ...PreviewAutomationHostIdentity.fields,
 });
-export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type;
+export type PreviewAutomationHost = typeof PreviewAutomationHost.Type;
 
-export const PreviewAutomationOwnerFocus = Schema.Struct({
-  ...PreviewAutomationOwnerIdentity.fields,
+export const PreviewAutomationHostFocus = Schema.Struct({
+  ...PreviewAutomationHostIdentity.fields,
   connectionId: PreviewAutomationConnectionId,
   focused: Schema.Boolean,
 });
-export type PreviewAutomationOwnerFocus = typeof PreviewAutomationOwnerFocus.Type;
+export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;
 
 export const PreviewAutomationRequest = Schema.Struct({
   requestId: TrimmedNonEmptyString,
@@ -525,8 +523,8 @@ 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),
@@ -538,7 +536,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;
   }
 }
@@ -682,7 +680,7 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC
 
 export const PreviewAutomationError = Schema.Union([
   PreviewAutomationUnavailableError,
-  PreviewAutomationNoFocusedOwnerError,
+  PreviewAutomationNoAvailableHostError,
   PreviewAutomationUnsupportedClientError,
   PreviewAutomationTabNotFoundError,
   PreviewAutomationTimeoutError,
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index ed592d33c0d..40c52db55db 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -107,8 +107,8 @@ import {
 } from "./preview.ts";
 import {
   PreviewAutomationError,
-  PreviewAutomationOwner,
-  PreviewAutomationOwnerFocus,
+  PreviewAutomationHost,
+  PreviewAutomationHostFocus,
   PreviewAutomationResponse,
   PreviewAutomationStreamEvent,
 } from "./previewAutomation.ts";
@@ -196,7 +196,7 @@ export const WS_METHODS = {
   previewReportStatus: "preview.reportStatus",
   previewAutomationConnect: "previewAutomation.connect",
   previewAutomationRespond: "previewAutomation.respond",
-  previewAutomationFocusOwner: "previewAutomation.focusOwner",
+  previewAutomationFocusHost: "previewAutomation.focusHost",
 
   // Server meta
   serverGetConfig: "server.getConfig",
@@ -549,7 +549,7 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,
 });
 
 export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
-  payload: PreviewAutomationOwner,
+  payload: PreviewAutomationHost,
   success: PreviewAutomationStreamEvent,
   error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
   stream: true,
@@ -560,8 +560,8 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati
   error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
 });
 
-export const WsPreviewAutomationFocusOwnerRpc = Rpc.make(WS_METHODS.previewAutomationFocusOwner, {
-  payload: PreviewAutomationOwnerFocus,
+export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, {
+  payload: PreviewAutomationHostFocus,
   error: EnvironmentAuthorizationError,
 });
 
@@ -728,7 +728,7 @@ export const WsRpcGroup = RpcGroup.make(
   WsPreviewReportStatusRpc,
   WsPreviewAutomationConnectRpc,
   WsPreviewAutomationRespondRpc,
-  WsPreviewAutomationFocusOwnerRpc,
+  WsPreviewAutomationFocusHostRpc,
   WsSubscribePreviewEventsRpc,
   WsSubscribeDiscoveredLocalServersRpc,
   WsSubscribeServerConfigRpc,

From 7215a13bc4ab9164cfaff014f223118ddefeff61 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Wed, 24 Jun 2026 18:00:57 -0700
Subject: [PATCH 03/12] Add persistent preview viewport sizing

Co-authored-by: codex 
---
 .../src/mcp/PreviewAutomationBroker.test.ts   | 106 ++++++++
 .../server/src/mcp/PreviewAutomationBroker.ts |  40 ++-
 .../src/mcp/toolkits/preview/handlers.ts      |   3 +
 apps/server/src/mcp/toolkits/preview/tools.ts |  19 +-
 apps/server/src/preview/Manager.test.ts       |  55 ++++
 apps/server/src/preview/Manager.ts            |  38 +++
 apps/server/src/server.test.ts                |   1 +
 apps/server/src/ws.ts                         |   5 +
 apps/web/src/browser/ElectronBrowserHost.tsx  |   7 +-
 apps/web/src/browser/HostedBrowserWebview.tsx | 236 ++++++++++++++++--
 apps/web/src/browser/browserRecording.ts      |   7 +-
 .../src/browser/browserSurfaceStore.test.ts   |  23 ++
 apps/web/src/browser/browserSurfaceStore.ts   |  48 +++-
 .../browser/browserViewportActions.test.ts    |  33 +++
 .../web/src/browser/browserViewportActions.ts |  25 ++
 .../src/browser/browserViewportLayout.test.ts |  77 ++++++
 apps/web/src/browser/browserViewportLayout.ts |  75 ++++++
 .../components/preview/AgentBrowserCursor.tsx |  13 +-
 .../preview/PreviewAutomationHosts.tsx        | 118 ++++++++-
 .../src/components/preview/PreviewView.tsx    |  76 +++++-
 .../preview/PreviewViewportControl.tsx        | 234 +++++++++++++++++
 apps/web/src/previewStateStore.test.ts        |  28 +++
 apps/web/src/previewStateStore.ts             |   3 +-
 packages/client-runtime/src/state/preview.ts  |   6 +
 packages/contracts/src/ipc.ts                 |   2 +
 packages/contracts/src/preview.test.ts        | 113 +++++++++
 packages/contracts/src/preview.ts             |  82 ++++++
 packages/contracts/src/previewAutomation.ts   | 105 +++++++-
 packages/contracts/src/rpc.ts                 |   9 +
 packages/shared/package.json                  |   4 +
 packages/shared/src/previewViewport.test.ts   |  47 ++++
 packages/shared/src/previewViewport.ts        | 135 ++++++++++
 32 files changed, 1719 insertions(+), 54 deletions(-)
 create mode 100644 apps/web/src/browser/browserSurfaceStore.test.ts
 create mode 100644 apps/web/src/browser/browserViewportActions.test.ts
 create mode 100644 apps/web/src/browser/browserViewportActions.ts
 create mode 100644 apps/web/src/browser/browserViewportLayout.test.ts
 create mode 100644 apps/web/src/browser/browserViewportLayout.ts
 create mode 100644 apps/web/src/components/preview/PreviewViewportControl.tsx
 create mode 100644 packages/shared/src/previewViewport.test.ts
 create mode 100644 packages/shared/src/previewViewport.ts

diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts
index 8408c59b435..0639e3fe70a 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts
@@ -430,6 +430,112 @@ it.effect("pins a provider session to its initial host despite later focus chang
   ),
 );
 
+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* () {
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts
index c5bee957092..8314d914ee6 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.ts
@@ -1,4 +1,5 @@
 import {
+  PREVIEW_AUTOMATION_V1_OPERATIONS,
   PreviewAutomationClientDisconnectedError,
   PreviewAutomationControlInterruptedError,
   PreviewAutomationExecutionError,
@@ -60,6 +61,7 @@ interface ClientConnection {
   readonly clientId: string;
   readonly connectionId: string;
   readonly environmentId: PreviewAutomationHost["environmentId"];
+  readonly supportedOperations: ReadonlySet;
   readonly focused: boolean;
   readonly focusOrder: number;
   readonly queue: Queue.Queue;
@@ -117,6 +119,11 @@ const selectorDiagnosticsFromInput = (
 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 {
@@ -261,6 +268,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
       clientId,
       connectionId,
       environmentId: host.environmentId,
+      supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
       focused: false,
       focusOrder: 0,
       queue,
@@ -365,22 +373,32 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
       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 dead lease is pruned above and may then
-      // fail over to the best remaining host.
+      // 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 =
-        assignedConnection?.environmentId === input.scope.environmentId
+        hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
           ? assignedConnection
-          : Array.from(current.clients.values())
-              .filter((host) => host.environmentId === input.scope.environmentId)
-              .sort(
-                (left, right) =>
-                  Number(right.focused) - Number(left.focused) ||
-                  right.focusOrder - left.focusOrder,
-              )[0];
+          : 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) {
-        assignments.delete(assignmentKey);
+        if (!hasLiveAssignment) assignments.delete(assignmentKey);
         return [undefined, { ...current, assignments }] as const;
       }
       assignments.set(assignmentKey, {
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..dde875cec40 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 common device preset. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-15-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 79e2d7ad768..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,
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index fce468329f4..9020e99f670 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -330,6 +330,7 @@ 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],
@@ -1623,6 +1624,10 @@ const makeWsRpcLayer = (
           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",
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..06cdd4b0feb 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,14 +1,24 @@ "use client"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; +import { Scaling } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; -import { useCallback, useEffect, useRef } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} 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 { commitBrowserViewportChange } from "./browserViewportActions"; +import { resizeFreeformViewport, resolveBrowserViewportLayout } from "./browserViewportLayout"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; @@ -18,6 +28,7 @@ interface ElectronWebview extends HTMLElement { preload?: string; webpreferences?: string; getWebContentsId: () => number; + executeJavaScript: (code: string, userGesture?: boolean) => Promise; } declare global { @@ -26,17 +37,39 @@ declare global { } } +const viewportSettingKey = (viewport: PreviewViewportSetting): string => + viewport._tag === "fill" + ? "fill" + : `${viewport._tag}:${viewport.width}:${viewport.height}:${viewport._tag === "preset" ? viewport.presetId : ""}`; + 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 dragCleanupRef = useRef<(() => void) | null>(null); + const wrapperRef = useRef(null); const webviewRef = useRef(null); - const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const [dragViewport, setDragViewport] = useState<{ + readonly sourceKey: string; + readonly width: number; + readonly height: number; + } | null>(null); + const presentation = useBrowserSurfaceStore( + useShallow((state) => { + const current = state.byTabId[tabId]; + return { + rect: current?.rect ?? null, + visible: current?.visible ?? false, + }; + }), + ); const recording = useActiveBrowserRecordingTabId() === tabId; usePreviewBridge({ threadRef, tabId }); @@ -50,6 +83,13 @@ export function HostedBrowserWebview(props: { }; }, [tabId]); + useEffect( + () => () => { + dragCleanupRef.current?.(); + }, + [], + ); + const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); @@ -89,10 +129,118 @@ export function HostedBrowserWebview(props: { }; }, [config, tabId]); + const active = presentation.visible && presentation.rect !== null; + const lastRect = presentation.rect; + const sourceViewportKey = viewportSettingKey(viewport); + const effectiveViewport = + dragViewport?.sourceKey === sourceViewportKey + ? ({ + _tag: "freeform", + width: dragViewport.width, + height: dragViewport.height, + } as const satisfies PreviewViewportSetting) + : viewport; + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const hiddenSize = + effectiveViewport._tag !== "fill" + ? { + width: effectiveViewport.width * normalizedZoomFactor, + height: effectiveViewport.height * normalizedZoomFactor, + } + : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; + const containerSize = active && lastRect ? lastRect : hiddenSize; + const layout = resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor); + + const handleResizePointerDown = (event: ReactPointerEvent) => { + if (effectiveViewport._tag !== "freeform") return; + event.preventDefault(); + event.stopPropagation(); + dragCleanupRef.current?.(); + const pointerId = event.pointerId; + const target = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + const startWidth = effectiveViewport.width; + const startHeight = effectiveViewport.height; + let latest = { width: startWidth, height: startHeight }; + try { + target.setPointerCapture(pointerId); + } catch { + // Window listeners below keep the drag functional when capture is unavailable. + } + + const move = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + moveEvent.preventDefault(); + const { width, height } = resizeFreeformViewport( + { width: startWidth, height: startHeight }, + { x: moveEvent.clientX - startX, y: moveEvent.clientY - startY }, + zoomFactor, + ); + latest = { width, height }; + setDragViewport({ sourceKey: sourceViewportKey, width, height }); + }; + 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(); + void commitBrowserViewportChange(tabId, { + _tag: "freeform", + width: latest.width, + height: latest.height, + }).then( + () => setDragViewport(null), + () => setDragViewport(null), + ); + } + function cancel(cancelEvent: PointerEvent) { + if (cancelEvent.pointerId !== pointerId) return; + cleanup(); + setDragViewport(null); + } + dragCleanupRef.current = cleanup; + window.addEventListener("pointermove", move, { passive: false }); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", cancel); + }; + + 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, + 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]); + 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 +253,69 @@ 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 ( - +
+
+ + {active && effectiveViewport._tag === "freeform" ? ( + + ) : null} +
+
); } diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 5bb3364807d..d6f8f153b0d 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -137,10 +137,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/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts new file mode 100644 index 00000000000..5f0eb7d3e88 --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { 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, + scrollLeft: 0, + scrollTop: 0, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + rect: null, + visible: false, + content: { width: 393, height: 852 }, + }); + }); +}); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 64fd8e2df2b..32a5525ab16 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,12 +10,23 @@ 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 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; } @@ -35,7 +46,42 @@ 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.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..d6a074add9c --- /dev/null +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + 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"); + }); +}); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts new file mode 100644 index 00000000000..db8f97cad36 --- /dev/null +++ b/apps/web/src/browser/browserViewportActions.ts @@ -0,0 +1,25 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; + +const handlers = new Map(); + +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 handler = handlers.get(tabId); + return handler + ? handler(setting) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); +} diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts new file mode 100644 index 00000000000..29ab41bd70d --- /dev/null +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resizeFreeformViewport, resolveBrowserViewportLayout } 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, + 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("creates a scrollable canvas for a larger fixed viewport", () => { + expect( + resolveBrowserViewportLayout( + { width: 600, height: 700 }, + { _tag: "freeform", width: 1440, height: 900 }, + ), + ).toMatchObject({ + canvasWidth: 1440, + canvasHeight: 900, + viewportX: 0, + viewportY: 0, + viewportWidth: 1440, + viewportHeight: 900, + }); + }); + + 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); + }); +}); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts new file mode 100644 index 00000000000..e4d4a53a4de --- /dev/null +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -0,0 +1,75 @@ +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; + readonly viewportWidth: number; + readonly viewportHeight: number; + readonly fillsPanel: boolean; +} + +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, + fillsPanel: true, + }; + } + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const renderedWidth = setting.width * normalizedZoomFactor; + const renderedHeight = setting.height * normalizedZoomFactor; + return { + canvasWidth: Math.max(containerWidth, renderedWidth), + canvasHeight: Math.max(containerHeight, renderedHeight), + viewportX: Math.max(0, Math.round((containerWidth - renderedWidth) / 2)), + viewportY: Math.max(0, Math.round((containerHeight - renderedHeight) / 2)), + viewportWidth: renderedWidth, + viewportHeight: renderedHeight, + fillsPanel: false, + }; +} + +const clampViewportDimension = (value: number): number => + Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value)); + +export function resizeFreeformViewport( + start: PreviewViewportSize, + delta: { readonly x: number; readonly y: number }, + zoomFactor = 1, +): PreviewViewportSize { + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + let width = clampViewportDimension(Math.round(start.width + delta.x / normalizedZoomFactor)); + let height = clampViewportDimension(Math.round(start.height + delta.y / normalizedZoomFactor)); + if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height }; + if (Math.abs(delta.x) >= Math.abs(delta.y)) { + 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 }; +} diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index dcaab1e3aab..58f637990a8 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; @@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: { }) { const { tabId, zoomFactor, controller } = props; const event = useBrowserPointerStore((state) => 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,16 @@ export function AgentBrowserCursor(props: { function AgentBrowserCursorEvent(props: { readonly event: DesktopPreviewPointerEvent; + readonly content: { + readonly x: number; + readonly y: 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 +57,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?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor + (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 index 37254e10e86..66221d1f4d8 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -3,14 +3,21 @@ 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"; @@ -91,6 +98,75 @@ const waitForNavigationReadiness = async ( }); }; +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 waitForRenderedViewport = async ( + tabId: string, + setting: PreviewViewportSetting, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() <= deadline) { + try { + const webview = findPreviewWebview(tabId); + const expectedWidth = Number(webview?.getAttribute("data-preview-css-width")); + const expectedHeight = Number(webview?.getAttribute("data-preview-css-height")); + const modeApplied = webview?.getAttribute("data-preview-viewport-mode") === setting._tag; + const viewport = webview && modeApplied ? await readWebviewViewport(webview) : null; + const tolerance = setting._tag === "fill" ? 1 : 0; + if ( + viewport && + Number.isInteger(expectedWidth) && + Number.isInteger(expectedHeight) && + Math.abs(viewport.width - expectedWidth) <= tolerance && + Math.abs(viewport.height - expectedHeight) <= tolerance + ) { + return viewport; + } + } catch (error) { + // Registration and navigation can transiently replace the guest while + // React applies the server snapshot. Retry until the operation deadline. + lastError = error; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new Error(`Timed out applying browser viewport for tab ${tabId}`, { + cause: lastError, + }); +}; + const currentStatus = async ( threadRef: ScopedThreadRef, requestedTabId: string | null, @@ -101,9 +177,15 @@ const currentStatus = async ( 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 }; + return { ...status, visible, ...viewportStatus }; } const navStatus = snapshot?.navStatus; return { @@ -113,6 +195,7 @@ const currentStatus = async ( url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, loading: navStatus?._tag === "Loading", + ...viewportStatus, }; }; @@ -144,6 +227,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) () => ({ clientId: automationClientId, environmentId, + supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], }), [automationClientId, environmentId], ); @@ -157,6 +241,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const open = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + const resize = useAtomCommand(previewEnvironment.resize, { + reportFailure: false, + }); const respondToAutomation = useAtomCommand( previewEnvironment.respondToAutomation, "preview automation response", @@ -272,6 +359,33 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); 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); + } + applyPreviewServerSnapshot(threadRef, result.value); + const viewport = await waitForRenderedViewport( + ready.tabId, + setting, + input.timeoutMs ?? request.timeoutMs, + ); + return { + tabId: ready.tabId, + setting, + viewport, + } satisfies PreviewAutomationResizeResult; + } case "snapshot": { const ready = await requireReadyTab(); return await ready.bridge.automation.snapshot(ready.tabId); @@ -352,7 +466,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }); } }, - [environmentId, listPreviews, open, registry], + [environmentId, listPreviews, open, registry, resize], ); const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); const setRequestHandler = useAtomSet(requestHandlerAtom); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 861a8df616b..7227b35e69c 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 { + applyPreviewServerSnapshot, + rememberPreviewUrl, + useThreadPreviewState, +} from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; @@ -20,10 +29,13 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { PreviewViewportControl } from "./PreviewViewportControl"; +import { subscribeBrowserViewportChange } from "~/browser/browserViewportActions"; 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 +72,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 +104,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 +148,36 @@ 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; + } + applyPreviewServerSnapshot(threadRef, result.value); + }, + [resize, tabId, threadRef], + ); + + useEffect(() => { + if (!tabId || !visible) return; + return subscribeBrowserViewportChange(tabId, handleViewportChange); + }, [handleViewportChange, tabId, visible]); + const handleBack = useCallback(() => { if (previewBridge && tabId) void previewBridge.goBack(tabId); }, [tabId]); @@ -523,11 +570,26 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } trailingActions={ previewBridge ? ( - +
+ + +
) : null } /> diff --git a/apps/web/src/components/preview/PreviewViewportControl.tsx b/apps/web/src/components/preview/PreviewViewportControl.tsx new file mode 100644 index 00000000000..a099b35c194 --- /dev/null +++ b/apps/web/src/components/preview/PreviewViewportControl.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { + PREVIEW_VIEWPORT_MAX_AREA, + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MIN_DIMENSION, + type PreviewViewportSetting, +} from "@t3tools/contracts"; +import { + PREVIEW_VIEWPORT_PRESETS, + previewViewportLabel, + resolvePreviewViewport, +} from "@t3tools/shared/previewViewport"; +import { MonitorSmartphone, RotateCw } from "lucide-react"; +import { type FormEvent, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; + +interface Props { + readonly setting: PreviewViewportSetting; + readonly disabled: boolean; + readonly fillSize: { readonly width: number; readonly height: number } | null; + readonly onChange: (setting: PreviewViewportSetting) => Promise; +} + +const PRESET_GROUPS = (["Desktop", "Tablet", "Phone"] as const).map((category) => ({ + category, + presets: PREVIEW_VIEWPORT_PRESETS.filter((preset) => preset.category === category), +})); + +export function PreviewViewportControl({ setting, disabled, fillSize, onChange }: Props) { + const [open, setOpen] = useState(false); + const [pending, setPending] = useState(false); + const [customSize, setCustomSize] = useState(() => ({ + width: setting._tag !== "fill" ? String(setting.width) : "1024", + height: setting._tag !== "fill" ? String(setting.height) : "768", + })); + + const apply = async (next: PreviewViewportSetting) => { + setPending(true); + try { + await onChange(next); + setOpen(false); + } catch { + // The caller reports the command failure; keep the chooser open for retry. + } finally { + setPending(false); + } + }; + + const width = Number(customSize.width); + const height = Number(customSize.height); + const customValid = + Number.isInteger(width) && + Number.isInteger(height) && + width >= PREVIEW_VIEWPORT_MIN_DIMENSION && + width <= PREVIEW_VIEWPORT_MAX_DIMENSION && + height >= PREVIEW_VIEWPORT_MIN_DIMENSION && + height <= PREVIEW_VIEWPORT_MAX_DIMENSION && + width * height <= PREVIEW_VIEWPORT_MAX_AREA; + + const submitCustom = (event: FormEvent) => { + event.preventDefault(); + if (!customValid) return; + void apply({ _tag: "freeform", width, height }); + }; + + const rotate = () => { + if (setting._tag === "fill") return; + void apply({ + ...setting, + width: setting.height, + height: setting.width, + }); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + const nextSize = setting._tag === "fill" ? fillSize : setting; + if (nextSize) { + setCustomSize({ + width: String(Math.round(nextSize.width)), + height: String(Math.round(nextSize.height)), + }); + } + } + setOpen(nextOpen); + }; + + return ( + + + } + > + + {previewViewportLabel(setting)} + + +
+
+
+

Browser viewport

+

Independent CSS-pixel sizing

+
+ +
+ +
+ + + {PRESET_GROUPS.map(({ category, presets }) => ( +
+

+ {category} +

+ {presets.map((preset) => { + const selected = setting._tag === "preset" && setting.presetId === preset.id; + return ( + + ); + })} +
+ ))} +
+ +
+

Custom size

+
+ + setCustomSize((current) => ({ ...current, width: event.target.value })) + } + aria-label="Viewport width" + /> + × + + setCustomSize((current) => ({ ...current, height: event.target.value })) + } + aria-label="Viewport height" + /> + +
+

+ {PREVIEW_VIEWPORT_MIN_DIMENSION}–{PREVIEW_VIEWPORT_MAX_DIMENSION}px per side, up to 4K + total area. +

+

+ Device presets change viewport size only; user agent and touch behavior remain + desktop. +

+
+
+
+
+ ); +} diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index d2bf2e7c260..7e0949e2164 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -108,6 +108,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, { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 7f8f8576130..ec00d403138 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -163,7 +163,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 = diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 831cea05be7..cb68c94f372 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -54,6 +54,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, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 144095ee25b..6a8dce87ca3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -69,6 +69,7 @@ import type { PreviewOpenInput, PreviewRefreshInput, PreviewReportStatusInput, + PreviewResizeInput, PreviewSessionSnapshot, } from "./preview.ts"; import { @@ -1154,6 +1155,7 @@ 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; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index e4e6757b441..2615f1936da 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -6,12 +6,24 @@ import { PreviewEvent, PreviewNavStatus, PreviewSessionSnapshot, + PreviewViewportSetting, } from "./preview.ts"; +import { + PreviewAutomationHost, + 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 decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); describe("PreviewNavStatus", () => { it("decodes Idle", () => { @@ -68,6 +80,88 @@ 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-8", orientation: "landscape" }), + ).toMatchObject({ mode: "preset", preset: "pixel-8" }); + 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("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 +198,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..330ec45464c 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -17,6 +17,72 @@ 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 = [ + "desktop-1920x1080", + "desktop-1440x900", + "laptop-1366x768", + "laptop-1280x800", + "ipad-pro-11", + "ipad-mini", + "iphone-15-pro", + "iphone-se", + "pixel-8", + "galaxy-s24", +] as const; + +export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS); +export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type; + +export const PreviewViewportSetting = Schema.Union([ + Schema.TaggedStruct("fill", {}), + Schema.TaggedStruct("freeform", { + ...PreviewViewportSize.fields, + }).check(viewportAreaFilter), + Schema.TaggedStruct("preset", { + ...PreviewViewportSize.fields, + presetId: PreviewViewportPresetId, + }).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 +108,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 +144,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 +185,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 +208,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 1cf4dcf461f..a9c9e162c07 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: "Common desktop, laptop, iPad, iPhone, Pixel, or Galaxy viewport preset.", + }), + ).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.", @@ -424,6 +518,11 @@ export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity 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; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 40c52db55db..48c5d9a774d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -103,6 +103,7 @@ import { PreviewOpenInput, PreviewRefreshInput, PreviewReportStatusInput, + PreviewResizeInput, PreviewSessionSnapshot, } from "./preview.ts"; import { @@ -190,6 +191,7 @@ export const WS_METHODS = { // Preview methods previewOpen: "preview.open", previewNavigate: "preview.navigate", + previewResize: "preview.resize", previewRefresh: "preview.refresh", previewClose: "preview.close", previewList: "preview.list", @@ -527,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]), @@ -722,6 +730,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeTerminalMetadataRpc, WsPreviewOpenRpc, WsPreviewNavigateRpc, + WsPreviewResizeRpc, WsPreviewRefreshRpc, WsPreviewCloseRpc, WsPreviewListRpc, 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..b406d427fbf --- /dev/null +++ b/packages/shared/src/previewViewport.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + 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-15-pro" })).toEqual({ + _tag: "preset", + width: 393, + height: 852, + presetId: "iphone-15-pro", + }); + expect( + resolvePreviewViewport({ + mode: "preset", + preset: "iphone-15-pro", + orientation: "landscape", + }), + ).toEqual({ + _tag: "preset", + width: 852, + height: 393, + presetId: "iphone-15-pro", + }); + }); + + 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..fd51fac6ba5 --- /dev/null +++ b/packages/shared/src/previewViewport.ts @@ -0,0 +1,135 @@ +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; + +const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = { + "desktop-1920x1080": { + label: "Desktop Full HD", + category: "Desktop", + detail: "1920 × 1080", + width: 1920, + height: 1080, + }, + "desktop-1440x900": { + label: "Desktop", + category: "Desktop", + detail: "1440 × 900", + width: 1440, + height: 900, + }, + "laptop-1366x768": { + label: "Laptop HD", + category: "Desktop", + detail: "1366 × 768", + width: 1366, + height: 768, + }, + "laptop-1280x800": { + label: "Laptop", + category: "Desktop", + detail: "1280 × 800", + width: 1280, + height: 800, + }, + "ipad-pro-11": { + label: 'iPad Pro 11"', + category: "Tablet", + detail: "834 × 1194", + width: 834, + height: 1194, + }, + "ipad-mini": { + label: "iPad mini", + category: "Tablet", + detail: "744 × 1133", + width: 744, + height: 1133, + }, + "iphone-15-pro": { + label: "iPhone 15 Pro", + category: "Phone", + detail: "393 × 852", + width: 393, + height: 852, + }, + "iphone-se": { + label: "iPhone SE", + category: "Phone", + detail: "375 × 667", + width: 375, + height: 667, + }, + "pixel-8": { + label: "Pixel 8", + category: "Phone", + detail: "412 × 915", + width: 412, + height: 915, + }, + "galaxy-s24": { + label: "Galaxy S24", + category: "Phone", + detail: "360 × 780", + width: 360, + height: 780, + }, +} 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"; +} From ae5cfec2a73fe4a5ec0cde528b66706a4a144a11 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 24 Jun 2026 23:35:01 -0700 Subject: [PATCH 04/12] Add preview device toolbar flow Co-authored-by: codex --- apps/web/src/browser/BrowserDeviceToolbar.tsx | 249 ++++++++++++++++++ .../browser/BrowserViewportResizeHandles.tsx | 170 ++++++++++++ apps/web/src/browser/HostedBrowserWebview.tsx | 167 ++++-------- .../browser/browserViewportActions.test.ts | 38 +++ .../web/src/browser/browserViewportActions.ts | 20 +- .../src/browser/browserViewportLayout.test.ts | 79 +++++- apps/web/src/browser/browserViewportLayout.ts | 139 +++++++++- .../src/browser/useBrowserViewportResize.ts | 213 +++++++++++++++ .../components/preview/PreviewMoreMenu.tsx | 16 +- .../src/components/preview/PreviewView.tsx | 41 ++- .../preview/PreviewViewportControl.tsx | 234 ---------------- 11 files changed, 983 insertions(+), 383 deletions(-) create mode 100644 apps/web/src/browser/BrowserDeviceToolbar.tsx create mode 100644 apps/web/src/browser/BrowserViewportResizeHandles.tsx create mode 100644 apps/web/src/browser/useBrowserViewportResize.ts delete mode 100644 apps/web/src/components/preview/PreviewViewportControl.tsx diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx new file mode 100644 index 00000000000..cd8eb284dda --- /dev/null +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -0,0 +1,249 @@ +"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 { RotateCw, 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 } from "./browserViewportLayout"; + +const RESPONSIVE_VALUE = "responsive"; +const PRESET_GROUPS = (["Desktop", "Tablet", "Phone"] as const).map((category) => ({ + category, + presets: PREVIEW_VIEWPORT_PRESETS.filter((preset) => preset.category === category), +})); +const SELECT_ITEMS = [ + { value: RESPONSIVE_VALUE, label: "Responsive" }, + ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), +]; + +interface Props { + readonly setting: Exclude; + readonly width: number; + readonly onChange: (setting: PreviewViewportSetting) => Promise; +} + +export function BrowserDeviceToolbar({ setting, width, 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" ? 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) => { + setPending(true); + void onChange(next).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 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 })); + }; + + const rotate = () => { + apply({ ...setting, width: setting.height, height: setting.width }); + }; + + return ( +
+ {width >= 480 ? ( + Dimensions: + ) : null} + + +
{ + event.preventDefault(); + applyCustomSize(); + }} + onBlur={(event) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + if ( + nextTarget instanceof HTMLElement && + nextTarget.closest("[data-browser-device-toolbar]") + ) { + setCustomSize(null); + return; + } + applyCustomSize(); + }} + > + + setCustomSize( + (current) => + current ?? { + width: String(setting.width), + height: String(setting.height), + }, + ) + } + onChange={(event) => + setCustomSize((current) => ({ + width: event.target.value, + height: current?.height ?? String(setting.height), + })) + } + aria-label="Viewport width" + aria-invalid={!customValid} + className={cn("text-center tabular-nums", width >= 360 ? "w-16" : "w-12")} + /> + × + + setCustomSize( + (current) => + current ?? { + width: String(setting.width), + height: String(setting.height), + }, + ) + } + onChange={(event) => + setCustomSize((current) => ({ + width: current?.width ?? String(setting.width), + height: event.target.value, + })) + } + aria-label="Viewport height" + aria-invalid={!customValid} + className={cn("text-center tabular-nums", width >= 360 ? "w-16" : "w-12")} + /> +
+ + + +
+ ); +} diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx new file mode 100644 index 00000000000..f4dc17db09c --- /dev/null +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -0,0 +1,170 @@ +"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 transition-colors hover:bg-foreground/[0.035] focus-visible:bg-foreground/[0.035]"; +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 rounded-full border border-border/80 bg-background/90 text-muted-foreground opacity-75 shadow-sm backdrop-blur-sm transition-[color,background-color,border-color,opacity,transform] duration-150 group-hover:scale-105 group-hover:border-foreground/35 group-hover:text-foreground group-hover:opacity-100 group-focus-visible:scale-105 group-focus-visible:border-ring group-focus-visible:text-foreground group-focus-visible:opacity-100 group-active:scale-95 group-active:bg-accent"; + +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/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 06cdd4b0feb..6308c29fbe3 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,15 +1,8 @@ "use client"; import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; -import { Scaling } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; -import { - useCallback, - useEffect, - useRef, - useState, - type PointerEvent as ReactPointerEvent, -} from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; @@ -17,10 +10,11 @@ import { cn } from "~/lib/utils"; import { useActiveBrowserRecordingTabId } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; -import { commitBrowserViewportChange } from "./browserViewportActions"; -import { resizeFreeformViewport, resolveBrowserViewportLayout } from "./browserViewportLayout"; +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; @@ -37,11 +31,6 @@ declare global { } } -const viewportSettingKey = (viewport: PreviewViewportSetting): string => - viewport._tag === "fill" - ? "fill" - : `${viewport._tag}:${viewport.width}:${viewport.height}:${viewport._tag === "preset" ? viewport.presetId : ""}`; - export function HostedBrowserWebview(props: { readonly threadRef: ScopedThreadRef; readonly tabId: string; @@ -53,14 +42,8 @@ export function HostedBrowserWebview(props: { const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); - const dragCleanupRef = useRef<(() => void) | null>(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); - const [dragViewport, setDragViewport] = useState<{ - readonly sourceKey: string; - readonly width: number; - readonly height: number; - } | null>(null); const presentation = useBrowserSurfaceStore( useShallow((state) => { const current = state.byTabId[tabId]; @@ -83,13 +66,6 @@ export function HostedBrowserWebview(props: { }; }, [tabId]); - useEffect( - () => () => { - dragCleanupRef.current?.(); - }, - [], - ); - const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); @@ -131,88 +107,30 @@ export function HostedBrowserWebview(props: { const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; - const sourceViewportKey = viewportSettingKey(viewport); - const effectiveViewport = - dragViewport?.sourceKey === sourceViewportKey - ? ({ - _tag: "freeform", - width: dragViewport.width, - height: dragViewport.height, - } as const satisfies PreviewViewportSetting) - : viewport; const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; const hiddenSize = - effectiveViewport._tag !== "fill" + viewport._tag !== "fill" ? { - width: effectiveViewport.width * normalizedZoomFactor, - height: effectiveViewport.height * normalizedZoomFactor, + width: viewport.width * normalizedZoomFactor, + height: viewport.height * normalizedZoomFactor, } : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; const containerSize = active && lastRect ? lastRect : hiddenSize; - const layout = resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor); - - const handleResizePointerDown = (event: ReactPointerEvent) => { - if (effectiveViewport._tag !== "freeform") return; - event.preventDefault(); - event.stopPropagation(); - dragCleanupRef.current?.(); - const pointerId = event.pointerId; - const target = event.currentTarget; - const startX = event.clientX; - const startY = event.clientY; - const startWidth = effectiveViewport.width; - const startHeight = effectiveViewport.height; - let latest = { width: startWidth, height: startHeight }; - try { - target.setPointerCapture(pointerId); - } catch { - // Window listeners below keep the drag functional when capture is unavailable. - } - - const move = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - moveEvent.preventDefault(); - const { width, height } = resizeFreeformViewport( - { width: startWidth, height: startHeight }, - { x: moveEvent.clientX - startX, y: moveEvent.clientY - startY }, - zoomFactor, - ); - latest = { width, height }; - setDragViewport({ sourceKey: sourceViewportKey, width, height }); - }; - 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(); - void commitBrowserViewportChange(tabId, { - _tag: "freeform", - width: latest.width, - height: latest.height, - }).then( - () => setDragViewport(null), - () => setDragViewport(null), - ); - } - function cancel(cancelEvent: PointerEvent) { - if (cancelEvent.pointerId !== pointerId) return; - cleanup(); - setDragViewport(null); - } - dragCleanupRef.current = cleanup; - window.addEventListener("pointermove", move, { passive: false }); - window.addEventListener("pointerup", finish); - window.addEventListener("pointercancel", cancel); - }; + const deviceToolbarVisible = active && viewport._tag !== "fill"; + const { + activeDrag, + commitViewportChange, + effectiveViewport, + handleResizeKeyDown, + handleResizePointerDown, + layout, + } = useBrowserViewportResize({ + tabId, + viewport, + zoomFactor, + containerSize, + deviceToolbarVisible, + }); const syncContentPresentation = useCallback(() => { const wrapper = wrapperRef.current; @@ -271,6 +189,13 @@ export function HostedBrowserWebview(props: { data-preview-viewport={tabId} >
+ {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( + + ) : null} - {active && effectiveViewport._tag === "freeform" ? ( - + {active && effectiveViewport._tag !== "fill" ? ( + <> + + {activeDrag ? ( + + ) : null} + ) : null}
diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index d6a074add9c..7fcda1bf283 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -30,4 +30,42 @@ describe("browserViewportActions", () => { }), ).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(); + }); }); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index db8f97cad36..d7224963d13 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -3,6 +3,7 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; const handlers = new Map(); +const commitTails = new Map>(); export function subscribeBrowserViewportChange( tabId: string, @@ -18,8 +19,19 @@ export function commitBrowserViewportChange( tabId: string, setting: PreviewViewportSetting, ): Promise { - const handler = handlers.get(tabId); - return handler - ? handler(setting) - : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + const previous = commitTails.get(tabId) ?? Promise.resolve(); + const commit = previous + .catch(() => undefined) + .then(() => { + const handler = handlers.get(tabId); + return handler + ? handler(setting) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + }); + commitTails.set(tabId, commit); + const clear = () => { + if (commitTails.get(tabId) === commit) commitTails.delete(tabId); + }; + void commit.then(clear, clear); + return commit; } diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index 29ab41bd70d..857d2c4a2b4 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { resizeFreeformViewport, resolveBrowserViewportLayout } from "./browserViewportLayout"; +import { + resizeBrowserViewportFromRail, + resizeFreeformViewport, + resolveBrowserDeviceViewportLayout, + resolveBrowserViewportLayout, + resolveResponsiveBrowserViewportSize, +} from "./browserViewportLayout"; describe("resolveBrowserViewportLayout", () => { it("fills the available surface in fill mode", () => { @@ -74,4 +80,75 @@ describe("resolveBrowserViewportLayout", () => { 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("reserves persistent device-toolbar rails around the guest viewport", () => { + expect( + resolveBrowserDeviceViewportLayout( + { width: 1200, height: 900 }, + { _tag: "freeform", width: 1120, height: 818 }, + ), + ).toEqual({ + canvasWidth: 1200, + canvasHeight: 900, + viewportX: 40, + viewportY: 42, + viewportWidth: 1120, + viewportHeight: 818, + fillsPanel: false, + }); + }); + + it("captures the available framed area when responsive mode is enabled", () => { + expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ + width: 1120, + height: 818, + }); + expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ + width: 560, + height: 409, + }); + }); + + 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 index e4d4a53a4de..0fcaabe36be 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -16,6 +16,35 @@ export interface BrowserViewportLayout { readonly fillsPanel: boolean; } +export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 42; +export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 40; + +export type BrowserViewportResizeDirection = + | "north" + | "northeast" + | "east" + | "southeast" + | "south" + | "southwest" + | "west" + | "northwest"; + +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, @@ -34,7 +63,7 @@ export function resolveBrowserViewportLayout( fillsPanel: true, }; } - const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); const renderedWidth = setting.width * normalizedZoomFactor; const renderedHeight = setting.height * normalizedZoomFactor; return { @@ -48,6 +77,26 @@ export function resolveBrowserViewportLayout( }; } +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: layout.canvasWidth + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2, + canvasHeight: + layout.canvasHeight + BROWSER_DEVICE_TOOLBAR_HEIGHT + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + 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)); @@ -55,12 +104,27 @@ export function resizeFreeformViewport( start: PreviewViewportSize, delta: { readonly x: number; readonly y: number }, zoomFactor = 1, + direction: BrowserViewportResizeDirection = "southeast", ): PreviewViewportSize { - const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; - let width = clampViewportDimension(Math.round(start.width + delta.x / normalizedZoomFactor)); - let height = clampViewportDimension(Math.round(start.height + delta.y / normalizedZoomFactor)); + 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; + let width = clampViewportDimension( + Math.round(start.width + horizontalDelta / normalizedZoomFactor), + ); + let height = clampViewportDimension( + Math.round(start.height + verticalDelta / normalizedZoomFactor), + ); if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height }; - if (Math.abs(delta.x) >= Math.abs(delta.y)) { + if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) { width = Math.max( PREVIEW_VIEWPORT_MIN_DIMENSION, Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height), @@ -73,3 +137,68 @@ export function resizeFreeformViewport( } 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", +): 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, + ); +} + +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..000df50678d --- /dev/null +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -0,0 +1,213 @@ +"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 { + resizeBrowserViewportFromRail, + resizeFreeformViewport, + resolveBrowserDeviceViewportArea, + resolveBrowserDeviceViewportLayout, + resolveBrowserViewportLayout, + type BrowserViewportResizeDirection, +} from "./browserViewportLayout"; + +interface ViewportDrag extends PreviewViewportSize { + readonly sourceKey: string; + readonly direction: BrowserViewportResizeDirection; +} + +const viewportSettingKey = (viewport: PreviewViewportSetting): string => + viewport._tag === "fill" + ? "fill" + : `${viewport._tag}:${viewport.width}:${viewport.height}:${viewport._tag === "preset" ? viewport.presetId : ""}`; + +export function useBrowserViewportResize(options: { + readonly tabId: string; + readonly viewport: PreviewViewportSetting; + readonly zoomFactor: number; + readonly containerSize: PreviewViewportSize; + readonly deviceToolbarVisible: boolean; +}) { + const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible } = options; + const dragCleanupRef = useRef<(() => void) | null>(null); + const dragVersionRef = useRef(0); + const [dragViewport, setDragViewport] = useState(null); + const sourceViewportKey = viewportSettingKey(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?.(); + }, + [], + ); + + const commitViewportChange = useCallback( + (next: PreviewViewportSetting) => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + 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 next = resizeFreeformViewport(effectiveViewport, delta, zoomFactor, direction); + if (next.width === effectiveViewport.width && next.height === effectiveViewport.height) return; + setDragViewport({ sourceKey: sourceViewportKey, ...next, direction }); + commitDrag({ _tag: "freeform", ...next }); + }; + + const handleResizePointerDown = ( + direction: BrowserViewportResizeDirection, + event: ReactPointerEvent, + ) => { + if (effectiveViewport._tag === "fill") return; + event.preventDefault(); + event.stopPropagation(); + 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; + 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, + zoomFactor, + direction, + ); + 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/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 7227b35e69c..74bea5a8732 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -29,8 +29,8 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; -import { PreviewViewportControl } from "./PreviewViewportControl"; import { subscribeBrowserViewportChange } from "~/browser/browserViewportActions"; +import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -173,6 +173,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, [resize, tabId, threadRef], ); + const handleToggleDeviceToolbar = () => { + if (viewport._tag !== "fill") { + void handleViewportChange(FILL_PREVIEW_VIEWPORT).catch(() => undefined); + return; + } + + const responsiveSize = panelRect + ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) + : { width: 1024, height: 768 }; + void handleViewportChange({ _tag: "freeform", ...responsiveSize }).catch(() => undefined); + }; + useEffect(() => { if (!tabId || !visible) return; return subscribeBrowserViewportChange(tabId, handleViewportChange); @@ -570,26 +582,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } trailingActions={ previewBridge ? ( -
- - -
+ ) : null } /> diff --git a/apps/web/src/components/preview/PreviewViewportControl.tsx b/apps/web/src/components/preview/PreviewViewportControl.tsx deleted file mode 100644 index a099b35c194..00000000000 --- a/apps/web/src/components/preview/PreviewViewportControl.tsx +++ /dev/null @@ -1,234 +0,0 @@ -"use client"; - -import { - PREVIEW_VIEWPORT_MAX_AREA, - PREVIEW_VIEWPORT_MAX_DIMENSION, - PREVIEW_VIEWPORT_MIN_DIMENSION, - type PreviewViewportSetting, -} from "@t3tools/contracts"; -import { - PREVIEW_VIEWPORT_PRESETS, - previewViewportLabel, - resolvePreviewViewport, -} from "@t3tools/shared/previewViewport"; -import { MonitorSmartphone, RotateCw } from "lucide-react"; -import { type FormEvent, useState } from "react"; - -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; -import { cn } from "~/lib/utils"; - -interface Props { - readonly setting: PreviewViewportSetting; - readonly disabled: boolean; - readonly fillSize: { readonly width: number; readonly height: number } | null; - readonly onChange: (setting: PreviewViewportSetting) => Promise; -} - -const PRESET_GROUPS = (["Desktop", "Tablet", "Phone"] as const).map((category) => ({ - category, - presets: PREVIEW_VIEWPORT_PRESETS.filter((preset) => preset.category === category), -})); - -export function PreviewViewportControl({ setting, disabled, fillSize, onChange }: Props) { - const [open, setOpen] = useState(false); - const [pending, setPending] = useState(false); - const [customSize, setCustomSize] = useState(() => ({ - width: setting._tag !== "fill" ? String(setting.width) : "1024", - height: setting._tag !== "fill" ? String(setting.height) : "768", - })); - - const apply = async (next: PreviewViewportSetting) => { - setPending(true); - try { - await onChange(next); - setOpen(false); - } catch { - // The caller reports the command failure; keep the chooser open for retry. - } finally { - setPending(false); - } - }; - - const width = Number(customSize.width); - const height = Number(customSize.height); - const customValid = - Number.isInteger(width) && - Number.isInteger(height) && - width >= PREVIEW_VIEWPORT_MIN_DIMENSION && - width <= PREVIEW_VIEWPORT_MAX_DIMENSION && - height >= PREVIEW_VIEWPORT_MIN_DIMENSION && - height <= PREVIEW_VIEWPORT_MAX_DIMENSION && - width * height <= PREVIEW_VIEWPORT_MAX_AREA; - - const submitCustom = (event: FormEvent) => { - event.preventDefault(); - if (!customValid) return; - void apply({ _tag: "freeform", width, height }); - }; - - const rotate = () => { - if (setting._tag === "fill") return; - void apply({ - ...setting, - width: setting.height, - height: setting.width, - }); - }; - - const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen) { - const nextSize = setting._tag === "fill" ? fillSize : setting; - if (nextSize) { - setCustomSize({ - width: String(Math.round(nextSize.width)), - height: String(Math.round(nextSize.height)), - }); - } - } - setOpen(nextOpen); - }; - - return ( - - - } - > - - {previewViewportLabel(setting)} - - -
-
-
-

Browser viewport

-

Independent CSS-pixel sizing

-
- -
- -
- - - {PRESET_GROUPS.map(({ category, presets }) => ( -
-

- {category} -

- {presets.map((preset) => { - const selected = setting._tag === "preset" && setting.presetId === preset.id; - return ( - - ); - })} -
- ))} -
- -
-

Custom size

-
- - setCustomSize((current) => ({ ...current, width: event.target.value })) - } - aria-label="Viewport width" - /> - × - - setCustomSize((current) => ({ ...current, height: event.target.value })) - } - aria-label="Viewport height" - /> - -
-

- {PREVIEW_VIEWPORT_MIN_DIMENSION}–{PREVIEW_VIEWPORT_MAX_DIMENSION}px per side, up to 4K - total area. -

-

- Device presets change viewport size only; user agent and touch behavior remain - desktop. -

-
-
-
-
- ); -} From 81d145d321f2172a9addf108f309b9e67a691c25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 11:32:44 -0700 Subject: [PATCH 05/12] Add locked-aspect preview viewport resizing - Add aspect-ratio locking and resize handles for preview device sizing - Scale oversized fixed viewports to fit the panel - Add timeout protection for queued viewport commits --- apps/server/src/mcp/toolkits/preview/tools.ts | 2 +- apps/web/src/browser/BrowserDeviceToolbar.tsx | 199 +++++++++++++----- .../browser/BrowserViewportResizeHandles.tsx | 31 ++- apps/web/src/browser/HostedBrowserWebview.tsx | 20 +- apps/web/src/browser/browserRecording.ts | 4 + .../src/browser/browserSurfaceStore.test.ts | 1 + apps/web/src/browser/browserSurfaceStore.ts | 2 + .../browser/browserViewportActions.test.ts | 35 +++ .../web/src/browser/browserViewportActions.ts | 29 ++- .../src/browser/browserViewportLayout.test.ts | 53 +++-- apps/web/src/browser/browserViewportLayout.ts | 111 ++++++++-- .../src/browser/useBrowserViewportResize.ts | 63 +++++- .../components/preview/AgentBrowserCursor.tsx | 3 +- .../preview/PreviewAutomationHosts.tsx | 49 +++-- .../src/components/preview/PreviewView.tsx | 16 +- .../preview/previewAutomationErrors.ts | 4 +- .../previewAutomationRequestConsumer.test.ts | 27 ++- .../preview/previewAutomationTarget.test.ts | 45 ++++ .../preview/previewAutomationTarget.ts | 25 +++ .../components/preview/usePreviewSession.ts | 5 +- apps/web/src/previewStateStore.test.ts | 60 ++++++ apps/web/src/previewStateStore.ts | 95 ++++++++- .../client-runtime/src/state/preview.test.ts | 18 ++ packages/client-runtime/src/state/preview.ts | 10 +- packages/contracts/src/preview.test.ts | 5 +- packages/contracts/src/preview.ts | 38 +++- packages/contracts/src/previewAutomation.ts | 2 +- packages/shared/src/previewViewport.test.ts | 39 +++- packages/shared/src/previewViewport.ts | 161 +++++++++----- 29 files changed, 920 insertions(+), 232 deletions(-) create mode 100644 apps/web/src/components/preview/previewAutomationTarget.test.ts create mode 100644 apps/web/src/components/preview/previewAutomationTarget.ts create mode 100644 packages/client-runtime/src/state/preview.test.ts diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index dde875cec40..d9567b01c3c 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -74,7 +74,7 @@ export const PreviewNavigateTool = safeBrowserTool( 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 common device preset. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-15-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", + "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, diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index cd8eb284dda..8f6501ec8e8 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -7,7 +7,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { RotateCw, X } from "lucide-react"; +import { Link2, X } from "lucide-react"; import { useState } from "react"; import { Button } from "~/components/ui/button"; @@ -23,25 +23,49 @@ import { } from "~/components/ui/select"; import { cn } from "~/lib/utils"; -import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "./browserViewportLayout"; +import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; const RESPONSIVE_VALUE = "responsive"; -const PRESET_GROUPS = (["Desktop", "Tablet", "Phone"] as const).map((category) => ({ - category, - presets: PREVIEW_VIEWPORT_PRESETS.filter((preset) => preset.category === category), -})); 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, onChange }: Props) { +export function BrowserDeviceToolbar({ + setting, + width, + aspectRatio, + onAspectRatioChange, + onChange, +}: Props) { const [pending, setPending] = useState(false); const [customSize, setCustomSize] = useState<{ readonly width: string; @@ -51,7 +75,11 @@ export function BrowserDeviceToolbar({ setting, width, onChange }: Props) { width: String(setting.width), height: String(setting.height), }; - const selectedValue = setting._tag === "preset" ? setting.presetId : RESPONSIVE_VALUE; + 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 = @@ -82,6 +110,34 @@ export function BrowserDeviceToolbar({ setting, width, onChange }: Props) { 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) { @@ -91,23 +147,50 @@ export function BrowserDeviceToolbar({ setting, width, onChange }: Props) { } const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); if (!preset) return; + if (aspectRatio !== null) onAspectRatioChange(preset.width / preset.height); apply(resolvePreviewViewport({ mode: "preset", preset: preset.id })); }; const rotate = () => { - apply({ ...setting, width: setting.height, height: setting.width }); + const hasCustomSize = + customValid && (customWidth !== setting.width || customHeight !== setting.height); + const source = hasCustomSize + ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const) + : setting; + if (aspectRatio !== null) onAspectRatioChange(1 / aspectRatio); + apply({ ...source, width: source.height, height: source.width }); + }; + + 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 >= 480 ? ( - Dimensions: + {width >= 560 ? ( + + Dimensions + ) : null}
{ event.preventDefault(); applyCustomSize(); }} - onBlur={(event) => { - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - if ( - nextTarget instanceof HTMLElement && - nextTarget.closest("[data-browser-device-toolbar]") - ) { - setCustomSize(null); - return; - } - applyCustomSize(); - }} > - setCustomSize((current) => ({ - width: event.target.value, - height: current?.height ?? String(setting.height), - })) - } + onChange={(event) => updateCustomDimension("width", event.target.value)} aria-label="Viewport width" aria-invalid={!customValid} - className={cn("text-center tabular-nums", width >= 360 ? "w-16" : "w-12")} + 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) => ({ - width: current?.width ?? String(setting.width), - height: event.target.value, - })) - } + onChange={(event) => updateCustomDimension("height", event.target.value)} aria-label="Viewport height" aria-invalid={!customValid} - className={cn("text-center tabular-nums", width >= 360 ? "w-16" : "w-12")} + 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 index f4dc17db09c..08e69c359a1 100644 --- a/apps/web/src/browser/BrowserViewportResizeHandles.tsx +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -30,9 +30,9 @@ interface Props { type HandleKind = "horizontal" | "vertical" | "corner"; const EDGE_BUTTON_CLASS = - "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none transition-colors hover:bg-foreground/[0.035] focus-visible:bg-foreground/[0.035]"; + "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 rounded-full border border-border/80 bg-background/90 text-muted-foreground opacity-75 shadow-sm backdrop-blur-sm transition-[color,background-color,border-color,opacity,transform] duration-150 group-hover:scale-105 group-hover:border-foreground/35 group-hover:text-foreground group-hover:opacity-100 group-focus-visible:scale-105 group-focus-visible:border-ring group-focus-visible:text-foreground group-focus-visible:opacity-100 group-active:scale-95 group-active:bg-accent"; + "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; @@ -68,30 +68,29 @@ function ResizeHandle(props: { {kind === "vertical" ? ( -
) : null} @@ -222,8 +226,10 @@ export function HostedBrowserWebview(props: { style={{ left: layout.viewportX, top: layout.viewportY, - width: layout.viewportWidth, - height: layout.viewportHeight, + width: layout.viewportWidth / layout.viewportScale, + height: layout.viewportHeight / layout.viewportScale, + transform: layout.viewportScale < 1 ? `scale(${layout.viewportScale})` : undefined, + transformOrigin: "top left", }} /> {active && effectiveViewport._tag !== "fill" ? ( diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index d6f8f153b0d..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"; diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 5f0eb7d3e88..f535f203f49 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -10,6 +10,7 @@ describe("browserSurfaceStore", () => { y: 0, width: 393, height: 852, + scale: 1, scrollLeft: 0, scrollTop: 0, }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 32a5525ab16..2552272c8e2 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -19,6 +19,7 @@ export interface BrowserSurfaceContentPresentation { readonly y: number; readonly width: number; readonly height: number; + readonly scale: number; readonly scrollLeft: number; readonly scrollTop: number; } @@ -73,6 +74,7 @@ export const useBrowserSurfaceStore = create()((set) = previous.y === content.y && previous.width === content.width && previous.height === content.height && + previous.scale === content.scale && previous.scrollLeft === content.scrollLeft && previous.scrollTop === content.scrollTop ) { diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index 7fcda1bf283..aa5818facd8 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -1,6 +1,8 @@ +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"; @@ -68,4 +70,37 @@ describe("browserViewportActions", () => { expect(calls).toEqual([800, 900]); unsubscribe(); }); + + it("releases the per-tab queue when a viewport handler never settles", async () => { + vi.useFakeTimers(); + try { + const never = new Promise(() => undefined); + const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise => undefined); + handler.mockImplementationOnce(() => never).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; + 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 index d7224963d13..289e67046ab 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -2,9 +2,36 @@ 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, + handler: BrowserViewportHandler, + setting: PreviewViewportSetting, +): 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([Promise.resolve().then(() => handler(setting)), timeout]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); +}; + export function subscribeBrowserViewportChange( tabId: string, handler: BrowserViewportHandler, @@ -25,7 +52,7 @@ export function commitBrowserViewportChange( .then(() => { const handler = handlers.get(tabId); return handler - ? handler(setting) + ? runHandlerWithTimeout(tabId, handler, setting) : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); }); commitTails.set(tabId, commit); diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index 857d2c4a2b4..edd432a4e95 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -17,6 +17,7 @@ describe("resolveBrowserViewportLayout", () => { viewportY: 0, viewportWidth: 700, viewportHeight: 500, + viewportScale: 1, fillsPanel: true, }); }); @@ -37,20 +38,20 @@ describe("resolveBrowserViewportLayout", () => { }); }); - it("creates a scrollable canvas for a larger fixed viewport", () => { - expect( - resolveBrowserViewportLayout( - { width: 600, height: 700 }, - { _tag: "freeform", width: 1440, height: 900 }, - ), - ).toMatchObject({ - canvasWidth: 1440, - canvasHeight: 900, + 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: 0, - viewportWidth: 1440, - viewportHeight: 900, + viewportY: 163, + viewportWidth: 600, + viewportHeight: 375, }); + expect(layout.viewportScale).toBeCloseTo(5 / 12); }); it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => { @@ -93,31 +94,41 @@ describe("resolveBrowserViewportLayout", () => { ).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: 1120, height: 818 }, + { _tag: "freeform", width: 1180, height: 858 }, ), ).toEqual({ canvasWidth: 1200, canvasHeight: 900, - viewportX: 40, - viewportY: 42, - viewportWidth: 1120, - viewportHeight: 818, + 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: 1120, - height: 818, + width: 1180, + height: 858, }); expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ - width: 560, - height: 409, + width: 590, + height: 429, }); }); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 0fcaabe36be..8b5874cd4a9 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -11,13 +11,16 @@ export interface BrowserViewportLayout { 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 = 42; -export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 40; +export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32; +export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; export type BrowserViewportResizeDirection = | "north" @@ -60,19 +63,28 @@ export function resolveBrowserViewportLayout( 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: Math.max(containerWidth, renderedWidth), - canvasHeight: Math.max(containerHeight, renderedHeight), - viewportX: Math.max(0, Math.round((containerWidth - renderedWidth) / 2)), - viewportY: Math.max(0, Math.round((containerHeight - renderedHeight) / 2)), - viewportWidth: renderedWidth, - viewportHeight: renderedHeight, + 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, }; } @@ -89,9 +101,8 @@ export function resolveBrowserDeviceViewportLayout( ); return { ...layout, - canvasWidth: layout.canvasWidth + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2, - canvasHeight: - layout.canvasHeight + BROWSER_DEVICE_TOOLBAR_HEIGHT + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + 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, }; @@ -100,11 +111,59 @@ export function resolveBrowserDeviceViewportLayout( 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") @@ -117,12 +176,28 @@ export function resizeFreeformViewport( : direction.includes("north") ? -delta.y : 0; - let width = clampViewportDimension( - Math.round(start.width + horizontalDelta / normalizedZoomFactor), - ); - let height = clampViewportDimension( - Math.round(start.height + verticalDelta / normalizedZoomFactor), - ); + 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( @@ -161,6 +236,7 @@ export function resizeBrowserViewportFromRail( available: PreviewViewportSize, zoomFactor = 1, direction: BrowserViewportResizeDirection = "southeast", + aspectRatio?: number, ): PreviewViewportSize { const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); const startWidth = start.width * normalizedZoomFactor; @@ -185,6 +261,7 @@ export function resizeBrowserViewportFromRail( }, normalizedZoomFactor, direction, + aspectRatio, ); } diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index 000df50678d..a09b1105d5b 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -25,6 +25,8 @@ interface ViewportDrag extends PreviewViewportSize { readonly direction: BrowserViewportResizeDirection; } +const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150; + const viewportSettingKey = (viewport: PreviewViewportSetting): string => viewport._tag === "fill" ? "fill" @@ -36,10 +38,13 @@ export function useBrowserViewportResize(options: { readonly zoomFactor: number; readonly containerSize: PreviewViewportSize; readonly deviceToolbarVisible: boolean; + readonly aspectRatio: number | null; }) { - const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible } = options; + 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 = viewportSettingKey(viewport); const sourceViewportKeyRef = useRef(sourceViewportKey); @@ -65,14 +70,34 @@ export function useBrowserViewportResize(options: { () => () => { 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); }, @@ -109,10 +134,29 @@ export function useBrowserViewportResize(options: { if (!delta) return; event.preventDefault(); event.stopPropagation(); - const next = resizeFreeformViewport(effectiveViewport, delta, zoomFactor, direction); - if (next.width === effectiveViewport.width && next.height === effectiveViewport.height) return; - setDragViewport({ sourceKey: sourceViewportKey, ...next, direction }); - commitDrag({ _tag: "freeform", ...next }); + 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 = ( @@ -122,6 +166,11 @@ export function useBrowserViewportResize(options: { 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; @@ -130,6 +179,7 @@ export function useBrowserViewportResize(options: { 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, @@ -160,8 +210,9 @@ export function useBrowserViewportResize(options: { y: moveEvent.clientY - startY, }, viewportContainerSize, - zoomFactor, + dragZoomFactor, direction, + aspectRatio ?? undefined, ); latest = { width, height }; setDragViewport({ sourceKey: sourceViewportKey, width, height, direction }); diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index 58f637990a8..ca6c2ff7235 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -38,6 +38,7 @@ function AgentBrowserCursorEvent(props: { readonly content: { readonly x: number; readonly y: number; + readonly scale: number; readonly scrollLeft: number; readonly scrollTop: number; } | null; @@ -57,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 + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor + (content?.y ?? 0) - (content?.scrollTop ?? 0)}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 index 66221d1f4d8..c91df45a588 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -21,10 +21,19 @@ import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Atom } from "effect/unstable/reactivity"; -import { applyPreviewServerSnapshot, readThreadPreviewState } from "~/previewStateStore"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + reconcilePreviewServerSessions, + updatePreviewServerSnapshot, +} from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; -import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; +import { + readActiveBrowserRecordingTabId, + startBrowserRecording, + stopBrowserRecording, +} from "~/browser/browserRecording"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { isElectron } from "~/env"; import { useEnvironments } from "~/state/environments"; @@ -42,6 +51,10 @@ import { } from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; import { createPreviewAutomationClientId } from "./previewAutomationClientId"; +import { + needsPreviewAutomationSessionSync, + resolvePreviewAutomationTarget, +} from "./previewAutomationTarget"; const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, @@ -172,8 +185,7 @@ const currentStatus = async ( requestedTabId: string | null, ): Promise => { const state = readThreadPreviewState(threadRef); - const tabId = requestedTabId ?? state.snapshot?.tabId ?? null; - const snapshot = (tabId ? state.sessions[tabId] : null) ?? state.snapshot; + const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); const visible = tabId ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) : false; @@ -264,9 +276,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) let tabId = request.tabId ?? null; try { let state = readThreadPreviewState(threadRef); - const needsSessionSync = - Object.keys(state.sessions).length === 0 || - (request.tabId !== undefined && state.sessions[request.tabId] === undefined); + const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); if (needsSessionSync) { const listTarget = { environmentId, @@ -277,9 +287,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) if (result._tag === "Failure") { throw squashAtomCommandFailure(result); } - for (const snapshot of result.value.sessions) { - applyPreviewServerSnapshot(threadRef, snapshot); - } + reconcilePreviewServerSessions(threadRef, result.value.sessions); state = readThreadPreviewState(threadRef); } tabId = request.tabId ?? state.snapshot?.tabId ?? null; @@ -335,7 +343,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) request.timeoutMs, ); if (reusedExistingTab && input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); + 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); } @@ -374,7 +393,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) if (result._tag === "Failure") { throw squashAtomCommandFailure(result); } - applyPreviewServerSnapshot(threadRef, result.value); + updatePreviewServerSnapshot(threadRef, result.value); const viewport = await waitForRenderedViewport( ready.tabId, setting, @@ -442,14 +461,14 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }; } case "recordingStop": { - const ready = await requireReadyTab(); - const artifact = await stopBrowserRecording(ready.tabId); + const recordingTabId = readActiveBrowserRecordingTabId(); + const artifact = recordingTabId ? await stopBrowserRecording(recordingTabId) : null; if (!artifact) { throw new PreviewAutomationRecordingNotActiveError({ requestId: request.requestId, environmentId, threadId: request.threadId, - tabId: ready.tabId, + tabId: recordingTabId ?? tabId, }); } return artifact; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 74bea5a8732..4e571e73d7c 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -13,8 +13,8 @@ import { useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { - applyPreviewServerSnapshot, rememberPreviewUrl, + updatePreviewServerSnapshot, useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; @@ -29,7 +29,10 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; -import { subscribeBrowserViewportChange } from "~/browser/browserViewportActions"; +import { + commitBrowserViewportChange, + subscribeBrowserViewportChange, +} from "~/browser/browserViewportActions"; import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; @@ -168,21 +171,24 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }); throw error; } - applyPreviewServerSnapshot(threadRef, result.value); + updatePreviewServerSnapshot(threadRef, result.value); }, [resize, tabId, threadRef], ); const handleToggleDeviceToolbar = () => { + if (!tabId) return; if (viewport._tag !== "fill") { - void handleViewportChange(FILL_PREVIEW_VIEWPORT).catch(() => undefined); + void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); return; } const responsiveSize = panelRect ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) : { width: 1024, height: 768 }; - void handleViewportChange({ _tag: "freeform", ...responsiveSize }).catch(() => undefined); + void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch( + () => undefined, + ); }; useEffect(() => { diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index 20d96f197e3..f7cab838be8 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -82,7 +82,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError requestId: TrimmedNonEmptyString, environmentId: EnvironmentId, threadId: ThreadId, - tabId: PreviewTabId, + tabId: Schema.NullOr(PreviewTabId), }, ) { get responseTag() { @@ -90,7 +90,7 @@ 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}.`; } } diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index f16dbe1b212..7928f41715e 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -9,7 +9,10 @@ import { import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; -import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; +import { + PreviewAutomationRecordingNotActiveError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom, serializePreviewAutomationError, @@ -242,6 +245,28 @@ 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("correlates unexpected failures without exposing cause details", () => { const cause = new Error("private bridge token: preview-secret"); const context = { 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/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 7e0949e2164..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; @@ -320,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 ec00d403138..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; @@ -229,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, @@ -244,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 cb68c94f372..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, ) { @@ -98,7 +106,7 @@ export function createPreviewEnvironmentAtoms( scheduler: automationScheduler, concurrency: { mode: "latest", - key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), + key: previewAutomationHostFocusConcurrencyKey, }, }), }; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 2615f1936da..70a1ac745fc 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -108,8 +108,9 @@ describe("PreviewAutomationResizeInput", () => { it("requires fields that match the selected mode", () => { expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" }); expect( - decodeResizeInput({ mode: "preset", preset: "pixel-8", orientation: "landscape" }), - ).toMatchObject({ mode: "preset", preset: "pixel-8" }); + 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(); }); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 330ec45464c..c00f878bcbe 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -52,20 +52,48 @@ export const PreviewRenderedViewportSize = Schema.Struct({ 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", - "ipad-mini", "iphone-15-pro", - "iphone-se", "pixel-8", "galaxy-s24", ] as const; -export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS); -export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type; +const StoredPreviewViewportPresetId = Schema.Literals([ + ...PREVIEW_VIEWPORT_PRESET_IDS, + ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS, +]); export const PreviewViewportSetting = Schema.Union([ Schema.TaggedStruct("fill", {}), @@ -74,7 +102,7 @@ export const PreviewViewportSetting = Schema.Union([ }).check(viewportAreaFilter), Schema.TaggedStruct("preset", { ...PreviewViewportSize.fields, - presetId: PreviewViewportPresetId, + presetId: StoredPreviewViewportPresetId, }).check(viewportAreaFilter), ]); export type PreviewViewportSetting = typeof PreviewViewportSetting.Type; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index a9c9e162c07..c725582f416 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -161,7 +161,7 @@ export const PreviewAutomationResizeInput = Schema.Struct({ }), preset: Schema.optional( PreviewViewportPresetId.annotate({ - description: "Common desktop, laptop, iPad, iPhone, Pixel, or Galaxy viewport preset.", + description: "Named viewport from Chrome DevTools' standard device catalog.", }), ).annotate({ description: "Named device size. Required only when mode is preset.", diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts index b406d427fbf..3222e90d7be 100644 --- a/packages/shared/src/previewViewport.test.ts +++ b/packages/shared/src/previewViewport.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + PREVIEW_VIEWPORT_PRESETS, previewViewportLabel, previewViewportPresetOrientation, resolvePreviewViewport, @@ -17,26 +18,48 @@ describe("previewViewport", () => { }); it("resolves device presets in either orientation", () => { - expect(resolvePreviewViewport({ mode: "preset", preset: "iphone-15-pro" })).toEqual({ + expect(resolvePreviewViewport({ mode: "preset", preset: "iphone-12-pro" })).toEqual({ _tag: "preset", - width: 393, - height: 852, - presetId: "iphone-15-pro", + width: 390, + height: 844, + presetId: "iphone-12-pro", }); expect( resolvePreviewViewport({ mode: "preset", - preset: "iphone-15-pro", + preset: "iphone-12-pro", orientation: "landscape", }), ).toEqual({ _tag: "preset", - width: 852, - height: 393, - presetId: "iphone-15-pro", + 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"); diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index fd51fac6ba5..1d70bca5dfb 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -16,56 +16,9 @@ export interface PreviewViewportPreset { 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 = { - "desktop-1920x1080": { - label: "Desktop Full HD", - category: "Desktop", - detail: "1920 × 1080", - width: 1920, - height: 1080, - }, - "desktop-1440x900": { - label: "Desktop", - category: "Desktop", - detail: "1440 × 900", - width: 1440, - height: 900, - }, - "laptop-1366x768": { - label: "Laptop HD", - category: "Desktop", - detail: "1366 × 768", - width: 1366, - height: 768, - }, - "laptop-1280x800": { - label: "Laptop", - category: "Desktop", - detail: "1280 × 800", - width: 1280, - height: 800, - }, - "ipad-pro-11": { - label: 'iPad Pro 11"', - category: "Tablet", - detail: "834 × 1194", - width: 834, - height: 1194, - }, - "ipad-mini": { - label: "iPad mini", - category: "Tablet", - detail: "744 × 1133", - width: 744, - height: 1133, - }, - "iphone-15-pro": { - label: "iPhone 15 Pro", - category: "Phone", - detail: "393 × 852", - width: 393, - height: 852, - }, "iphone-se": { label: "iPhone SE", category: "Phone", @@ -73,19 +26,117 @@ const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = { width: 375, height: 667, }, - "pixel-8": { - label: "Pixel 8", + "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, }, - "galaxy-s24": { - label: "Galaxy S24", + "samsung-galaxy-s8-plus": { + label: "Samsung Galaxy S8+", category: "Phone", - detail: "360 × 780", + detail: "360 × 740", width: 360, - height: 780, + 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; From 37fcd15a9b662c409a3f0f3f55326550afc1068a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 12:57:52 -0700 Subject: [PATCH 06/12] Audit preview zoom and viewport readiness - preserve live webview zoom and load failure state - make background preview typing work via runtime input - stabilize viewport readiness checks for fixed and fill modes --- apps/desktop/src/preview/Manager.test.ts | 193 ++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 164 +++++++++++---- apps/web/src/browser/HostedBrowserWebview.tsx | 2 + apps/web/src/browser/browserViewportLayout.ts | 5 + .../src/browser/useBrowserViewportResize.ts | 8 +- .../preview/PreviewAutomationHosts.tsx | 33 ++- .../preview/previewViewportReadiness.test.ts | 72 +++++++ .../preview/previewViewportReadiness.ts | 37 ++++ 8 files changed, 458 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/components/preview/previewViewportReadiness.test.ts create mode 100644 apps/web/src/components/preview/previewViewportReadiness.ts diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index acb0d783a82..aedd2082767 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -250,6 +250,142 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + withManager((manager) => + Effect.gen(function* () { + let effectiveZoom = 0.9; + const listeners = new Map void>(); + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 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 zoomFactors: number[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + zoomFactors.push(state.zoomFactor); + }), + ); + yield* manager.createTab("tab_zoom"); + yield* manager.registerWebview("tab_zoom", 42); + + expect(zoomFactors.at(-1)).toBe(0.9); + expect(setZoomFactor).not.toHaveBeenCalled(); + + effectiveZoom = 1.25; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(zoomFactors.at(-1)).toBe(1.25); + expect(setZoomFactor).not.toHaveBeenCalled(); + }), + ), + ); + + 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"); + }), + ), + ); + effectIt.effect("captures a PNG screenshot into browser artifacts", () => withManager((manager) => Effect.gen(function* () { @@ -511,6 +647,63 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("types in background webviews and enables native key input", () => + withManager((manager) => + Effect.gen(function* () { + const sendCommand = vi.fn(async (method: string, _params?: Record) => + method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + 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, + 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.automationPress("tab_input", { key: "x" }); + + const methods = sendCommand.mock.calls.map(([method]) => method); + const keyDownIndex = methods.indexOf("Input.dispatchKeyEvent"); + const enableIndex = methods.indexOf("Input.setIgnoreInputEvents"); + 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(); + expect(methods).not.toContain("Input.insertText"); + expect(enableIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeLessThan(keyDownIndex); + expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false }); + }), + ), + ); + 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..87004710db6 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -854,6 +854,19 @@ 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, @@ -1093,20 +1106,55 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { 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(), + ); + 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 = + current.navStatus.kind === "LoadFailed" && computedNavStatus.kind === "Success" + ? current.navStatus + : computedNavStatus; + const state: PreviewTabState = { + ...current, + navStatus, + canGoBack, + canGoForward, + zoomFactor, + 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 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, @@ -1280,6 +1328,11 @@ 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), ); @@ -1297,6 +1350,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* attachListeners(tabId, wc); runFork(ensureControlSession(wc).pipe(Effect.ignore)); + const zoomFactor = yield* attempt( + { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, + () => wc.getZoomFactor(), + ); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); @@ -1313,6 +1370,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 +1388,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 +1999,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 +2061,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 +2071,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 +2087,32 @@ 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}; + const inserted = text.length > 0 + ? document.execCommand("insertText", false, text) + : !clear || document.execCommand("delete", false); + 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 +2136,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 +2149,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* ( @@ -2100,6 +2170,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationPressInput, send: SendCommand, ) { + yield* prepareAutomationInput(send, false); const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { switch (modifier) { case "Alt": @@ -2529,6 +2600,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 +2716,7 @@ export const PreviewManagerError = Schema.Union([ PreviewAutomationDebuggerAttachedError, PreviewAutomationEvaluationError, PreviewAutomationTargetNotFoundError, + PreviewAutomationTargetNotEditableError, PreviewAutomationCoordinatesOutsideViewportError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 40da2b096ac..5a82fae30bd 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -10,6 +10,7 @@ import { cn } from "~/lib/utils"; import { useActiveBrowserRecordingTabId } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { browserViewportSettingKey } from "./browserViewportLayout"; import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; @@ -208,6 +209,7 @@ export function HostedBrowserWebview(props: { {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} data-preview-tab={tabId} data-preview-viewport-mode={effectiveViewport._tag} + data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)} data-preview-css-width={ effectiveViewport._tag === "fill" ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 8b5874cd4a9..ed5529fe138 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -32,6 +32,11 @@ export type BrowserViewportResizeDirection = | "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; diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index a09b1105d5b..4f05f6dd27d 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -12,6 +12,7 @@ import { import { commitBrowserViewportChange } from "./browserViewportActions"; import { + browserViewportSettingKey, resizeBrowserViewportFromRail, resizeFreeformViewport, resolveBrowserDeviceViewportArea, @@ -27,11 +28,6 @@ interface ViewportDrag extends PreviewViewportSize { const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150; -const viewportSettingKey = (viewport: PreviewViewportSetting): string => - viewport._tag === "fill" - ? "fill" - : `${viewport._tag}:${viewport.width}:${viewport.height}:${viewport._tag === "preset" ? viewport.presetId : ""}`; - export function useBrowserViewportResize(options: { readonly tabId: string; readonly viewport: PreviewViewportSetting; @@ -46,7 +42,7 @@ export function useBrowserViewportResize(options: { const keyboardCommitTimerRef = useRef | null>(null); const keyboardViewportRef = useRef(null); const [dragViewport, setDragViewport] = useState(null); - const sourceViewportKey = viewportSettingKey(viewport); + const sourceViewportKey = browserViewportSettingKey(viewport); const sourceViewportKeyRef = useRef(sourceViewportKey); sourceViewportKeyRef.current = sourceViewportKey; const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index c91df45a588..046bd5e9fd3 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -55,6 +55,7 @@ import { needsPreviewAutomationSessionSync, resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; +import { isPreviewViewportReady } from "./previewViewportReadiness"; const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, @@ -144,6 +145,16 @@ const readRenderedViewport = async (tabId: string): Promise { + 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, @@ -154,19 +165,19 @@ const waitForRenderedViewport = async ( while (Date.now() <= deadline) { try { const webview = findPreviewWebview(tabId); - const expectedWidth = Number(webview?.getAttribute("data-preview-css-width")); - const expectedHeight = Number(webview?.getAttribute("data-preview-css-height")); - const modeApplied = webview?.getAttribute("data-preview-viewport-mode") === setting._tag; - const viewport = webview && modeApplied ? await readWebviewViewport(webview) : null; - const tolerance = setting._tag === "fill" ? 1 : 0; + const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; + const declaredViewport = readDeclaredViewport(webview); + const renderedViewport = webview ? await readWebviewViewport(webview) : null; if ( - viewport && - Number.isInteger(expectedWidth) && - Number.isInteger(expectedHeight) && - Math.abs(viewport.width - expectedWidth) <= tolerance && - Math.abs(viewport.height - expectedHeight) <= tolerance + renderedViewport && + isPreviewViewportReady({ + setting, + appliedSettingKey, + declaredViewport, + renderedViewport, + }) ) { - return viewport; + return renderedViewport; } } catch (error) { // Registration and navigation can transiently replace the guest while 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 + ); +} From 15acfd66911dbe19856967e2407d4c710a215c16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 13:26:43 -0700 Subject: [PATCH 07/12] Ensure preview key cleanup runs on press failures - add cleanup commands for key-up and focus emulation - cover failure path in preview manager tests --- apps/desktop/src/preview/Manager.test.ts | 55 +++++++++++++++++++++--- apps/desktop/src/preview/Manager.ts | 49 ++++++++++++++++++--- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index aedd2082767..0ca72844088 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -650,9 +650,17 @@ describe("PreviewManager", () => { effectIt.effect("types in background webviews and enables native key input", () => withManager((manager) => Effect.gen(function* () { - const sendCommand = vi.fn(async (method: string, _params?: Record) => - method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined, - ); + let failKeyDown = false; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if ( + failKeyDown && + method === "Input.dispatchKeyEvent" && + params?.["type"] === "keyDown" + ) { + throw new Error("key dispatch failed"); + } + return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined; + }); fromId.mockReturnValue({ id: 42, isDestroyed: () => false, @@ -683,9 +691,24 @@ describe("PreviewManager", () => { yield* manager.automationType("tab_input", { text: "hello", clear: true }); yield* manager.automationPress("tab_input", { key: "x" }); - const methods = sendCommand.mock.calls.map(([method]) => method); - const keyDownIndex = methods.indexOf("Input.dispatchKeyEvent"); + 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" && @@ -698,8 +721,28 @@ describe("PreviewManager", () => { expect(typeEvaluation).toBeDefined(); expect(methods).not.toContain("Input.insertText"); expect(enableIndex).toBeGreaterThanOrEqual(0); - expect(enableIndex).toBeLessThan(keyDownIndex); + expect(enableIndex).toBeLessThan(focusOnIndex); + expect(focusOnIndex).toBeLessThan(keyDownIndex); + expect(keyDownIndex).toBeLessThan(keyUpIndex); + expect(keyUpIndex).toBeLessThan(focusOffIndex); 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, + text: "y", + unmodifiedText: "y", + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { + enabled: false, + }); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 87004710db6..619267be5f0 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -871,7 +871,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(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; @@ -912,7 +912,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, @@ -2169,6 +2184,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, input: PreviewAutomationPressInput, send: SendCommand, + sendCleanup: SendCommand, ) { yield* prepareAutomationInput(send, false); const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { @@ -2191,9 +2207,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function 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 }); + let keyReleased = false; + const releaseInput = Effect.gen(function* () { + if (!keyReleased) { + yield* sendCleanup("Input.dispatchKeyEvent", { type: "keyUp", ...params }).pipe( + Effect.ignore, + ); + } + yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe( + Effect.ignore, + ); + }); + + // Hidden/background guest WebContents do not have document focus until a user + // clicks them. CDP focus emulation makes native key dispatch deterministic + // without bringing another thread's preview to the foreground. + yield* Effect.gen(function* () { + yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }); + yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); + yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + keyReleased = true; + }).pipe(Effect.ensuring(releaseInput)); }); const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( @@ -2201,8 +2236,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, input, send, sendCleanup), ); }); From 239e5e7b8a37ae4ccdecf9f2a9fded094952a9b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 14:06:04 -0700 Subject: [PATCH 08/12] Tighten preview automation ownership and failure handling - Prevent viewport commits from overtaking newer updates after timeout - Preserve editable-target and viewport timeout errors across hosts - Restore focus after desktop key dispatch and add coverage --- apps/desktop/src/preview/Manager.test.ts | 115 +++++++++++++++--- apps/desktop/src/preview/Manager.ts | 63 +++++++--- .../src/mcp/PreviewAutomationBroker.test.ts | 44 +++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 31 +++++ .../src/browser/BrowserDeviceToolbar.test.ts | 38 ++++++ apps/web/src/browser/BrowserDeviceToolbar.tsx | 20 +-- .../src/browser/browserDeviceToolbarState.ts | 11 ++ .../src/browser/browserRecordingScope.test.ts | 12 ++ apps/web/src/browser/browserRecordingScope.ts | 6 + .../browser/browserViewportActions.test.ts | 12 +- .../web/src/browser/browserViewportActions.ts | 28 +++-- .../preview/PreviewAutomationHosts.tsx | 27 ++-- .../preview/previewAutomationErrors.ts | 89 +++++++++++++- .../previewAutomationRequestConsumer.test.ts | 57 +++++++++ packages/contracts/src/preview.test.ts | 30 +++++ packages/contracts/src/previewAutomation.ts | 21 ++++ 16 files changed, 538 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/browser/BrowserDeviceToolbar.test.ts create mode 100644 apps/web/src/browser/browserDeviceToolbarState.ts create mode 100644 apps/web/src/browser/browserRecordingScope.test.ts create mode 100644 apps/web/src/browser/browserRecordingScope.ts diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 0ca72844088..643c7336c08 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(); @@ -254,16 +266,21 @@ describe("PreviewManager", () => { 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: () => "https://example.com", + getURL: () => url, getTitle: () => "Example", isLoading: () => false, - getZoomFactor: () => effectiveZoom, + getZoomFactor: () => { + if (!zoomReadable) throw new Error("zoom unavailable"); + return effectiveZoom; + }, setZoomFactor, on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { listeners.set(event, listener); @@ -281,25 +298,37 @@ describe("PreviewManager", () => { off: vi.fn(), }, } as never); - const zoomFactors: number[] = []; + const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { - zoomFactors.push(state.zoomFactor); + states.push(state); }), ); yield* manager.createTab("tab_zoom"); yield* manager.registerWebview("tab_zoom", 42); - expect(zoomFactors.at(-1)).toBe(0.9); + expect(states.at(-1)?.zoomFactor).toBe(0.9); expect(setZoomFactor).not.toHaveBeenCalled(); effectiveZoom = 1.25; listeners.get("did-navigate")?.(); yield* Effect.yieldNow; - expect(zoomFactors.at(-1)).toBe(1.25); + 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); }), ), ); @@ -651,6 +680,7 @@ describe("PreviewManager", () => { 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 && @@ -659,8 +689,25 @@ describe("PreviewManager", () => { ) { throw new Error("key dispatch failed"); } + if (method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown") { + 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, @@ -669,11 +716,17 @@ describe("PreviewManager", () => { getTitle: () => "Example", isLoading: () => false, isDevToolsOpened: () => false, + focus, getZoomFactor: () => 1, setZoomFactor: vi.fn(), on: vi.fn(), off: vi.fn(), - ipc: { 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(), @@ -721,10 +774,19 @@ describe("PreviewManager", () => { expect(typeEvaluation).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(); @@ -743,6 +805,25 @@ describe("PreviewManager", () => { 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: "!", + modifiers: 0, + text: "!", + unmodifiedText: "!", + }); + expect(restoreFocus).toHaveBeenCalledTimes(3); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 619267be5f0..95af284edc2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -286,7 +286,7 @@ type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Ef type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } - | { readonly kind: "key"; readonly key: string; readonly code: string }; + | { readonly kind: "key"; readonly key: string; readonly code?: string }; interface ManagedListeners { readonly scope: Scope.Closeable; @@ -352,8 +352,7 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { value.kind === "key" && "key" in value && typeof value.key === "string" && - "code" in value && - typeof value.code === "string" + (!("code" in value) || typeof value.code === "string") ); }; @@ -370,10 +369,17 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): left.kind === "key" && right.kind === "key" && left.key === right.key && - left.code === right.code + (left.code === undefined || right.code === undefined || left.code === right.code) ); }; +const previewAutomationKeyCode = (key: string): string | undefined => { + if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`; + if (/^[0-9]$/.test(key)) return `Digit${key}`; + if (key === " ") return "Space"; + return key.length > 1 ? key : undefined; +}; + const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( artifactDirectory: string, ) { @@ -1124,7 +1130,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function 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(); @@ -1144,7 +1150,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - zoomFactor, + ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), updatedAt, }; return [ @@ -2182,6 +2188,7 @@ 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, @@ -2200,16 +2207,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }, 0); const key = input.key; + const code = previewAutomationKeyCode(key); const text = key.length === 1 ? key : undefined; const params = { key, - code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + ...(code === undefined ? {} : { code }), modifiers, ...(text ? { text, unmodifiedText: text } : {}), }; - let keyReleased = false; + const previouslyFocused = yield* attempt( + { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, + () => webContents.getFocusedWebContents(), + ); + let keyDownAttempted = false; const releaseInput = Effect.gen(function* () { - if (!keyReleased) { + if (keyDownAttempted) { yield* sendCleanup("Input.dispatchKeyEvent", { type: "keyUp", ...params }).pipe( Effect.ignore, ); @@ -2217,17 +2229,36 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function 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); + } }); - // Hidden/background guest WebContents do not have document focus until a user - // clicks them. CDP focus emulation makes native key dispatch deterministic - // without bringing another thread's preview to the foreground. + // 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, { kind: "key", key, code: params.code }); + yield* expectAgentInput(tabId, { + kind: "key", + key, + ...(code === undefined ? {} : { code }), + }); + keyDownAttempted = true; yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); - yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); - keyReleased = true; }).pipe(Effect.ensuring(releaseInput)); }); @@ -2237,7 +2268,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "press", (send, sendCleanup) => - performAutomationPress(tabId, input, send, sendCleanup), + performAutomationPress(tabId, wc, input, send, sendCleanup), ); }); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 0639e3fe70a..66003557303 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -6,6 +6,7 @@ import { PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, PreviewAutomationNoAvailableHostError, + PreviewAutomationTargetNotEditableError, PreviewTabId, ProviderInstanceId, ThreadId, @@ -179,6 +180,49 @@ 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* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 8314d914ee6..9f330e548c6 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -10,6 +10,7 @@ import { PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, + PreviewAutomationTargetNotEditableError, PreviewAutomationTimeoutError, PreviewAutomationUnsupportedClientError, type PreviewAutomationError, @@ -183,6 +184,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; diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts new file mode 100644 index 00000000000..087b5d109b4 --- /dev/null +++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts @@ -0,0 +1,38 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { commitViewportAndAspectRatio } 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(); + }); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index 8f6501ec8e8..f20ab0b3710 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -24,6 +24,7 @@ import { import { cn } from "~/lib/utils"; import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; +import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; const RESPONSIVE_VALUE = "responsive"; const SELECT_ITEMS = [ @@ -91,9 +92,9 @@ export function BrowserDeviceToolbar({ customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; - const apply = (next: PreviewViewportSetting) => { + const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { setPending(true); - void onChange(next).then( + void commitViewportAndAspectRatio(next, nextAspectRatio, onChange, onAspectRatioChange).then( () => { setPending(false); setCustomSize(null); @@ -147,8 +148,10 @@ export function BrowserDeviceToolbar({ } const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); if (!preset) return; - if (aspectRatio !== null) onAspectRatioChange(preset.width / preset.height); - apply(resolvePreviewViewport({ mode: "preset", preset: preset.id })); + apply( + resolvePreviewViewport({ mode: "preset", preset: preset.id }), + aspectRatio === null ? null : preset.width / preset.height, + ); }; const rotate = () => { @@ -157,8 +160,10 @@ export function BrowserDeviceToolbar({ const source = hasCustomSize ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const) : setting; - if (aspectRatio !== null) onAspectRatioChange(1 / aspectRatio); - apply({ ...source, width: source.height, height: source.width }); + apply( + { ...source, width: source.height, height: source.width }, + aspectRatio === null ? null : 1 / aspectRatio, + ); }; const toggleAspectRatio = () => { @@ -325,8 +330,7 @@ export function BrowserDeviceToolbar({ className="sticky right-0 ml-auto bg-background/95" disabled={pending} onClick={() => { - onAspectRatioChange(null); - apply({ _tag: "fill" }); + apply({ _tag: "fill" }, null); }} > diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts new file mode 100644 index 00000000000..70a8e597428 --- /dev/null +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -0,0 +1,11 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +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/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts new file mode 100644 index 00000000000..70976883ffe --- /dev/null +++ b/apps/web/src/browser/browserRecordingScope.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; + +describe("resolveBrowserRecordingStopTarget", () => { + it("only permits stopping the recording owned by the requested tab", () => { + expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); + expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); + expect(resolveBrowserRecordingStopTarget(null, "tab-b")).toBeNull(); + expect(resolveBrowserRecordingStopTarget("tab-a", null)).toBeNull(); + }); +}); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts new file mode 100644 index 00000000000..33a2116458c --- /dev/null +++ b/apps/web/src/browser/browserRecordingScope.ts @@ -0,0 +1,6 @@ +export function resolveBrowserRecordingStopTarget( + requestedTabId: string | null, + activeTabId: string | null, +): string | null { + return requestedTabId !== null && requestedTabId === activeTabId ? requestedTabId : null; +} diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index aa5818facd8..fd8f7809f30 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -71,12 +71,15 @@ describe("browserViewportActions", () => { unsubscribe(); }); - it("releases the per-tab queue when a viewport handler never settles", async () => { + it("does not let a timed-out handler overtake a newer viewport commit", async () => { vi.useFakeTimers(); try { - const never = new Promise(() => undefined); + let releaseFirst: (() => void) | undefined; + const delayed = new Promise((resolve) => { + releaseFirst = resolve; + }); const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise => undefined); - handler.mockImplementationOnce(() => never).mockResolvedValueOnce(undefined); + handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined); const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler); const first = commitBrowserViewportChange("tab-timeout", { _tag: "freeform", @@ -94,6 +97,9 @@ describe("browserViewportActions", () => { await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); await firstResult; + expect(handler).toHaveBeenCalledTimes(1); + + releaseFirst?.(); await second; expect(handler).toHaveBeenCalledTimes(2); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index 289e67046ab..eea176ebbff 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -15,11 +15,7 @@ export class BrowserViewportCommitTimeoutError extends Error { const handlers = new Map(); const commitTails = new Map>(); -const runHandlerWithTimeout = ( - tabId: string, - handler: BrowserViewportHandler, - setting: PreviewViewportSetting, -): Promise => { +const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise => { let timeoutId: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timeoutId = setTimeout( @@ -27,7 +23,7 @@ const runHandlerWithTimeout = ( BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, ); }); - return Promise.race([Promise.resolve().then(() => handler(setting)), timeout]).finally(() => { + return Promise.race([operation, timeout]).finally(() => { if (timeoutId !== undefined) clearTimeout(timeoutId); }); }; @@ -47,18 +43,24 @@ export function commitBrowserViewportChange( setting: PreviewViewportSetting, ): Promise { const previous = commitTails.get(tabId) ?? Promise.resolve(); - const commit = previous + const started = previous .catch(() => undefined) .then(() => { const handler = handlers.get(tabId); - return handler - ? runHandlerWithTimeout(tabId, handler, setting) + const operation = handler + ? Promise.resolve().then(() => handler(setting)) : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + return { operation }; }); - commitTails.set(tabId, commit); + // 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) === commit) commitTails.delete(tabId); + if (commitTails.get(tabId) === execution) commitTails.delete(tabId); }; - void commit.then(clear, clear); - return commit; + void execution.then(clear, clear); + return result; } diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 046bd5e9fd3..da5da45f38b 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -34,6 +34,7 @@ import { startBrowserRecording, stopBrowserRecording, } from "~/browser/browserRecording"; +import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { isElectron } from "~/env"; import { useEnvironments } from "~/state/environments"; @@ -48,6 +49,7 @@ import { PreviewAutomationOverlayTimeoutError, PreviewAutomationRecordingNotActiveError, PreviewAutomationTargetUnavailableError, + PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; import { createPreviewAutomationClientId } from "./previewAutomationClientId"; @@ -159,9 +161,13 @@ 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; - let lastError: unknown; while (Date.now() <= deadline) { try { const webview = findPreviewWebview(tabId); @@ -179,15 +185,16 @@ const waitForRenderedViewport = async ( ) { return renderedViewport; } - } catch (error) { + } catch { // Registration and navigation can transiently replace the guest while // React applies the server snapshot. Retry until the operation deadline. - lastError = error; } await new Promise((resolve) => window.setTimeout(resolve, 50)); } - throw new Error(`Timed out applying browser viewport for tab ${tabId}`, { - cause: lastError, + throw new PreviewAutomationViewportTimeoutError({ + ...context, + tabId, + timeoutMs, }); }; @@ -409,6 +416,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ready.tabId, setting, input.timeoutMs ?? request.timeoutMs, + { + requestId: request.requestId, + environmentId, + threadId: request.threadId, + }, ); return { tabId: ready.tabId, @@ -473,13 +485,14 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "recordingStop": { const recordingTabId = readActiveBrowserRecordingTabId(); - const artifact = recordingTabId ? await stopBrowserRecording(recordingTabId) : null; + const stopTabId = resolveBrowserRecordingStopTarget(tabId, recordingTabId); + const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; if (!artifact) { throw new PreviewAutomationRecordingNotActiveError({ requestId: request.requestId, environmentId, threadId: request.threadId, - tabId: recordingTabId ?? tabId, + tabId, }); } return artifact; diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index f7cab838be8..dcf35de53f2 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -56,6 +56,25 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC } } +export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationViewportTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`; + } +} + export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationTargetUnavailableError", { @@ -94,6 +113,61 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError } } +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", { @@ -108,8 +182,17 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass
 {
     });
   });
 
+  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 = {
diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts
index 70a1ac745fc..80044793873 100644
--- a/packages/contracts/src/preview.test.ts
+++ b/packages/contracts/src/preview.test.ts
@@ -10,6 +10,7 @@ import {
 } from "./preview.ts";
 import {
   PreviewAutomationHost,
+  PreviewAutomationError,
   PreviewAutomationResizeInput,
   PreviewAutomationResizeResult,
   PreviewAutomationStatus,
@@ -23,6 +24,7 @@ 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", () => {
@@ -142,6 +144,34 @@ describe("PreviewAutomationHost", () => {
   });
 });
 
+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 = {
diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts
index c725582f416..47dc7fc249e 100644
--- a/packages/contracts/src/previewAutomation.ts
+++ b/packages/contracts/src/previewAutomation.ts
@@ -721,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",
   {
@@ -786,6 +806,7 @@ export const PreviewAutomationError = Schema.Union([
   PreviewAutomationControlInterruptedError,
   PreviewAutomationExecutionError,
   PreviewAutomationInvalidSelectorError,
+  PreviewAutomationTargetNotEditableError,
   PreviewAutomationResultTooLargeError,
   PreviewAutomationClientDisconnectedError,
   PreviewAutomationRequestQueueClosedError,

From af8737489b64be2e8786e21930e268b026e18536 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Thu, 25 Jun 2026 14:46:50 -0700
Subject: [PATCH 09/12] Harden desktop dev launcher retry and env fallback

- Refresh launcher env only as a fallback for live dev runs
- Retry transient renderer load failures in development
- Ignore ambient `VITE_DEV_SERVER_URL` unless explicitly set
---
 apps/desktop/scripts/electron-launcher.mjs    | 50 +++++++---
 .../scripts/electron-launcher.test.mjs        | 28 ++++++
 apps/desktop/src/window/DesktopWindow.test.ts | 68 +++++++++++++
 apps/desktop/src/window/DesktopWindow.ts      | 98 ++++++++++++++++++-
 scripts/dev-runner.test.ts                    |  1 +
 scripts/dev-runner.ts                         | 13 +--
 6 files changed, 233 insertions(+), 25 deletions(-)
 create mode 100644 apps/desktop/scripts/electron-launcher.test.mjs

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/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index 76413dd0b55..aede651132d 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,70 @@ 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/"]]);
+        assert.equal(fakeWindow.reload.mock.calls.length, 1);
+
+        didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true);
+        didFinishLoad();
+        yield* TestClock.adjust(250);
+        assert.equal(fakeWindow.loadURL.mock.calls.length, 1);
+        assert.equal(fakeWindow.reload.mock.calls.length, 1);
+      }).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..4b23416804f 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()) {
+                window.webContents.reload();
+              }
+            }),
+          ),
+        ),
+      );
+      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/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+."),

From cfbdaf4b4a091baf98a6984f574cf42fb401f7df Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Thu, 25 Jun 2026 15:31:55 -0700
Subject: [PATCH 10/12] Fix preview keyboard dispatch

Build Chromium-compatible key packets with virtual key codes, distinguish text and raw keydown events, and guarantee keyup cleanup. Add focused coverage for named, printable, shortcut, and shifted keys.

Co-authored-by: codex 
---
 apps/desktop/src/preview/Manager.test.ts      |  16 +-
 apps/desktop/src/preview/Manager.ts           |  42 +----
 .../src/preview/PreviewKeyboard.test.ts       |  67 +++++++
 apps/desktop/src/preview/PreviewKeyboard.ts   | 163 ++++++++++++++++++
 4 files changed, 247 insertions(+), 41 deletions(-)
 create mode 100644 apps/desktop/src/preview/PreviewKeyboard.test.ts
 create mode 100644 apps/desktop/src/preview/PreviewKeyboard.ts

diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index 643c7336c08..44abddf5416 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -685,11 +685,14 @@ describe("PreviewManager", () => {
           if (
             failKeyDown &&
             method === "Input.dispatchKeyEvent" &&
-            params?.["type"] === "keyDown"
+            (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown")
           ) {
             throw new Error("key dispatch failed");
           }
-          if (method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown") {
+          if (
+            method === "Input.dispatchKeyEvent" &&
+            (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown")
+          ) {
             humanInput?.(
               {},
               {
@@ -799,8 +802,9 @@ describe("PreviewManager", () => {
           key: "y",
           code: "KeyY",
           modifiers: 0,
-          text: "y",
-          unmodifiedText: "y",
+          windowsVirtualKeyCode: 89,
+          location: 0,
+          isKeypad: false,
         });
         expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
           enabled: false,
@@ -819,7 +823,11 @@ describe("PreviewManager", () => {
         expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", {
           type: "keyDown",
           key: "!",
+          code: "Digit1",
           modifiers: 0,
+          windowsVirtualKeyCode: 49,
+          location: 0,
+          isKeypad: false,
           text: "!",
           unmodifiedText: "!",
         });
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 95af284edc2..5412bd6885e 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" }
@@ -373,13 +374,6 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal):
   );
 };
 
-const previewAutomationKeyCode = (key: string): string | undefined => {
-  if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
-  if (/^[0-9]$/.test(key)) return `Digit${key}`;
-  if (key === " ") return "Space";
-  return key.length > 1 ? key : undefined;
-};
-
 const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
   artifactDirectory: string,
 ) {
@@ -2194,27 +2188,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
     sendCleanup: SendCommand,
   ) {
     yield* prepareAutomationInput(send, false);
-    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;
-      }
-    }, 0);
-    const key = input.key;
-    const code = previewAutomationKeyCode(key);
-    const text = key.length === 1 ? key : undefined;
-    const params = {
-      key,
-      ...(code === undefined ? {} : { code }),
-      modifiers,
-      ...(text ? { text, unmodifiedText: text } : {}),
-    };
+    const keySequence = makePreviewAutomationKeySequence(input);
     const previouslyFocused = yield* attempt(
       { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
       () => webContents.getFocusedWebContents(),
@@ -2222,9 +2196,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
     let keyDownAttempted = false;
     const releaseInput = Effect.gen(function* () {
       if (keyDownAttempted) {
-        yield* sendCleanup("Input.dispatchKeyEvent", { type: "keyUp", ...params }).pipe(
-          Effect.ignore,
-        );
+        yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore);
       }
       yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(
         Effect.ignore,
@@ -2252,13 +2224,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
       );
       yield* send("Page.bringToFront");
       yield* send("Emulation.setFocusEmulationEnabled", { enabled: true });
-      yield* expectAgentInput(tabId, {
-        kind: "key",
-        key,
-        ...(code === undefined ? {} : { code }),
-      });
+      yield* expectAgentInput(tabId, keySequence.signal);
       keyDownAttempted = true;
-      yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
+      yield* send("Input.dispatchKeyEvent", keySequence.keyDown);
     }).pipe(Effect.ensuring(releaseInput));
   });
 
diff --git a/apps/desktop/src/preview/PreviewKeyboard.test.ts b/apps/desktop/src/preview/PreviewKeyboard.test.ts
new file mode 100644
index 00000000000..cac6871e7ad
--- /dev/null
+++ b/apps/desktop/src/preview/PreviewKeyboard.test.ts
@@ -0,0 +1,67 @@
+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" });
+  });
+});
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 },
+  };
+}

From 0ccff5e7064d2c0f3ac1eb00f9563e1d2dec22c4 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Thu, 25 Jun 2026 15:49:44 -0700
Subject: [PATCH 11/12] Harden preview lifecycle edge cases

Close the remaining review gaps around renderer retries, replacement streams, hidden viewport state, webview zoom restoration, navigation failure recovery, clear-only typing, and recording stop behavior.

Co-authored-by: codex 
---
 apps/desktop/src/preview/Manager.test.ts      | 50 ++++++++++++
 apps/desktop/src/preview/Manager.ts           | 78 +++++++++++++-----
 .../src/preview/PreviewKeyboard.test.ts       | 17 ++++
 apps/desktop/src/window/DesktopWindow.test.ts | 11 ++-
 apps/desktop/src/window/DesktopWindow.ts      |  2 +-
 .../server/src/mcp/PreviewAutomationBroker.ts | 80 ++++++++++++-------
 apps/web/src/browser/HostedBrowserWebview.tsx |  4 +-
 .../src/browser/browserRecordingScope.test.ts |  9 +--
 apps/web/src/browser/browserRecordingScope.ts |  7 +-
 .../src/browser/browserSurfaceStore.test.ts   | 16 +++-
 apps/web/src/browser/browserSurfaceStore.ts   | 20 +++++
 .../preview/PreviewAutomationHosts.tsx        |  2 +-
 .../src/components/preview/PreviewView.tsx    |  4 +-
 13 files changed, 230 insertions(+), 70 deletions(-)

diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index 44abddf5416..ab76e4a3f24 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -329,6 +329,36 @@ describe("PreviewManager", () => {
           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);
       }),
     ),
   );
@@ -411,6 +441,14 @@ describe("PreviewManager", () => {
         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");
       }),
     ),
   );
@@ -745,6 +783,7 @@ describe("PreviewManager", () => {
         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;
@@ -775,6 +814,17 @@ describe("PreviewManager", () => {
             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();
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 5412bd6885e..43b2259ead0 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -287,7 +287,7 @@ type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Ef
 
 type PreviewInputSignal =
   | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
-  | { readonly kind: "key"; readonly key: string; readonly code?: string };
+  | { readonly kind: "key"; readonly key: string; readonly code: string };
 
 interface ManagedListeners {
   readonly scope: Scope.Closeable;
@@ -353,7 +353,8 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
     value.kind === "key" &&
     "key" in value &&
     typeof value.key === "string" &&
-    (!("code" in value) || typeof value.code === "string")
+    "code" in value &&
+    typeof value.code === "string"
   );
 };
 
@@ -370,7 +371,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal):
     left.kind === "key" &&
     right.kind === "key" &&
     left.key === right.key &&
-    (left.code === undefined || right.code === undefined || left.code === right.code)
+    left.code === right.code
   );
 };
 
@@ -1119,7 +1120,9 @@ 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;
       const zoomFactor = yield* attempt(
         { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id },
@@ -1136,7 +1139,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
         // failed guest is no longer "loading", but it has not successfully
         // navigated anywhere. Keep the failure until a new load actually starts.
         const navStatus =
-          current.navStatus.kind === "LoadFailed" && computedNavStatus.kind === "Success"
+          preserveLoadFailure &&
+          current.navStatus.kind === "LoadFailed" &&
+          computedNavStatus.kind === "Success"
             ? current.navStatus
             : computedNavStatus;
         const state: PreviewTabState = {
@@ -1156,7 +1161,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
       });
       if (Option.isSome(next)) yield* emit(tabId, next.value);
     });
-    const sync = () => runFork(syncState());
+    const sync = () => runFork(syncState(true));
+    const syncNavigation = () => runFork(syncState(false));
     const failed = (
       _event: Event,
       code: number,
@@ -1224,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);
@@ -1236,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);
@@ -1353,22 +1359,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
       );
       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 zoomFactor = yield* attempt(
-      { operation: "registerWebview.getZoomFactor", tabId, webContentsId },
-      () => wc.getZoomFactor(),
-    );
     const registeredAt = yield* currentIso;
     const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
       const current = tabs.get(tabId);
@@ -2123,9 +2139,31 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
               }
             }
             const text = ${textJson};
-            const inserted = text.length > 0
-              ? document.execCommand("insertText", false, text)
-              : !clear || document.execCommand("delete", false);
+            let inserted = true;
+            if (text.length > 0) {
+              inserted = document.execCommand("insertText", false, text);
+            } else if (clear) {
+              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 };
diff --git a/apps/desktop/src/preview/PreviewKeyboard.test.ts b/apps/desktop/src/preview/PreviewKeyboard.test.ts
index cac6871e7ad..3acdcf0d1e5 100644
--- a/apps/desktop/src/preview/PreviewKeyboard.test.ts
+++ b/apps/desktop/src/preview/PreviewKeyboard.test.ts
@@ -64,4 +64,21 @@ describe("preview keyboard packets", () => {
     });
     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/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index aede651132d..7b9bdaf0886 100644
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -266,14 +266,17 @@ describe("DesktopWindow", () => {
         assert.equal(fakeWindow.loadURL.mock.calls.length, 1);
 
         yield* TestClock.adjust(100);
-        assert.deepEqual(fakeWindow.loadURL.mock.calls, [["t3code-dev://app/"]]);
-        assert.equal(fakeWindow.reload.mock.calls.length, 1);
+        assert.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, 1);
-        assert.equal(fakeWindow.reload.mock.calls.length, 1);
+        assert.equal(fakeWindow.loadURL.mock.calls.length, 2);
+        assert.equal(fakeWindow.reload.mock.calls.length, 0);
       }).pipe(Effect.provide(layer));
     }),
   );
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index 4b23416804f..41bbbfd5944 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -339,7 +339,7 @@ export const make = Effect.gen(function* () {
             Effect.sync(() => {
               developmentLoadRetryFiber = undefined;
               if (!window.isDestroyed()) {
-                window.webContents.reload();
+                loadApplication();
               }
             }),
           ),
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts
index 9f330e548c6..688383d7b1c 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.ts
@@ -104,6 +104,30 @@ interface BrokerState {
   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 => {
@@ -254,33 +278,12 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
     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 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) {
-          pending.delete(requestId);
-          disconnected.push(entry);
-        }
-      }
-      return [disconnected, { ...current, clients, assignments, pending }] as const;
-    });
     yield* Effect.forEach(
-      toFail,
+      disconnected,
       ({ deferred, context }) =>
         Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),
       { discard: true },
@@ -288,6 +291,17 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
     yield* Queue.shutdown(queue);
   });
 
+  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;
+    });
+    yield* closeConnection(queue, disconnected);
+  });
+
   const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
     host: PreviewAutomationHost,
   ) {
@@ -305,17 +319,25 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
       queue,
     };
     const registration = yield* SynchronizedRef.modify(state, (current) => {
-      const clients = new Map(current.clients);
-      const focusSequence = current.focusSequence + 1;
+      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: current.clients.get(clientId), registeredConnection },
-        { ...current, clients, focusSequence },
+        {
+          previousConnection,
+          disconnected: removed.disconnected,
+          registeredConnection,
+        },
+        { ...removed.state, clients, focusSequence },
       ] as const;
     });
     if (registration.previousConnection) {
-      yield* disconnect(clientId, registration.previousConnection.queue);
+      yield* closeConnection(registration.previousConnection.queue, registration.disconnected);
     }
     return registration.registeredConnection;
   });
diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
index 5a82fae30bd..9ea2a48da66 100644
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -9,7 +9,7 @@ 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 { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
 import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
@@ -50,7 +50,7 @@ export function HostedBrowserWebview(props: {
     useShallow((state) => {
       const current = state.byTabId[tabId];
       return {
-        rect: current?.rect ?? null,
+        rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId),
         visible: current?.visible ?? false,
       };
     }),
diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts
index 70976883ffe..ae91f1a7fdc 100644
--- a/apps/web/src/browser/browserRecordingScope.test.ts
+++ b/apps/web/src/browser/browserRecordingScope.test.ts
@@ -3,10 +3,9 @@ import { describe, expect, it } from "vite-plus/test";
 import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope";
 
 describe("resolveBrowserRecordingStopTarget", () => {
-  it("only permits stopping the recording owned by the requested tab", () => {
-    expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a");
-    expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull();
-    expect(resolveBrowserRecordingStopTarget(null, "tab-b")).toBeNull();
-    expect(resolveBrowserRecordingStopTarget("tab-a", null)).toBeNull();
+  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
index 33a2116458c..807b5d0c9a6 100644
--- a/apps/web/src/browser/browserRecordingScope.ts
+++ b/apps/web/src/browser/browserRecordingScope.ts
@@ -1,6 +1,3 @@
-export function resolveBrowserRecordingStopTarget(
-  requestedTabId: string | null,
-  activeTabId: string | null,
-): string | null {
-  return requestedTabId !== null && requestedTabId === activeTabId ? requestedTabId : null;
+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
index f535f203f49..1445303b8f7 100644
--- a/apps/web/src/browser/browserSurfaceStore.test.ts
+++ b/apps/web/src/browser/browserSurfaceStore.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vite-plus/test";
 
-import { useBrowserSurfaceStore } from "./browserSurfaceStore";
+import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
 
 describe("browserSurfaceStore", () => {
   it("tracks content dimensions for a browser that has never been visible", () => {
@@ -21,4 +21,18 @@ describe("browserSurfaceStore", () => {
       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 2552272c8e2..79095f0bdbb 100644
--- a/apps/web/src/browser/browserSurfaceStore.ts
+++ b/apps/web/src/browser/browserSurfaceStore.ts
@@ -31,6 +31,26 @@ interface BrowserSurfaceStoreState {
   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 &&
diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
index da5da45f38b..2cd8494691f 100644
--- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx
+++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
@@ -485,7 +485,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
           }
           case "recordingStop": {
             const recordingTabId = readActiveBrowserRecordingTabId();
-            const stopTabId = resolveBrowserRecordingStopTarget(tabId, recordingTabId);
+            const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId);
             const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;
             if (!artifact) {
               throw new PreviewAutomationRecordingNotActiveError({
diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx
index 4e571e73d7c..bb1c4d409eb 100644
--- a/apps/web/src/components/preview/PreviewView.tsx
+++ b/apps/web/src/components/preview/PreviewView.tsx
@@ -192,9 +192,9 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
   };
 
   useEffect(() => {
-    if (!tabId || !visible) return;
+    if (!tabId) return;
     return subscribeBrowserViewportChange(tabId, handleViewportChange);
-  }, [handleViewportChange, tabId, visible]);
+  }, [handleViewportChange, tabId]);
 
   const handleBack = useCallback(() => {
     if (previewBridge && tabId) void previewBridge.goBack(tabId);

From ba9d8e500abf63d69c38a2354c066ac304391672 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Thu, 25 Jun 2026 15:59:39 -0700
Subject: [PATCH 12/12] Sync preview aspect lock with viewport

Reconcile an active aspect-ratio lock with externally committed viewport dimensions and clear the lock when the browser returns to Fill mode.

Co-authored-by: codex 
---
 apps/web/src/browser/BrowserDeviceToolbar.test.ts | 13 ++++++++++++-
 apps/web/src/browser/HostedBrowserWebview.tsx     |  6 ++++++
 apps/web/src/browser/browserDeviceToolbarState.ts |  7 +++++++
 3 files changed, 25 insertions(+), 1 deletion(-)

diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts
index 087b5d109b4..ee4987794c3 100644
--- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts
+++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts
@@ -1,7 +1,10 @@
 import type { PreviewViewportSetting } from "@t3tools/contracts";
 import { describe, expect, it, vi } from "vite-plus/test";
 
-import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
+import {
+  commitViewportAndAspectRatio,
+  reconcileLockedAspectRatio,
+} from "./browserDeviceToolbarState";
 
 describe("commitViewportAndAspectRatio", () => {
   it("commits the aspect ratio only after the viewport succeeds", async () => {
@@ -36,3 +39,11 @@ describe("commitViewportAndAspectRatio", () => {
     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/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
index 9ea2a48da66..2907a067aad 100644
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -11,6 +11,7 @@ import { cn } from "~/lib/utils";
 import { useActiveBrowserRecordingTabId } from "./browserRecording";
 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";
@@ -112,6 +113,11 @@ export function HostedBrowserWebview(props: {
   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"
       ? {
diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts
index 70a8e597428..9986ee02282 100644
--- a/apps/web/src/browser/browserDeviceToolbarState.ts
+++ b/apps/web/src/browser/browserDeviceToolbarState.ts
@@ -1,5 +1,12 @@
 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,