authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-27 08:18:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-27 08:18:28-07:00
log44fb34ad5383badfb02c9f7daa1d8486277eeb01
tree3ce4b090b77b1eb13c0f1d12b0cdf3f286c75be9
parent6245c547c2d88e26434ac7b8e08213f2d9ef8577
signature Signed by PGP key B5690EEEBB952194

Stabilize preview browser surfaces, automation, and recording (#3565)


22 files changed, 1248 insertions(+), 207 deletions(-)

apps/server/src/mcp/McpHttpServer.test.ts+48-40
...@@ -15,6 +15,7 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";...@@ -15,6 +15,7 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
15const environmentId = EnvironmentId.make("environment-mcp-test");15const environmentId = EnvironmentId.make("environment-mcp-test");
16const threadId = ThreadId.make("thread-mcp-test");16const threadId = ThreadId.make("thread-mcp-test");
17const tabId = PreviewTabId.make("tab-mcp-test");17const tabId = PreviewTabId.make("tab-mcp-test");
18const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
18const invocation = {19const invocation = {
19 environmentId,20 environmentId,
20 threadId,21 threadId,
...@@ -154,49 +155,53 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -154,49 +155,53 @@ it.effect("registers annotated tools and preserves authenticated request context
154 Effect.gen(function* () {155 Effect.gen(function* () {
155 const server = yield* McpServer.McpServer;156 const server = yield* McpServer.McpServer;
156 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;157 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
158 const routedRequests: Array<{
159 readonly operation: string;
160 readonly tabId?: string | undefined;
161 }> = [];
157 const events = yield* broker.connect({162 const events = yield* broker.connect({
158 clientId: "mcp-test-client",163 clientId: "mcp-test-client",
159 environmentId,164 environmentId,
160 });165 });
161 yield* Stream.runForEach(events, (event) =>166 yield* Stream.runForEach(events, (event) => {
162 event.type === "connected"167 if (event.type === "connected") return Effect.void;
163 ? Effect.void168 routedRequests.push(event.request);
164 : broker.respond({169 return broker.respond({
165 clientId: "mcp-test-client",170 clientId: "mcp-test-client",
166 connectionId: event.connectionId,171 connectionId: event.connectionId,
167 requestId: event.request.requestId,172 requestId: event.request.requestId,
168 ok: true,173 ok: true,
169 result:174 result:
170 event.request.operation === "snapshot"175 event.request.operation === "snapshot"
171 ? {176 ? {
172 url: "http://example.test/",177 url: "http://example.test/",
173 title: "Example",178 title: "Example",
174 loading: false,179 loading: false,
175 visibleText: "Example",180 visibleText: "Example",
176 interactiveElements: [],181 interactiveElements: [],
177 accessibilityTree: {},182 accessibilityTree: {},
178 consoleEntries: [],183 consoleEntries: [],
179 networkEntries: [],184 networkEntries: [],
180 actionTimeline: [],185 actionTimeline: [],
181 screenshot: {186 screenshot: {
182 mimeType: "image/png",187 mimeType: "image/png",
183 data: Buffer.from("png").toString("base64"),188 data: Buffer.from("png").toString("base64"),
184 width: 10,189 width: 10,
185 height: 5,190 height: 5,
186 },191 },
187 }192 }
188 : event.request.operation === "press"193 : event.request.operation === "press"
189 ? undefined194 ? undefined
190 : {195 : {
191 available: true,196 available: true,
192 visible: true,197 visible: true,
193 tabId,198 tabId,
194 url: "http://example.test/",199 url: "http://example.test/",
195 title: "Example",200 title: "Example",
196 loading: false,201 loading: false,
197 },202 },
198 }),203 });
199 ).pipe(Effect.forkScoped);204 }).pipe(Effect.forkScoped);
200 yield* Effect.yieldNow;205 yield* Effect.yieldNow;
201206
202 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");207 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
...@@ -239,7 +244,7 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -239,7 +244,7 @@ it.effect("registers annotated tools and preserves authenticated request context
239 expect(malformed.isError).toBe(true);244 expect(malformed.isError).toBe(true);
240245
241 const snapshot = yield* server246 const snapshot = yield* server
242 .callTool({ name: "preview_snapshot", arguments: {} })247 .callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } })
243 .pipe(248 .pipe(
244 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),249 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
245 Effect.provideService(McpSchema.McpServerClient, client),250 Effect.provideService(McpSchema.McpServerClient, client),
...@@ -249,6 +254,9 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -249,6 +254,9 @@ it.effect("registers annotated tools and preserves authenticated request context
249 expect(snapshot.structuredContent).toMatchObject({254 expect(snapshot.structuredContent).toMatchObject({
250 screenshot: { mimeType: "image/png", width: 10, height: 5 },255 screenshot: { mimeType: "image/png", width: 10, height: 5 },
251 });256 });
257 expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe(
258 alternateTabId,
259 );
252260
253 const press = yield* server261 const press = yield* server
254 .callTool({ name: "preview_press", arguments: { key: "Enter" } })262 .callTool({ name: "preview_press", arguments: { key: "Enter" } })
apps/server/src/mcp/PreviewAutomationBroker.test.ts+242-6
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 type PreviewAutomationStreamEvent,15 type PreviewAutomationStreamEvent,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
18import * as Deferred from "effect/Deferred";
18import * as Fiber from "effect/Fiber";19import * as Fiber from "effect/Fiber";
19import * as Result from "effect/Result";20import * as Result from "effect/Result";
20import * as Stream from "effect/Stream";21import * as Stream from "effect/Stream";
...@@ -84,6 +85,187 @@ it.effect("atomically registers a connected host and correlates its response", (...@@ -84,6 +85,187 @@ it.effect("atomically registers a connected host and correlates its response", (
84 ),85 ),
85);86);
8687
88it.effect("targets multiple tabs explicitly while retaining a default tab", () =>
89 Effect.scoped(
90 Effect.gen(function* () {
91 const broker = yield* makeBroker;
92 const appTabId = PreviewTabId.make("tab-web-app");
93 const simulatorTabId = PreviewTabId.make("tab-ios-simulator");
94 const openedTabIds = [appTabId, simulatorTabId];
95 let openIndex = 0;
96 const routedRequests: RoutedRequest[] = [];
97 const requests = requestsFrom(yield* broker.connect(makeHost()));
98 yield* Stream.runForEach(requests, (request) => {
99 routedRequests.push(request);
100 return broker.respond({
101 clientId: "client-1",
102 connectionId: request.connectionId,
103 requestId: request.requestId,
104 ok: true,
105 result:
106 request.operation === "open"
107 ? { available: true, tabId: openedTabIds[openIndex++] }
108 : { url: "http://localhost:3200" },
109 });
110 }).pipe(Effect.forkScoped);
111 yield* Effect.yieldNow;
112
113 yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } });
114 yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } });
115 yield* broker.invoke({ scope, operation: "snapshot", input: {} });
116 yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId });
117 yield* broker.invoke({ scope, operation: "snapshot", input: {} });
118
119 expect(routedRequests).toHaveLength(5);
120 expect(routedRequests[0]?.tabId).toBeUndefined();
121 expect(routedRequests[1]?.tabId).toBe(appTabId);
122 expect(routedRequests[2]?.tabId).toBe(simulatorTabId);
123 expect(routedRequests[2]?.tabIdExplicit).toBe(false);
124 expect(routedRequests[3]?.tabId).toBe(appTabId);
125 expect(routedRequests[3]?.tabIdExplicit).toBe(true);
126 expect(routedRequests[4]?.tabId).toBe(appTabId);
127 }),
128 ),
129);
130
131it.effect("does not let an older response replace a newer explicit tab target", () =>
132 Effect.scoped(
133 Effect.gen(function* () {
134 const broker = yield* makeBroker;
135 const olderTabId = PreviewTabId.make("tab-older-request");
136 const newerTabId = PreviewTabId.make("tab-newer-request");
137 const releaseOlderResponse = yield* Deferred.make<void>();
138 const routedRequests: RoutedRequest[] = [];
139 const requests = requestsFrom(yield* broker.connect(makeHost()));
140 yield* Stream.runForEach(requests, (request) => {
141 routedRequests.push(request);
142 const response = Effect.gen(function* () {
143 if (request.tabId === olderTabId) {
144 yield* Deferred.await(releaseOlderResponse);
145 }
146 yield* broker.respond({
147 clientId: "client-1",
148 connectionId: request.connectionId,
149 requestId: request.requestId,
150 ok: true,
151 result: { url: "http://localhost:3200" },
152 });
153 if (request.tabId === newerTabId) {
154 yield* Deferred.succeed(releaseOlderResponse, undefined);
155 }
156 });
157 return response.pipe(Effect.forkScoped, Effect.asVoid);
158 }).pipe(Effect.forkScoped);
159 yield* Effect.yieldNow;
160
161 const older = yield* broker
162 .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId })
163 .pipe(Effect.forkScoped);
164 yield* Effect.yieldNow;
165 const newer = yield* broker
166 .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId })
167 .pipe(Effect.forkScoped);
168 yield* Fiber.join(newer);
169 yield* Fiber.join(older);
170 yield* broker.invoke({ scope, operation: "snapshot", input: {} });
171
172 expect(routedRequests.at(-1)?.tabId).toBe(newerTabId);
173 }),
174 ),
175);
176
177it.effect("does not replace the default tab with a globally stopped recording tab", () =>
178 Effect.scoped(
179 Effect.gen(function* () {
180 const broker = yield* makeBroker;
181 const browsingTabId = PreviewTabId.make("tab-session-b");
182 const recordingTabId = PreviewTabId.make("tab-session-a-recording");
183 const routedRequests: RoutedRequest[] = [];
184 const requests = requestsFrom(yield* broker.connect(makeHost()));
185 yield* Stream.runForEach(requests, (request) => {
186 routedRequests.push(request);
187 return broker.respond({
188 clientId: "client-1",
189 connectionId: request.connectionId,
190 requestId: request.requestId,
191 ok: true,
192 result:
193 request.operation === "open"
194 ? { available: true, tabId: browsingTabId }
195 : request.operation === "recordingStop"
196 ? { id: "recording-1", tabId: recordingTabId }
197 : { url: "http://localhost:3200" },
198 });
199 }).pipe(Effect.forkScoped);
200 yield* Effect.yieldNow;
201
202 yield* broker.invoke({ scope, operation: "open", input: {} });
203 yield* broker.invoke({ scope, operation: "recordingStop", input: {} });
204 yield* broker.invoke({ scope, operation: "snapshot", input: {} });
205
206 expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId);
207 }),
208 ),
209);
210
211it.effect("does not let a no-tab response suppress an earlier tab decision", () =>
212 Effect.scoped(
213 Effect.gen(function* () {
214 const broker = yield* makeBroker;
215 const initialTabId = PreviewTabId.make("tab-initial");
216 const openedTabId = PreviewTabId.make("tab-opened-late");
217 const releaseOpenResponse = yield* Deferred.make<void>();
218 const routedRequests: RoutedRequest[] = [];
219 const requests = requestsFrom(yield* broker.connect(makeHost()));
220 yield* Stream.runForEach(requests, (request) => {
221 routedRequests.push(request);
222 const marker =
223 typeof request.input === "object" && request.input !== null && "marker" in request.input
224 ? request.input.marker
225 : undefined;
226 const response = Effect.gen(function* () {
227 if (marker === "older") {
228 yield* Deferred.await(releaseOpenResponse);
229 }
230 yield* broker.respond({
231 clientId: "client-1",
232 connectionId: request.connectionId,
233 requestId: request.requestId,
234 ok: true,
235 result:
236 request.operation === "open"
237 ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId }
238 : { url: "http://localhost:3200" },
239 });
240 if (marker === "newer") {
241 yield* Deferred.succeed(releaseOpenResponse, undefined);
242 }
243 });
244 return response.pipe(Effect.forkScoped, Effect.asVoid);
245 }).pipe(Effect.forkScoped);
246 yield* Effect.yieldNow;
247
248 yield* broker.invoke({ scope, operation: "open", input: {} });
249 const older = yield* broker
250 .invoke({
251 scope,
252 operation: "open",
253 input: { marker: "older", reuseExistingTab: false },
254 })
255 .pipe(Effect.forkScoped);
256 yield* Effect.yieldNow;
257 const newer = yield* broker
258 .invoke({ scope, operation: "snapshot", input: { marker: "newer" } })
259 .pipe(Effect.forkScoped);
260 yield* Fiber.join(newer);
261 yield* Fiber.join(older);
262 yield* broker.invoke({ scope, operation: "snapshot", input: {} });
263
264 expect(routedRequests.at(-1)?.tabId).toBe(openedTabId);
265 }),
266 ),
267);
268
87it.effect("announces a live replacement stream before delivering requests", () =>269it.effect("announces a live replacement stream before delivering requests", () =>
88 Effect.scoped(270 Effect.scoped(
89 Effect.gen(function* () {271 Effect.gen(function* () {
...@@ -632,7 +814,9 @@ it.effect("fails over a pinned provider session only after its host disconnects"...@@ -632,7 +814,9 @@ it.effect("fails over a pinned provider session only after its host disconnects"
632 Effect.scoped(814 Effect.scoped(
633 Effect.gen(function* () {815 Effect.gen(function* () {
634 const broker = yield* makeBroker;816 const broker = yield* makeBroker;
817 const firstTabId = PreviewTabId.make("tab-on-first-host");
635 let firstConnectionId = "";818 let firstConnectionId = "";
819 let secondRoutedTabId: PreviewTabId | undefined;
636 const firstRequests = requestsFrom(820 const firstRequests = requestsFrom(
637 yield* broker.connect(makeHost({ clientId: "client-first" })),821 yield* broker.connect(makeHost({ clientId: "client-first" })),
638 (connectionId) => {822 (connectionId) => {
...@@ -648,18 +832,19 @@ it.effect("fails over a pinned provider session only after its host disconnects"...@@ -648,18 +832,19 @@ it.effect("fails over a pinned provider session only after its host disconnects"
648 connectionId: request.connectionId,832 connectionId: request.connectionId,
649 requestId: request.requestId,833 requestId: request.requestId,
650 ok: true,834 ok: true,
651 result: "first",835 result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first",
652 }),836 }),
653 ).pipe(Effect.forkScoped);837 ).pipe(Effect.forkScoped);
654 yield* Stream.runForEach(secondRequests, (request) =>838 yield* Stream.runForEach(secondRequests, (request) => {
655 broker.respond({839 secondRoutedTabId = request.tabId;
840 return broker.respond({
656 clientId: "client-second",841 clientId: "client-second",
657 connectionId: request.connectionId,842 connectionId: request.connectionId,
658 requestId: request.requestId,843 requestId: request.requestId,
659 ok: true,844 ok: true,
660 result: "second",845 result: "second",
661 }),846 });
662 ).pipe(Effect.forkScoped);847 }).pipe(Effect.forkScoped);
663 yield* Effect.yieldNow;848 yield* Effect.yieldNow;
664849
665 yield* broker.focusHost({850 yield* broker.focusHost({
...@@ -668,7 +853,10 @@ it.effect("fails over a pinned provider session only after its host disconnects"...@@ -668,7 +853,10 @@ it.effect("fails over a pinned provider session only after its host disconnects"
668 connectionId: firstConnectionId,853 connectionId: firstConnectionId,
669 focused: true,854 focused: true,
670 });855 });
671 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe("first");856 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
857 host: "first",
858 tabId: firstTabId,
859 });
672860
673 yield* Fiber.interrupt(firstConsumer);861 yield* Fiber.interrupt(firstConsumer);
674 yield* Effect.yieldNow;862 yield* Effect.yieldNow;
...@@ -676,6 +864,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"...@@ -676,6 +864,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"
676 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(864 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
677 "second",865 "second",
678 );866 );
867 expect(secondRoutedTabId).toBeUndefined();
679 }),868 }),
680 ),869 ),
681);870);
...@@ -740,6 +929,53 @@ it.effect("keeps a replacement stream authoritative when the old stream finalize...@@ -740,6 +929,53 @@ it.effect("keeps a replacement stream authoritative when the old stream finalize
740 ),929 ),
741);930);
742931
932it.effect("does not carry a tab id across a replacement automation stream", () =>
933 Effect.scoped(
934 Effect.gen(function* () {
935 const broker = yield* makeBroker;
936 const openedTabId = PreviewTabId.make("tab-first-webcontents");
937 const firstRequests = requestsFrom(yield* broker.connect(makeHost()));
938 yield* Stream.runForEach(firstRequests, (request) =>
939 broker.respond({
940 clientId: "client-1",
941 connectionId: request.connectionId,
942 requestId: request.requestId,
943 ok: true,
944 result:
945 request.operation === "open"
946 ? { host: "first", tabId: openedTabId }
947 : { host: "first" },
948 }),
949 ).pipe(Effect.forkScoped);
950 yield* Effect.yieldNow;
951
952 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
953 host: "first",
954 tabId: openedTabId,
955 });
956
957 const routedRequests: RoutedRequest[] = [];
958 const replacementRequests = requestsFrom(yield* broker.connect(makeHost()));
959 yield* Stream.runForEach(replacementRequests, (request) => {
960 routedRequests.push(request);
961 return broker.respond({
962 clientId: "client-1",
963 connectionId: request.connectionId,
964 requestId: request.requestId,
965 ok: true,
966 result: "replacement",
967 });
968 }).pipe(Effect.forkScoped);
969 yield* Effect.yieldNow;
970
971 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
972 "replacement",
973 );
974 expect(routedRequests.at(-1)?.tabId).toBeUndefined();
975 }),
976 ),
977);
978
743it.effect("fails requests assigned to the stream that is replaced", () =>979it.effect("fails requests assigned to the stream that is replaced", () =>
744 Effect.scoped(980 Effect.scoped(
745 Effect.gen(function* () {981 Effect.gen(function* () {
apps/server/src/mcp/PreviewAutomationBroker.ts+56-6
...@@ -13,13 +13,13 @@ import {...@@ -13,13 +13,13 @@ import {
13 PreviewAutomationTargetNotEditableError,13 PreviewAutomationTargetNotEditableError,
14 PreviewAutomationTimeoutError,14 PreviewAutomationTimeoutError,
15 PreviewAutomationUnsupportedClientError,15 PreviewAutomationUnsupportedClientError,
16 PreviewTabId,
16 type PreviewAutomationError,17 type PreviewAutomationError,
17 type PreviewAutomationOperation,18 type PreviewAutomationOperation,
18 type PreviewAutomationHost,19 type PreviewAutomationHost,
19 type PreviewAutomationHostFocus,20 type PreviewAutomationHostFocus,
20 type PreviewAutomationResponse,21 type PreviewAutomationResponse,
21 type PreviewAutomationStreamEvent,22 type PreviewAutomationStreamEvent,
22 type PreviewTabId,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import * as Context from "effect/Context";24import * as Context from "effect/Context";
25import * as Clock from "effect/Clock";25import * as Clock from "effect/Clock";
...@@ -29,6 +29,7 @@ import * as Effect from "effect/Effect";...@@ -29,6 +29,7 @@ import * as Effect from "effect/Effect";
29import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
30import * as Option from "effect/Option";30import * as Option from "effect/Option";
31import * as Queue from "effect/Queue";31import * as Queue from "effect/Queue";
32import * as Schema from "effect/Schema";
32import * as Stream from "effect/Stream";33import * as Stream from "effect/Stream";
33import * as SynchronizedRef from "effect/SynchronizedRef";34import * as SynchronizedRef from "effect/SynchronizedRef";
3435
...@@ -79,6 +80,8 @@ interface HostAssignment {...@@ -79,6 +80,8 @@ interface HostAssignment {
79 readonly connectionId: ClientConnection["connectionId"];80 readonly connectionId: ClientConnection["connectionId"];
80 readonly queue: ClientConnection["queue"];81 readonly queue: ClientConnection["queue"];
81 readonly expiresAt: number;82 readonly expiresAt: number;
83 readonly tabId?: PreviewTabId;
84 readonly tabSequence?: number;
82}85}
8386
84interface PreviewAutomationRequestErrorContext {87interface PreviewAutomationRequestErrorContext {
...@@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = (...@@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = (
144const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>147const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>
145 `${scope.environmentId}\u0000${scope.providerSessionId}`;148 `${scope.environmentId}\u0000${scope.providerSessionId}`;
146149
150const isPreviewTabId = Schema.is(PreviewTabId);
151
152const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {
153 if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;
154 const tabId = result.tabId;
155 return tabId === null || isPreviewTabId(tabId) ? tabId : undefined;
156};
157
147const supportsOperation = (158const supportsOperation = (
148 connection: ClientConnection,159 connection: ClientConnection,
149 operation: PreviewAutomationOperation,160 operation: PreviewAutomationOperation,
...@@ -454,15 +465,24 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -454,15 +465,24 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
454 if (!hasLiveAssignment) assignments.delete(assignmentKey);465 if (!hasLiveAssignment) assignments.delete(assignmentKey);
455 return [undefined, { ...current, assignments }] as const;466 return [undefined, { ...current, assignments }] as const;
456 }467 }
468 const canReuseAssignedTab =
469 assigned !== undefined &&
470 assigned.connectionId === connection.connectionId &&
471 assigned.queue === connection.queue;
457 assignments.set(assignmentKey, {472 assignments.set(assignmentKey, {
458 clientId: connection.clientId,473 clientId: connection.clientId,
459 connectionId: connection.connectionId,474 connectionId: connection.connectionId,
460 queue: connection.queue,475 queue: connection.queue,
461 expiresAt: input.scope.expiresAt,476 expiresAt: input.scope.expiresAt,
477 ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),
478 ...(canReuseAssignedTab && assigned.tabSequence !== undefined
479 ? { tabSequence: assigned.tabSequence }
480 : {}),
462 });481 });
463482
464 const requestId = `preview-${current.requestSequence}`;483 const requestSequence = current.requestSequence;
465 const tabId = input.tabId;484 const requestId = `preview-${requestSequence}`;
485 const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined);
466 const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);486 const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
467 const context: PreviewAutomationRequestErrorContext = {487 const context: PreviewAutomationRequestErrorContext = {
468 operation: input.operation,488 operation: input.operation,
...@@ -480,7 +500,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -480,7 +500,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
480 const pending = new Map(current.pending);500 const pending = new Map(current.pending);
481 pending.set(requestId, { queue: connection.queue, deferred, context });501 pending.set(requestId, { queue: connection.queue, deferred, context });
482 return [502 return [
483 { connection, requestId, requestContext: context },503 { connection, requestId, requestContext: context, requestSequence },
484 { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },504 { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
485 ] as const;505 ] as const;
486 });506 });
...@@ -493,7 +513,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -493,7 +513,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
493 providerInstanceId: input.scope.providerInstanceId,513 providerInstanceId: input.scope.providerInstanceId,
494 });514 });
495 }515 }
496 const { connection, requestId, requestContext } = route;516 const { connection, requestId, requestContext, requestSequence } = route;
497 const removePending = SynchronizedRef.update(state, (next) => {517 const removePending = SynchronizedRef.update(state, (next) => {
498 if (!next.pending.has(requestId)) return next;518 if (!next.pending.has(requestId)) return next;
499 const pending = new Map(next.pending);519 const pending = new Map(next.pending);
...@@ -508,6 +528,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -508,6 +528,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
508 requestId,528 requestId,
509 threadId: input.scope.threadId,529 threadId: input.scope.threadId,
510 tabId: requestContext.tabId,530 tabId: requestContext.tabId,
531 tabIdExplicit: input.tabId !== undefined,
511 operation: input.operation,532 operation: input.operation,
512 input: input.input,533 input: input.input,
513 timeoutMs,534 timeoutMs,
...@@ -526,7 +547,36 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -526,7 +547,36 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
526 onSome: (value) => Effect.succeed(value as A),547 onSome: (value) => Effect.succeed(value as A),
527 });548 });
528 });549 });
529 return yield* awaitResponse().pipe(Effect.ensuring(removePending));550 const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
551 // A stop artifact identifies the globally recorded tab, not the caller's browsing target.
552 const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result);
553 const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
554 if (resultTabId === undefined) return result;
555 const assignmentKey = hostAssignmentKey(input.scope);
556 yield* SynchronizedRef.update(state, (current) => {
557 const assignment = current.assignments.get(assignmentKey);
558 if (
559 !assignment ||
560 assignment.connectionId !== connection.connectionId ||
561 assignment.queue !== connection.queue ||
562 (assignment.tabSequence ?? -1) > requestSequence
563 ) {
564 return current;
565 }
566 const assignments = new Map(current.assignments);
567 if (resultTabId === null) {
568 const { tabId: _tabId, ...withoutTabId } = assignment;
569 assignments.set(assignmentKey, { ...withoutTabId, tabSequence: requestSequence });
570 } else {
571 assignments.set(assignmentKey, {
572 ...assignment,
573 ...(resultTabId === undefined ? {} : { tabId: resultTabId }),
574 tabSequence: requestSequence,
575 });
576 }
577 return { ...current, assignments };
578 });
579 return result;
530 });580 });
531581
532 return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });582 return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
apps/server/src/mcp/toolkits/preview/handlers.ts+33-13
...@@ -6,6 +6,7 @@ import type {...@@ -6,6 +6,7 @@ import type {
6 PreviewAutomationResizeResult,6 PreviewAutomationResizeResult,
7 PreviewAutomationSnapshot,7 PreviewAutomationSnapshot,
8 PreviewAutomationStatus,8 PreviewAutomationStatus,
9 PreviewTabId,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
1011
11import * as McpInvocationContext from "../../McpInvocationContext.ts";12import * as McpInvocationContext from "../../McpInvocationContext.ts";
...@@ -16,6 +17,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(...@@ -16,6 +17,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
16 operation: PreviewAutomationOperation,17 operation: PreviewAutomationOperation,
17 input: unknown,18 input: unknown,
18 timeoutMs?: number,19 timeoutMs?: number,
20 tabId?: PreviewTabId,
19): Effect.fn.Return<21): Effect.fn.Return<
20 A,22 A,
21 import("@t3tools/contracts").PreviewAutomationError,23 import("@t3tools/contracts").PreviewAutomationError,
...@@ -28,31 +30,49 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(...@@ -28,31 +30,49 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
28 operation,30 operation,
29 input,31 input,
30 ...(timeoutMs === undefined ? {} : { timeoutMs }),32 ...(timeoutMs === undefined ? {} : { timeoutMs }),
33 ...(tabId === undefined ? {} : { tabId }),
31 });34 });
32});35});
3336
37const invokeTargeted = <A>(
38 operation: PreviewAutomationOperation,
39 input: {
40 readonly tabId?: PreviewTabId | undefined;
41 readonly [key: string]: unknown;
42 },
43 timeoutMs?: number,
44) => {
45 const { tabId, ...operationInput } = input;
46 return invoke<A>(operation, operationInput, timeoutMs, tabId);
47};
48
34const handlers = {49const handlers = {
35 preview_status: () => invoke<PreviewAutomationStatus>("status", {}),50 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
36 preview_open: (input) =>51 preview_open: (input) =>
37 invoke<PreviewAutomationStatus>("open", {52 invokeTargeted<PreviewAutomationStatus>("open", {
38 ...input,53 ...input,
39 show: input.show ?? true,54 show: input.show ?? true,
40 reuseExistingTab: input.reuseExistingTab ?? true,55 reuseExistingTab: input.reuseExistingTab ?? true,
41 }),56 }),
42 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),57 preview_navigate: (input) =>
58 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
43 preview_resize: (input) =>59 preview_resize: (input) =>
44 invoke<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),60 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
45 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),61 preview_snapshot: (input) => invokeTargeted<PreviewAutomationSnapshot>("snapshot", input ?? {}),
46 preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),62 preview_click: (input) =>
47 preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)),63 invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),
48 preview_press: (input) => invoke<void>("press", input).pipe(Effect.as(null)),64 preview_type: (input) =>
49 preview_scroll: (input) => invoke<void>("scroll", input).pipe(Effect.as(null)),65 invokeTargeted<void>("type", input, input.timeoutMs).pipe(Effect.as(null)),
66 preview_press: (input) => invokeTargeted<void>("press", input).pipe(Effect.as(null)),
67 preview_scroll: (input) => invokeTargeted<void>("scroll", input).pipe(Effect.as(null)),
50 preview_evaluate: (input) =>68 preview_evaluate: (input) =>
51 invoke<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)),69 invokeTargeted<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)),
52 preview_wait_for: (input) =>70 preview_wait_for: (input) =>
53 invoke<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)),71 invokeTargeted<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)),
54 preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}),72 preview_recording_start: (input) =>
55 preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}),73 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
74 preview_recording_stop: (input) =>
75 invokeTargeted<PreviewAutomationRecordingArtifact>("recordingStop", input ?? {}),
56} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];76} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
5777
58const { preview_snapshot, ...standardHandlers } = handlers;78const { preview_snapshot, ...standardHandlers } = handlers;
apps/server/src/mcp/toolkits/preview/tools.test.ts+4
...@@ -27,6 +27,10 @@ it("exports provider-compatible object schemas with described parameters", () =>...@@ -27,6 +27,10 @@ it("exports provider-compatible object schemas with described parameters", () =>
27 expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object");27 expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object");
28 expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined();28 expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined();
29 expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined();29 expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined();
30 expect(
31 schema.properties?.tabId,
32 `${tool.name} must allow an explicit collaborative browser tab target`,
33 ).toBeDefined();
30 for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) {34 for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) {
31 expect(35 expect(
32 schemaHasDescription(fieldSchema),36 schemaHasDescription(fieldSchema),
apps/server/src/mcp/toolkits/preview/tools.ts+17-12
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 PreviewAutomationScrollInput,12 PreviewAutomationScrollInput,
13 PreviewAutomationSnapshot,13 PreviewAutomationSnapshot,
14 PreviewAutomationStatus,14 PreviewAutomationStatus,
15 PreviewAutomationTabTargetInput,
15 PreviewAutomationTypeInput,16 PreviewAutomationTypeInput,
16 PreviewAutomationWaitForInput,17 PreviewAutomationWaitForInput,
17} from "@t3tools/contracts";18} from "@t3tools/contracts";
...@@ -37,7 +38,8 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>...@@ -37,7 +38,8 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
3738
38export const PreviewStatusTool = Tool.make("preview_status", {39export const PreviewStatusTool = Tool.make("preview_status", {
39 description:40 description:
40 "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.",41 "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",
42 parameters: PreviewAutomationTabTargetInput,
41 success: PreviewAutomationStatus,43 success: PreviewAutomationStatus,
42 failure: PreviewAutomationError,44 failure: PreviewAutomationError,
43 dependencies,45 dependencies,
...@@ -50,7 +52,7 @@ export const PreviewStatusTool = Tool.make("preview_status", {...@@ -50,7 +52,7 @@ export const PreviewStatusTool = Tool.make("preview_status", {
50export const PreviewOpenTool = browserTool(52export const PreviewOpenTool = browserTool(
51 Tool.make("preview_open", {53 Tool.make("preview_open", {
52 description:54 description:
53 "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.",55 "Show and initialize a collaborative browser tab. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.",
54 parameters: PreviewAutomationOpenInput,56 parameters: PreviewAutomationOpenInput,
55 success: PreviewAutomationStatus,57 success: PreviewAutomationStatus,
56 failure: PreviewAutomationError,58 failure: PreviewAutomationError,
...@@ -63,7 +65,7 @@ export const PreviewOpenTool = browserTool(...@@ -63,7 +65,7 @@ export const PreviewOpenTool = browserTool(
63export const PreviewNavigateTool = safeBrowserTool(65export const PreviewNavigateTool = safeBrowserTool(
64 Tool.make("preview_navigate", {66 Tool.make("preview_navigate", {
65 description:67 description:
66 "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.",68 "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.",
67 parameters: PreviewAutomationNavigateInput,69 parameters: PreviewAutomationNavigateInput,
68 success: PreviewAutomationStatus,70 success: PreviewAutomationStatus,
69 failure: PreviewAutomationError,71 failure: PreviewAutomationError,
...@@ -74,7 +76,7 @@ export const PreviewNavigateTool = safeBrowserTool(...@@ -74,7 +76,7 @@ export const PreviewNavigateTool = safeBrowserTool(
74export const PreviewResizeTool = safeBrowserTool(76export const PreviewResizeTool = safeBrowserTool(
75 Tool.make("preview_resize", {77 Tool.make("preview_resize", {
76 description:78 description:
77 "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.",79 "Resize a collaborative browser tab, optionally selected by tabId. 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.",
78 parameters: PreviewAutomationResizeInput,80 parameters: PreviewAutomationResizeInput,
79 success: PreviewAutomationResizeResult,81 success: PreviewAutomationResizeResult,
80 failure: PreviewAutomationError,82 failure: PreviewAutomationError,
...@@ -87,7 +89,8 @@ export const PreviewResizeTool = safeBrowserTool(...@@ -87,7 +89,8 @@ export const PreviewResizeTool = safeBrowserTool(
87export const PreviewSnapshotTool = readonlyBrowserTool(89export const PreviewSnapshotTool = readonlyBrowserTool(
88 Tool.make("preview_snapshot", {90 Tool.make("preview_snapshot", {
89 description:91 description:
90 "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.",92 "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.",
93 parameters: PreviewAutomationTabTargetInput,
91 success: PreviewAutomationSnapshot,94 success: PreviewAutomationSnapshot,
92 failure: PreviewAutomationError,95 failure: PreviewAutomationError,
93 dependencies,96 dependencies,
...@@ -97,7 +100,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(...@@ -97,7 +100,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
97export const PreviewClickTool = browserTool(100export const PreviewClickTool = browserTool(
98 Tool.make("preview_click", {101 Tool.make("preview_click", {
99 description:102 description:
100 "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.",103 "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.",
101 parameters: PreviewAutomationClickInput,104 parameters: PreviewAutomationClickInput,
102 success: Schema.Null,105 success: Schema.Null,
103 failure: PreviewAutomationError,106 failure: PreviewAutomationError,
...@@ -108,7 +111,7 @@ export const PreviewClickTool = browserTool(...@@ -108,7 +111,7 @@ export const PreviewClickTool = browserTool(
108export const PreviewTypeTool = browserTool(111export const PreviewTypeTool = browserTool(
109 Tool.make("preview_type", {112 Tool.make("preview_type", {
110 description:113 description:
111 "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.",114 "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.",
112 parameters: PreviewAutomationTypeInput,115 parameters: PreviewAutomationTypeInput,
113 success: Schema.Null,116 success: Schema.Null,
114 failure: PreviewAutomationError,117 failure: PreviewAutomationError,
...@@ -119,7 +122,7 @@ export const PreviewTypeTool = browserTool(...@@ -119,7 +122,7 @@ export const PreviewTypeTool = browserTool(
119export const PreviewPressTool = browserTool(122export const PreviewPressTool = browserTool(
120 Tool.make("preview_press", {123 Tool.make("preview_press", {
121 description:124 description:
122 "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.",125 "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.",
123 parameters: PreviewAutomationPressInput,126 parameters: PreviewAutomationPressInput,
124 success: Schema.Null,127 success: Schema.Null,
125 failure: PreviewAutomationError,128 failure: PreviewAutomationError,
...@@ -130,7 +133,7 @@ export const PreviewPressTool = browserTool(...@@ -130,7 +133,7 @@ export const PreviewPressTool = browserTool(
130export const PreviewScrollTool = safeBrowserTool(133export const PreviewScrollTool = safeBrowserTool(
131 Tool.make("preview_scroll", {134 Tool.make("preview_scroll", {
132 description:135 description:
133 "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.",136 "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.",
134 parameters: PreviewAutomationScrollInput,137 parameters: PreviewAutomationScrollInput,
135 success: Schema.Null,138 success: Schema.Null,
136 failure: PreviewAutomationError,139 failure: PreviewAutomationError,
...@@ -141,7 +144,7 @@ export const PreviewScrollTool = safeBrowserTool(...@@ -141,7 +144,7 @@ export const PreviewScrollTool = safeBrowserTool(
141export const PreviewEvaluateTool = browserTool(144export const PreviewEvaluateTool = browserTool(
142 Tool.make("preview_evaluate", {145 Tool.make("preview_evaluate", {
143 description:146 description:
144 "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.",147 "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.",
145 parameters: PreviewAutomationEvaluateInput,148 parameters: PreviewAutomationEvaluateInput,
146 success: Schema.Unknown,149 success: Schema.Unknown,
147 failure: PreviewAutomationError,150 failure: PreviewAutomationError,
...@@ -152,7 +155,7 @@ export const PreviewEvaluateTool = browserTool(...@@ -152,7 +155,7 @@ export const PreviewEvaluateTool = browserTool(
152export const PreviewWaitForTool = readonlyBrowserTool(155export const PreviewWaitForTool = readonlyBrowserTool(
153 Tool.make("preview_wait_for", {156 Tool.make("preview_wait_for", {
154 description:157 description:
155 "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.",158 "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.",
156 parameters: PreviewAutomationWaitForInput,159 parameters: PreviewAutomationWaitForInput,
157 success: Schema.Null,160 success: Schema.Null,
158 failure: PreviewAutomationError,161 failure: PreviewAutomationError,
...@@ -163,7 +166,8 @@ export const PreviewWaitForTool = readonlyBrowserTool(...@@ -163,7 +166,8 @@ export const PreviewWaitForTool = readonlyBrowserTool(
163export const PreviewRecordingStartTool = safeBrowserTool(166export const PreviewRecordingStartTool = safeBrowserTool(
164 Tool.make("preview_recording_start", {167 Tool.make("preview_recording_start", {
165 description:168 description:
166 "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.",169 "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.",
170 parameters: PreviewAutomationTabTargetInput,
167 success: PreviewAutomationRecordingStatus,171 success: PreviewAutomationRecordingStatus,
168 failure: PreviewAutomationError,172 failure: PreviewAutomationError,
169 dependencies,173 dependencies,
...@@ -173,6 +177,7 @@ export const PreviewRecordingStartTool = safeBrowserTool(...@@ -173,6 +177,7 @@ export const PreviewRecordingStartTool = safeBrowserTool(
173export const PreviewRecordingStopTool = safeBrowserTool(177export const PreviewRecordingStopTool = safeBrowserTool(
174 Tool.make("preview_recording_stop", {178 Tool.make("preview_recording_stop", {
175 description: "Stop the active browser recording and save it as a local evidence artifact.",179 description: "Stop the active browser recording and save it as a local evidence artifact.",
180 parameters: PreviewAutomationTabTargetInput,
176 success: PreviewAutomationRecordingArtifact,181 success: PreviewAutomationRecordingArtifact,
177 failure: PreviewAutomationError,182 failure: PreviewAutomationError,
178 dependencies,183 dependencies,
apps/server/src/provider/Layers/CursorAdapter.test.ts+12-17
...@@ -1021,24 +1021,19 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -1021,24 +1021,19 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
1021 assert.equal(turnCompleted.payload.stopReason, "cancelled");1021 assert.equal(turnCompleted.payload.stopReason, "cancelled");
1022 }1022 }
10231023
1024 const requests = yield* waitForJsonLogMatch(1024 const isCancelledApprovalResponse = (entry: Record<string, unknown>) =>
1025 requestLogPath,1025 !("method" in entry) &&
1026 (entry) => entry.method === "session/cancel",1026 typeof entry.result === "object" &&
1027 );1027 entry.result !== null &&
1028 "outcome" in entry.result &&
1029 typeof entry.result.outcome === "object" &&
1030 entry.result.outcome !== null &&
1031 "outcome" in entry.result.outcome &&
1032 entry.result.outcome.outcome === "cancelled";
1033 yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel");
1034 const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse);
1028 assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));1035 assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));
1029 assert.isTrue(1036 assert.isTrue(requests.some(isCancelledApprovalResponse));
1030 requests.some(
1031 (entry) =>
1032 !("method" in entry) &&
1033 typeof entry.result === "object" &&
1034 entry.result !== null &&
1035 "outcome" in entry.result &&
1036 typeof entry.result.outcome === "object" &&
1037 entry.result.outcome !== null &&
1038 "outcome" in entry.result.outcome &&
1039 entry.result.outcome.outcome === "cancelled",
1040 ),
1041 );
10421037
1043 yield* adapter.stopSession(threadId);1038 yield* adapter.stopSession(threadId);
1044 }),1039 }),
apps/web/src/browser/BrowserSurfaceSlot.tsx+6-6
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1"use client";1"use client";
22
3import { useEffect, useRef } from "react";3import { useLayoutEffect, useRef } from "react";
44
5import { useBrowserSurfaceStore } from "./browserSurfaceStore";5import { acquireBrowserSurface } from "./browserSurfaceStore";
66
7export function BrowserSurfaceSlot(props: {7export function BrowserSurfaceSlot(props: {
8 readonly tabId: string;8 readonly tabId: string;
...@@ -12,13 +12,13 @@ export function BrowserSurfaceSlot(props: {...@@ -12,13 +12,13 @@ export function BrowserSurfaceSlot(props: {
12 const { tabId, visible, className } = props;12 const { tabId, visible, className } = props;
13 const elementRef = useRef<HTMLDivElement | null>(null);13 const elementRef = useRef<HTMLDivElement | null>(null);
1414
15 useEffect(() => {15 useLayoutEffect(() => {
16 const element = elementRef.current;16 const element = elementRef.current;
17 if (!element) return;17 if (!element) return;
18 const lease = acquireBrowserSurface(tabId);
18 const update = () => {19 const update = () => {
19 const rect = element.getBoundingClientRect();20 const rect = element.getBoundingClientRect();
20 useBrowserSurfaceStore.getState().present(21 lease.present(
21 tabId,
22 {22 {
23 x: Math.round(rect.x),23 x: Math.round(rect.x),
24 y: Math.round(rect.y),24 y: Math.round(rect.y),
...@@ -37,7 +37,7 @@ export function BrowserSurfaceSlot(props: {...@@ -37,7 +37,7 @@ export function BrowserSurfaceSlot(props: {
37 observer.disconnect();37 observer.disconnect();
38 window.removeEventListener("resize", update);38 window.removeEventListener("resize", update);
39 window.removeEventListener("scroll", update, true);39 window.removeEventListener("scroll", update, true);
40 useBrowserSurfaceStore.getState().hide(tabId);40 lease.release();
41 };41 };
42 }, [tabId, visible]);42 }, [tabId, visible]);
4343
apps/web/src/browser/HostedBrowserWebview.tsx+20-27
...@@ -8,13 +8,13 @@ import { previewBridge } from "~/components/preview/previewBridge";...@@ -8,13 +8,13 @@ import { previewBridge } from "~/components/preview/previewBridge";
8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
1010
11import { useActiveBrowserRecordingTabId } from "./browserRecording";11import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording";
12import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";12import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
13import { browserViewportSettingKey } from "./browserViewportLayout";13import { browserViewportSettingKey } from "./browserViewportLayout";
14import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState";
15import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";14import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
16import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";15import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
17import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";16import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
17import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
18import { usePreviewWebviewConfig } from "./previewWebviewConfigState";18import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
19import { useBrowserViewportResize } from "./useBrowserViewportResize";19import { useBrowserViewportResize } from "./useBrowserViewportResize";
2020
...@@ -46,7 +46,8 @@ export function HostedBrowserWebview(props: {...@@ -46,7 +46,8 @@ export function HostedBrowserWebview(props: {
46 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);46 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
47 const wrapperRef = useRef<HTMLDivElement | null>(null);47 const wrapperRef = useRef<HTMLDivElement | null>(null);
48 const webviewRef = useRef<ElectronWebview | null>(null);48 const webviewRef = useRef<ElectronWebview | null>(null);
49 const [lockedAspectRatio, setLockedAspectRatio] = useState<number | null>(null);49 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
50 const activeRecordingTabId = useActiveBrowserRecordingTabId();
50 const presentation = useBrowserSurfaceStore(51 const presentation = useBrowserSurfaceStore(
51 useShallow((state) => {52 useShallow((state) => {
52 const current = state.byTabId[tabId];53 const current = state.byTabId[tabId];
...@@ -56,10 +57,13 @@ export function HostedBrowserWebview(props: {...@@ -56,10 +57,13 @@ export function HostedBrowserWebview(props: {
56 };57 };
57 }),58 }),
58 );59 );
59 const recording = useActiveBrowserRecordingTabId() === tabId;
60
61 usePreviewBridge({ threadRef, tabId });60 usePreviewBridge({ threadRef, tabId });
6261
62 useEffect(() => {
63 if (presentation.visible || activeRecordingTabId !== tabId) return;
64 void stopBrowserRecording(tabId).catch(() => undefined);
65 }, [activeRecordingTabId, presentation.visible, tabId]);
66
63 useEffect(() => {67 useEffect(() => {
64 const lease = acquireDesktopTab(tabId);68 const lease = acquireDesktopTab(tabId);
65 tabLeaseRef.current = lease;69 tabLeaseRef.current = lease;
...@@ -115,9 +119,11 @@ export function HostedBrowserWebview(props: {...@@ -115,9 +119,11 @@ export function HostedBrowserWebview(props: {
115 const viewportHeight = viewport._tag === "fill" ? null : viewport.height;119 const viewportHeight = viewport._tag === "fill" ? null : viewport.height;
116 const viewportAspectRatio =120 const viewportAspectRatio =
117 viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight;121 viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight;
118 useEffect(() => {122 const lockedAspectRatio =
119 setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio));123 aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null;
120 }, [viewportAspectRatio]);124 const handleAspectRatioChange = useCallback((aspectRatio: number | null) => {
125 setAspectRatioLocked(aspectRatio !== null);
126 }, []);
121 const hiddenSize =127 const hiddenSize =
122 viewport._tag !== "fill"128 viewport._tag !== "fill"
123 ? {129 ? {
...@@ -170,24 +176,11 @@ export function HostedBrowserWebview(props: {...@@ -170,24 +176,11 @@ export function HostedBrowserWebview(props: {
170176
171 if (!config) return null;177 if (!config) return null;
172178
173 const wrapperStyle =179 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
174 active && lastRect180 active,
175 ? {181 rect: lastRect,
176 left: lastRect.x,182 hiddenSize,
177 top: lastRect.y,183 });
178 width: lastRect.width,
179 height: lastRect.height,
180 zIndex: 30,
181 pointerEvents: "auto" as const,
182 }
183 : {
184 left: 0,
185 top: 0,
186 width: hiddenSize.width,
187 height: hiddenSize.height,
188 zIndex: recording ? 0 : -1,
189 pointerEvents: "none" as const,
190 };
191184
192 return (185 return (
193 <div186 <div
...@@ -203,7 +196,7 @@ export function HostedBrowserWebview(props: {...@@ -203,7 +196,7 @@ export function HostedBrowserWebview(props: {
203 setting={effectiveViewport}196 setting={effectiveViewport}
204 width={Math.max(1, Math.round(containerSize.width))}197 width={Math.max(1, Math.round(containerSize.width))}
205 aspectRatio={lockedAspectRatio}198 aspectRatio={lockedAspectRatio}
206 onAspectRatioChange={setLockedAspectRatio}199 onAspectRatioChange={handleAspectRatioChange}
207 onChange={commitViewportChange}200 onChange={commitViewportChange}
208 />201 />
209 ) : null}202 ) : null}
apps/web/src/browser/browserRecording.test.ts created+294
...@@ -0,0 +1,294 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } =
4 vi.hoisted(() => {
5 const events: string[] = [];
6 const surfaceState = {
7 byTabId: {} as Record<string, unknown>,
8 };
9 return {
10 events,
11 onFrame: vi.fn(() => vi.fn()),
12 registrySet: vi.fn((_atom: unknown, value: string | null) => {
13 events.push(value === null ? "clear" : `publish:${value}`);
14 }),
15 save: vi.fn(async () => ({
16 id: "recording-test",
17 tabId: "recording-tab",
18 path: "/tmp/recording-test.webm",
19 mimeType: "video/webm" as const,
20 sizeBytes: 0,
21 createdAt: "2026-06-26T00:00:00.000Z",
22 })),
23 startScreencast: vi.fn(async () => {
24 events.push("start-screencast");
25 }),
26 stopScreencast: vi.fn(async () => undefined),
27 surfaceState,
28 };
29 });
30
31vi.mock("~/components/preview/previewBridge", () => ({
32 previewBridge: {
33 recording: { onFrame, save, startScreencast, stopScreencast },
34 },
35}));
36
37vi.mock("~/rpc/atomRegistry", () => ({
38 appAtomRegistry: { set: registrySet },
39}));
40
41vi.mock("./browserSurfaceStore", () => ({
42 useBrowserSurfaceStore: {
43 getState: () => surfaceState,
44 },
45}));
46
47import {
48 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
49 BrowserRecordingConflictError,
50 BrowserRecordingOperationError,
51 BrowserRecordingRequiresVisibleTabError,
52 startBrowserRecording,
53 stopBrowserRecording,
54} from "./browserRecording";
55
56class FakeMediaRecorder {
57 static isTypeSupported(): boolean {
58 return true;
59 }
60
61 state: RecordingState = "inactive";
62 private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
63
64 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
65 const listeners = this.listeners.get(type) ?? new Set();
66 listeners.add(listener);
67 this.listeners.set(type, listeners);
68 }
69
70 start(): void {
71 this.state = "recording";
72 }
73
74 stop(): void {
75 this.state = "inactive";
76 for (const listener of this.listeners.get("stop") ?? []) {
77 if (typeof listener === "function") listener(new Event("stop"));
78 else listener.handleEvent(new Event("stop"));
79 }
80 }
81}
82
83describe("browser recording", () => {
84 beforeEach(() => {
85 events.length = 0;
86 surfaceState.byTabId = {
87 "recording-tab": {
88 visible: true,
89 rect: { x: 0, y: 0, width: 800, height: 600 },
90 content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 },
91 },
92 };
93 vi.clearAllMocks();
94 vi.stubGlobal("window", globalThis);
95 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
96 vi.stubGlobal("document", {
97 createElement: () => ({
98 width: 0,
99 height: 0,
100 captureStream: () => ({}),
101 getContext: () => ({ drawImage: vi.fn() }),
102 }),
103 });
104 });
105
106 afterEach(() => {
107 vi.useRealTimers();
108 vi.unstubAllGlobals();
109 });
110
111 it("starts recording for a visible tab", async () => {
112 await startBrowserRecording("recording-tab");
113
114 expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
115
116 await stopBrowserRecording("recording-tab");
117 });
118
119 it("rejects recording for a hidden tab before starting screencast", async () => {
120 surfaceState.byTabId = {
121 "recording-tab": {
122 visible: false,
123 rect: { x: 0, y: 0, width: 800, height: 600 },
124 content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 },
125 },
126 };
127
128 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
129 BrowserRecordingRequiresVisibleTabError,
130 );
131
132 expect(startScreencast).not.toHaveBeenCalled();
133 expect(registrySet).not.toHaveBeenCalled();
134 });
135
136 it("does not report success for a second start while the first is still starting", async () => {
137 let finishStartingScreencast: (() => void) | undefined;
138 startScreencast.mockImplementationOnce(async () => {
139 events.push("start-screencast");
140 await new Promise<void>((resolve) => {
141 finishStartingScreencast = resolve;
142 });
143 });
144
145 const firstStart = startBrowserRecording("recording-tab");
146 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
147
148 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
149 BrowserRecordingConflictError,
150 );
151
152 finishStartingScreencast?.();
153 await firstStart;
154 await stopBrowserRecording("recording-tab");
155 });
156
157 it("does not report success for a start while the recording is stopping", async () => {
158 let finishStoppingScreencast: (() => void) | undefined;
159 stopScreencast.mockImplementationOnce(async () => {
160 await new Promise<void>((resolve) => {
161 finishStoppingScreencast = resolve;
162 });
163 return undefined;
164 });
165
166 await startBrowserRecording("recording-tab");
167 const stopPromise = stopBrowserRecording("recording-tab");
168 await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
169
170 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
171 BrowserRecordingConflictError,
172 );
173
174 finishStoppingScreencast?.();
175 await stopPromise;
176 });
177
178 it("shares an in-progress stop with duplicate callers", async () => {
179 let finishStoppingScreencast: (() => void) | undefined;
180 stopScreencast.mockImplementationOnce(async () => {
181 await new Promise<void>((resolve) => {
182 finishStoppingScreencast = resolve;
183 });
184 return undefined;
185 });
186
187 await startBrowserRecording("recording-tab");
188 const firstStop = stopBrowserRecording("recording-tab");
189 await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
190 const duplicateStop = stopBrowserRecording("recording-tab");
191
192 finishStoppingScreencast?.();
193 const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]);
194
195 expect(duplicateArtifact).toEqual(firstArtifact);
196 expect(stopScreencast).toHaveBeenCalledOnce();
197 expect(save).toHaveBeenCalledOnce();
198 });
199
200 it("stops a screencast that finishes starting after cancellation", async () => {
201 let finishStartingScreencast: (() => void) | undefined;
202 startScreencast.mockImplementationOnce(async () => {
203 events.push("start-screencast");
204 await new Promise<void>((resolve) => {
205 finishStartingScreencast = resolve;
206 });
207 });
208
209 const startPromise = startBrowserRecording("recording-tab");
210 const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
211 BrowserRecordingOperationError,
212 );
213 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
214
215 const stopPromise = stopBrowserRecording("recording-tab");
216 await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
217 finishStartingScreencast?.();
218
219 await rejectedStart;
220 await stopPromise;
221 expect(stopScreencast).toHaveBeenCalledTimes(2);
222 expect(events.at(-1)).toBe("clear");
223 });
224
225 it("does not release the recording slot until a cancelled start settles", async () => {
226 let finishStartingScreencast: (() => void) | undefined;
227 startScreencast.mockImplementationOnce(async () => {
228 events.push("start-screencast");
229 await new Promise<void>((resolve) => {
230 finishStartingScreencast = resolve;
231 });
232 });
233
234 const firstStart = startBrowserRecording("recording-tab");
235 const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf(
236 BrowserRecordingOperationError,
237 );
238 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
239
240 const stopPromise = stopBrowserRecording("recording-tab");
241 const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab"));
242 await new Promise((resolve) => setTimeout(resolve, 0));
243 const startCallsBeforeFirstSettled = startScreencast.mock.calls.length;
244
245 finishStartingScreencast?.();
246 await rejectedFirstStart;
247 await stopPromise;
248 await restartAfterStop;
249 await stopBrowserRecording("recording-tab");
250
251 expect(startCallsBeforeFirstSettled).toBe(1);
252 });
253
254 it("fails a stop that waits too long for startup without freeing the recording slot", async () => {
255 vi.useFakeTimers();
256 let finishStartingScreencast: (() => void) | undefined;
257 startScreencast.mockImplementationOnce(async () => {
258 events.push("start-screencast");
259 await new Promise<void>((resolve) => {
260 finishStartingScreencast = resolve;
261 });
262 });
263
264 const startPromise = startBrowserRecording("recording-tab");
265 const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
266 BrowserRecordingOperationError,
267 );
268 expect(startScreencast).toHaveBeenCalledOnce();
269
270 const stopPromise = stopBrowserRecording("recording-tab");
271 await Promise.resolve();
272 await Promise.resolve();
273 expect(stopScreencast).toHaveBeenCalledOnce();
274
275 const rejection = expect(stopPromise).rejects.toMatchObject({
276 operation: "wait-startup",
277 tabId: "recording-tab",
278 });
279 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
280
281 await rejection;
282 expect(save).not.toHaveBeenCalled();
283 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
284 BrowserRecordingConflictError,
285 );
286
287 finishStartingScreencast?.();
288 await rejectedStart;
289 const cleanupResult = await stopBrowserRecording("recording-tab");
290 expect(cleanupResult).toBeNull();
291 expect(save).not.toHaveBeenCalled();
292 expect(events.at(-1)).toBe("clear");
293 });
294});
apps/web/src/browser/browserRecording.ts+201-49
...@@ -46,6 +46,17 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl...@@ -46,6 +46,17 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl
46 }46 }
47}47}
4848
49export class BrowserRecordingRequiresVisibleTabError extends Schema.TaggedErrorClass<BrowserRecordingRequiresVisibleTabError>()(
50 "BrowserRecordingRequiresVisibleTabError",
51 {
52 tabId: Schema.String,
53 },
54) {
55 override get message(): string {
56 return `Browser recording requires tab ${this.tabId} to be visible.`;
57 }
58}
59
49export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()(60export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()(
50 "BrowserRecordingOperationError",61 "BrowserRecordingOperationError",
51 {62 {
...@@ -55,6 +66,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow...@@ -55,6 +66,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow
55 "start-media-recorder",66 "start-media-recorder",
56 "start-screencast",67 "start-screencast",
57 "stop-screencast",68 "stop-screencast",
69 "wait-startup",
58 "stop-media-recorder",70 "stop-media-recorder",
59 "save-artifact",71 "save-artifact",
60 "cleanup",72 "cleanup",
...@@ -68,6 +80,16 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow...@@ -68,6 +80,16 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow
68 }80 }
69}81}
7082
83const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError);
84
85type BrowserRecordingLifecycle =
86 | { readonly phase: "starting" }
87 | { readonly phase: "recording" }
88 | {
89 readonly phase: "stopping";
90 readonly stopPromise: Promise<DesktopPreviewRecordingArtifact | null>;
91 };
92
71interface ActiveRecording {93interface ActiveRecording {
72 readonly tabId: string;94 readonly tabId: string;
73 readonly canvas: HTMLCanvasElement;95 readonly canvas: HTMLCanvasElement;
...@@ -76,6 +98,8 @@ interface ActiveRecording {...@@ -76,6 +98,8 @@ interface ActiveRecording {
76 readonly chunks: Blob[];98 readonly chunks: Blob[];
77 readonly mimeType: string;99 readonly mimeType: string;
78 readonly startedAt: string;100 readonly startedAt: string;
101 readonly startupSettled: Promise<void>;
102 lifecycle: BrowserRecordingLifecycle;
79}103}
80104
81const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe(105const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe(
...@@ -90,6 +114,8 @@ export function useActiveBrowserRecordingTabId(): string | null {...@@ -90,6 +114,8 @@ export function useActiveBrowserRecordingTabId(): string | null {
90let active: ActiveRecording | null = null;114let active: ActiveRecording | null = null;
91let unsubscribeFrames: (() => void) | null = null;115let unsubscribeFrames: (() => void) | null = null;
92116
117export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
118
93export function readActiveBrowserRecordingTabId(): string | null {119export function readActiveBrowserRecordingTabId(): string | null {
94 return active?.tabId ?? null;120 return active?.tabId ?? null;
95}121}
...@@ -131,17 +157,58 @@ const clearActiveRecording = (recording: ActiveRecording): void => {...@@ -131,17 +157,58 @@ const clearActiveRecording = (recording: ActiveRecording): void => {
131 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null);157 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null);
132};158};
133159
160const recordingStartupCancelledError = (
161 recording: ActiveRecording,
162 cause: unknown = new Error(`Browser recording startup was cancelled for tab ${recording.tabId}.`),
163): BrowserRecordingOperationError =>
164 new BrowserRecordingOperationError({
165 operation: "start-screencast",
166 tabId: recording.tabId,
167 cause,
168 });
169
170const isRecordingStarting = (recording: ActiveRecording): boolean =>
171 active === recording && recording.lifecycle.phase === "starting";
172
173const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => {
174 let timeout: ReturnType<typeof setTimeout> | null = null;
175 try {
176 await Promise.race([
177 recording.startupSettled,
178 new Promise<void>((_, reject) => {
179 timeout = setTimeout(() => {
180 reject(new Error(`Browser recording startup did not settle for tab ${recording.tabId}.`));
181 }, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
182 }),
183 ]);
184 } catch (cause) {
185 throw new BrowserRecordingOperationError({
186 operation: "wait-startup",
187 tabId: recording.tabId,
188 cause,
189 });
190 } finally {
191 if (timeout !== null) clearTimeout(timeout);
192 }
193};
194
195const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError =>
196 isBrowserRecordingOperationError(error) && error.operation === "wait-startup";
197
134export async function startBrowserRecording(tabId: string): Promise<string> {198export async function startBrowserRecording(tabId: string): Promise<string> {
135 const bridge = previewBridge;199 const bridge = previewBridge;
136 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });200 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });
137 if (active) {201 if (active) {
138 if (active.tabId === tabId) return active.startedAt;202 if (active.tabId === tabId && active.lifecycle.phase === "recording") {
203 return active.startedAt;
204 }
139 throw new BrowserRecordingConflictError({205 throw new BrowserRecordingConflictError({
140 requestedTabId: tabId,206 requestedTabId: tabId,
141 activeTabId: active.tabId,207 activeTabId: active.tabId,
142 });208 });
143 }209 }
144 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];210 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
211 if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId });
145 const recordingSize = surface?.content ?? surface?.rect;212 const recordingSize = surface?.content ?? surface?.rect;
146 const canvas = document.createElement("canvas");213 const canvas = document.createElement("canvas");
147 canvas.width = Math.max(1, recordingSize?.width ?? 1280);214 canvas.width = Math.max(1, recordingSize?.width ?? 1280);
...@@ -171,65 +238,104 @@ export async function startBrowserRecording(tabId: string): Promise<string> {...@@ -171,65 +238,104 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
171 }238 }
172 const startedAt = new Date().toISOString();239 const startedAt = new Date().toISOString();
173 const chunks: Blob[] = [];240 const chunks: Blob[] = [];
241 let settleStartup: (() => void) | undefined;
242 const startupSettled = new Promise<void>((resolve) => {
243 settleStartup = resolve;
244 });
174 recorder.addEventListener("dataavailable", (event) => {245 recorder.addEventListener("dataavailable", (event) => {
175 if (event.data.size > 0) chunks.push(event.data);246 if (event.data.size > 0) chunks.push(event.data);
176 });247 });
177 const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt };248 const recording: ActiveRecording = {
249 tabId,
250 canvas,
251 context,
252 recorder,
253 chunks,
254 mimeType,
255 startedAt,
256 startupSettled,
257 lifecycle: { phase: "starting" },
258 };
178 active = recording;259 active = recording;
179 try {260 try {
180 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
181 } catch (cause) {
182 clearActiveRecording(recording);
183 throw new BrowserRecordingOperationError({
184 operation: "subscribe-frames",
185 tabId,
186 cause,
187 });
188 }
189 try {
190 recorder.start(1_000);
191 } catch (cause) {
192 clearActiveRecording(recording);
193 throw new BrowserRecordingOperationError({
194 operation: "start-media-recorder",
195 tabId,
196 cause,
197 });
198 }
199 try {
200 await bridge.recording.startScreencast(tabId);
201 } catch (cause) {
202 let cleanupCause: unknown;
203 try {261 try {
204 await stopMediaRecorder(recorder);262 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
205 } catch (error) {263 } catch (cause) {
206 cleanupCause = error;
207 } finally {
208 clearActiveRecording(recording);264 clearActiveRecording(recording);
265 throw new BrowserRecordingOperationError({
266 operation: "subscribe-frames",
267 tabId,
268 cause,
269 });
209 }270 }
210 throw new BrowserRecordingOperationError({271 try {
211 operation: "start-screencast",272 recorder.start(1_000);
212 tabId,273 } catch (cause) {
213 cause:274 clearActiveRecording(recording);
214 cleanupCause === undefined275 throw new BrowserRecordingOperationError({
215 ? cause276 operation: "start-media-recorder",
216 : new AggregateError(277 tabId,
217 [cause, cleanupCause],278 cause,
218 `Browser recording start and cleanup failed for tab ${tabId}.`,279 });
219 { cause },280 }
220 ),281 if (!isRecordingStarting(recording)) {
221 });282 throw recordingStartupCancelledError(recording);
283 }
284 try {
285 await bridge.recording.startScreencast(tabId);
286 } catch (cause) {
287 if (!isRecordingStarting(recording)) {
288 throw recordingStartupCancelledError(recording, cause);
289 }
290 let cleanupCause: unknown;
291 try {
292 await stopMediaRecorder(recorder);
293 } catch (error) {
294 cleanupCause = error;
295 } finally {
296 clearActiveRecording(recording);
297 }
298 throw new BrowserRecordingOperationError({
299 operation: "start-screencast",
300 tabId,
301 cause:
302 cleanupCause === undefined
303 ? cause
304 : new AggregateError(
305 [cause, cleanupCause],
306 `Browser recording start and cleanup failed for tab ${tabId}.`,
307 { cause },
308 ),
309 });
310 }
311 if (!isRecordingStarting(recording)) {
312 try {
313 await bridge.recording.stopScreencast(tabId);
314 } catch (cause) {
315 throw recordingStartupCancelledError(
316 recording,
317 new AggregateError(
318 [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause],
319 `Browser recording startup cancellation failed for tab ${tabId}.`,
320 { cause },
321 ),
322 );
323 }
324 throw recordingStartupCancelledError(recording);
325 }
326 recording.lifecycle = { phase: "recording" };
327 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId);
328 return startedAt;
329 } finally {
330 settleStartup?.();
222 }331 }
223 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId);
224 return startedAt;
225}332}
226333
227export async function stopBrowserRecording(334const finalizeBrowserRecording = async (
228 tabId: string,335 bridge: NonNullable<typeof previewBridge>,
229): Promise<DesktopPreviewRecordingArtifact | null> {336 recording: ActiveRecording,
230 const bridge = previewBridge;337): Promise<DesktopPreviewRecordingArtifact> => {
231 const recording = active;338 const { tabId } = recording;
232 if (!bridge || !recording || recording.tabId !== tabId) return null;
233 let result:339 let result:
234 | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact }340 | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact }
235 | { readonly _tag: "Failure"; readonly error: unknown };341 | { readonly _tag: "Failure"; readonly error: unknown };
...@@ -243,6 +349,7 @@ export async function stopBrowserRecording(...@@ -243,6 +349,7 @@ export async function stopBrowserRecording(
243 cause,349 cause,
244 });350 });
245 }351 }
352 await waitForRecordingStartupToSettle(recording);
246 try {353 try {
247 await stopMediaRecorder(recording.recorder);354 await stopMediaRecorder(recording.recorder);
248 } catch (cause) {355 } catch (cause) {
...@@ -271,6 +378,14 @@ export async function stopBrowserRecording(...@@ -271,6 +378,14 @@ export async function stopBrowserRecording(
271 result = { _tag: "Failure", error };378 result = { _tag: "Failure", error };
272 }379 }
273380
381 if (result._tag === "Failure" && isStartupWaitTimeout(result.error)) {
382 // Do not clear `active` yet. The renderer-side start promise can still
383 // resolve later, and its cancellation path will call `stopScreencast`.
384 // Keeping the slot reserved prevents a newer recording for this tab from
385 // being started and then accidentally stopped by the older late cleanup.
386 throw result.error;
387 }
388
274 let cleanupError: BrowserRecordingOperationError | undefined;389 let cleanupError: BrowserRecordingOperationError | undefined;
275 try {390 try {
276 await stopMediaRecorder(recording.recorder);391 await stopMediaRecorder(recording.recorder);
...@@ -300,4 +415,41 @@ export async function stopBrowserRecording(...@@ -300,4 +415,41 @@ export async function stopBrowserRecording(
300 }415 }
301 if (cleanupError) throw cleanupError;416 if (cleanupError) throw cleanupError;
302 return result.artifact;417 return result.artifact;
418};
419
420const discardBrowserRecording = async (
421 bridge: NonNullable<typeof previewBridge>,
422 recording: ActiveRecording,
423): Promise<null> => {
424 try {
425 await bridge.recording.stopScreencast(recording.tabId).catch(() => undefined);
426 await stopMediaRecorder(recording.recorder).catch(() => undefined);
427 return null;
428 } finally {
429 clearActiveRecording(recording);
430 }
431};
432
433export function stopBrowserRecording(
434 tabId: string,
435): Promise<DesktopPreviewRecordingArtifact | null> {
436 const bridge = previewBridge;
437 const recording = active;
438 if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null);
439 if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise;
440
441 const stopPromise = Promise.resolve()
442 .then(() => finalizeBrowserRecording(bridge, recording))
443 .catch((error) => {
444 if (isStartupWaitTimeout(error) && active === recording) {
445 const cleanupAfterStartup = recording.startupSettled.then(() =>
446 discardBrowserRecording(bridge, recording),
447 );
448 recording.lifecycle = { phase: "stopping", stopPromise: cleanupAfterStartup };
449 void cleanupAfterStartup.catch(() => undefined);
450 }
451 throw error;
452 });
453 recording.lifecycle = { phase: "stopping", stopPromise };
454 return stopPromise;
303}455}
apps/web/src/browser/browserRecordingScope.test.ts+6-1
...@@ -3,9 +3,14 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,9 +3,14 @@ import { describe, expect, it } from "vite-plus/test";
3import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope";3import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope";
44
5describe("resolveBrowserRecordingStopTarget", () => {5describe("resolveBrowserRecordingStopTarget", () => {
6 it("stops the active recording even after the requested tab changes", () => {6 it("stops the active recording when no explicit tab was requested", () => {
7 expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a");7 expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a");
8 expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b");8 expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b");
9 expect(resolveBrowserRecordingStopTarget(null)).toBeNull();9 expect(resolveBrowserRecordingStopTarget(null)).toBeNull();
10 });10 });
11
12 it("only stops an explicitly requested tab when it owns the recording", () => {
13 expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a");
14 expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull();
15 });
11});16});
apps/web/src/browser/browserRecordingScope.ts+6-2
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null {1export function resolveBrowserRecordingStopTarget(
2 return activeTabId;2 activeTabId: string | null,
3 requestedTabId?: string,
4): string | null {
5 if (activeTabId === null) return null;
6 return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null;
3}7}
apps/web/src/browser/browserSurfaceStore.test.ts+44-4
...@@ -1,8 +1,16 @@...@@ -1,8 +1,16 @@
1import { describe, expect, it } from "vite-plus/test";1import { beforeEach, describe, expect, it } from "vite-plus/test";
22
3import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";3import {
4 acquireBrowserSurface,
5 resolveBrowserSurfacePanelRect,
6 useBrowserSurfaceStore,
7} from "./browserSurfaceStore";
48
5describe("browserSurfaceStore", () => {9describe("browserSurfaceStore", () => {
10 beforeEach(() => {
11 useBrowserSurfaceStore.setState({ byTabId: {} });
12 });
13
6 it("tracks content dimensions for a browser that has never been visible", () => {14 it("tracks content dimensions for a browser that has never been visible", () => {
7 const tabId = "hidden-browser-surface-content-test";15 const tabId = "hidden-browser-surface-content-test";
8 useBrowserSurfaceStore.getState().presentContent(tabId, {16 useBrowserSurfaceStore.getState().presentContent(tabId, {
...@@ -28,11 +36,43 @@ describe("browserSurfaceStore", () => {...@@ -28,11 +36,43 @@ describe("browserSurfaceStore", () => {
28 expect(36 expect(
29 resolveBrowserSurfacePanelRect(37 resolveBrowserSurfacePanelRect(
30 {38 {
31 hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 },39 hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null },
32 active: { rect: liveRect, visible: true, content: null, updatedAt: 2 },40 active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null },
33 },41 },
34 "hidden",42 "hidden",
35 ),43 ),
36 ).toEqual(liveRect);44 ).toEqual(liveRect);
37 });45 });
46
47 it("ignores updates and releases from a stale surface lease", () => {
48 const tabId = "leased-browser-surface";
49 const staleRect = { x: 0, y: 0, width: 500, height: 700 };
50 const liveRect = { x: 10, y: 20, width: 900, height: 640 };
51 const staleLease = acquireBrowserSurface(tabId);
52 staleLease.present(staleRect, true);
53
54 const liveLease = acquireBrowserSurface(tabId);
55 liveLease.present(liveRect, true);
56 staleLease.present(staleRect, true);
57 staleLease.release();
58
59 expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
60 rect: liveRect,
61 visible: true,
62 });
63 });
64
65 it("hides a surface when its current lease is released", () => {
66 const tabId = "released-browser-surface";
67 const lease = acquireBrowserSurface(tabId);
68 lease.present({ x: 10, y: 20, width: 900, height: 640 }, true);
69
70 lease.release();
71 lease.present({ x: 0, y: 0, width: 1, height: 1 }, true);
72
73 expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
74 visible: false,
75 owner: null,
76 });
77 });
38});78});
apps/web/src/browser/browserSurfaceStore.ts+56-7
...@@ -12,6 +12,7 @@ export interface BrowserSurfacePresentation {...@@ -12,6 +12,7 @@ export interface BrowserSurfacePresentation {
12 readonly visible: boolean;12 readonly visible: boolean;
13 readonly content: BrowserSurfaceContentPresentation | null;13 readonly content: BrowserSurfaceContentPresentation | null;
14 readonly updatedAt: number;14 readonly updatedAt: number;
15 readonly owner: symbol | null;
15}16}
1617
17export interface BrowserSurfaceContentPresentation {18export interface BrowserSurfaceContentPresentation {
...@@ -26,9 +27,20 @@ export interface BrowserSurfaceContentPresentation {...@@ -26,9 +27,20 @@ export interface BrowserSurfaceContentPresentation {
2627
27interface BrowserSurfaceStoreState {28interface BrowserSurfaceStoreState {
28 readonly byTabId: Record<string, BrowserSurfacePresentation>;29 readonly byTabId: Record<string, BrowserSurfacePresentation>;
29 readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void;30 readonly claim: (tabId: string, owner: symbol) => void;
31 readonly present: (
32 tabId: string,
33 owner: symbol,
34 rect: BrowserSurfaceRect,
35 visible: boolean,
36 ) => void;
30 readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void;37 readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void;
31 readonly hide: (tabId: string) => void;38 readonly release: (tabId: string, owner: symbol) => void;
39}
40
41export interface BrowserSurfaceLease {
42 readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void;
43 readonly release: () => void;
32}44}
3345
34export function resolveBrowserSurfacePanelRect(46export function resolveBrowserSurfacePanelRect(
...@@ -60,14 +72,32 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect):...@@ -60,14 +72,32 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect):
6072
61export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({73export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({
62 byTabId: {},74 byTabId: {},
63 present: (tabId, rect, visible) =>75 claim: (tabId, owner) =>
76 set((state) => {
77 const current = state.byTabId[tabId];
78 if (current?.owner === owner) return state;
79 return {
80 byTabId: {
81 ...state.byTabId,
82 [tabId]: {
83 rect: current?.rect ?? null,
84 visible: false,
85 content: current?.content ?? null,
86 updatedAt: Date.now(),
87 owner,
88 },
89 },
90 };
91 }),
92 present: (tabId, owner, rect, visible) =>
64 set((state) => {93 set((state) => {
65 const current = state.byTabId[tabId];94 const current = state.byTabId[tabId];
95 if (current?.owner !== owner) return state;
66 if (current && current.visible === visible && rectEquals(current.rect, rect)) return state;96 if (current && current.visible === visible && rectEquals(current.rect, rect)) return state;
67 return {97 return {
68 byTabId: {98 byTabId: {
69 ...state.byTabId,99 ...state.byTabId,
70 [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() },100 [tabId]: { ...current, rect, visible, updatedAt: Date.now() },
71 },101 },
72 };102 };
73 }),103 }),
...@@ -83,6 +113,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =...@@ -83,6 +113,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
83 visible: false,113 visible: false,
84 content,114 content,
85 updatedAt: Date.now(),115 updatedAt: Date.now(),
116 owner: null,
86 },117 },
87 },118 },
88 };119 };
...@@ -107,15 +138,33 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =...@@ -107,15 +138,33 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
107 },138 },
108 };139 };
109 }),140 }),
110 hide: (tabId) =>141 release: (tabId, owner) =>
111 set((state) => {142 set((state) => {
112 const current = state.byTabId[tabId];143 const current = state.byTabId[tabId];
113 if (!current || !current.visible) return state;144 if (current?.owner !== owner) return state;
114 return {145 return {
115 byTabId: {146 byTabId: {
116 ...state.byTabId,147 ...state.byTabId,
117 [tabId]: { ...current, visible: false, updatedAt: Date.now() },148 [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null },
118 },149 },
119 };150 };
120 }),151 }),
121}));152}));
153
154export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease {
155 const owner = Symbol(`browser-surface:${tabId}`);
156 let released = false;
157 useBrowserSurfaceStore.getState().claim(tabId, owner);
158
159 return {
160 present: (rect, visible) => {
161 if (released) return;
162 useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible);
163 },
164 release: () => {
165 if (released) return;
166 released = true;
167 useBrowserSurfaceStore.getState().release(tabId, owner);
168 },
169 };
170}
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts created+43
...@@ -0,0 +1,43 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 HIDDEN_BROWSER_WEBVIEW_OFFSET,
5 resolveHostedBrowserWebviewWrapperStyle,
6} from "./hostedBrowserWebviewStyle";
7
8describe("resolveHostedBrowserWebviewWrapperStyle", () => {
9 it("places an active webview on its presented surface", () => {
10 expect(
11 resolveHostedBrowserWebviewWrapperStyle({
12 active: true,
13 rect: { x: 12, y: 34, width: 800, height: 600 },
14 hiddenSize: { width: 1280, height: 800 },
15 }),
16 ).toEqual({
17 left: 12,
18 top: 34,
19 width: 800,
20 height: 600,
21 zIndex: 30,
22 pointerEvents: "auto",
23 });
24 });
25
26 it("keeps an inactive webview paintable while moving it offscreen", () => {
27 const style = resolveHostedBrowserWebviewWrapperStyle({
28 active: false,
29 rect: { x: 12, y: 34, width: 800, height: 600 },
30 hiddenSize: { width: 393, height: 852 },
31 });
32
33 expect(style).toEqual({
34 left: HIDDEN_BROWSER_WEBVIEW_OFFSET,
35 top: HIDDEN_BROWSER_WEBVIEW_OFFSET,
36 width: 393,
37 height: 852,
38 zIndex: -1,
39 pointerEvents: "none",
40 visibility: "visible",
41 });
42 });
43});
apps/web/src/browser/hostedBrowserWebviewStyle.ts created+49
...@@ -0,0 +1,49 @@
1import type { BrowserSurfaceRect } from "./browserSurfaceStore";
2
3export interface HostedBrowserWebviewSize {
4 readonly width: number;
5 readonly height: number;
6}
7
8export interface HostedBrowserWebviewWrapperStyle {
9 readonly left: number;
10 readonly top: number;
11 readonly width: number;
12 readonly height: number;
13 readonly zIndex: number;
14 readonly pointerEvents: "auto" | "none";
15 readonly visibility?: "visible";
16}
17
18export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000;
19
20export function resolveHostedBrowserWebviewWrapperStyle(input: {
21 readonly active: boolean;
22 readonly rect: BrowserSurfaceRect | null;
23 readonly hiddenSize: HostedBrowserWebviewSize;
24}): HostedBrowserWebviewWrapperStyle {
25 const { active, hiddenSize, rect } = input;
26 if (active && rect) {
27 return {
28 left: rect.x,
29 top: rect.y,
30 width: rect.width,
31 height: rect.height,
32 zIndex: 30,
33 pointerEvents: "auto",
34 };
35 }
36
37 return {
38 left: HIDDEN_BROWSER_WEBVIEW_OFFSET,
39 top: HIDDEN_BROWSER_WEBVIEW_OFFSET,
40 width: hiddenSize.width,
41 height: hiddenSize.height,
42 zIndex: -1,
43 pointerEvents: "none",
44 // Keep the guest CSS-visible even while physically offscreen. Electron
45 // webviews can keep metadata/status alive under `visibility:hidden` while
46 // CDP Runtime/Input commands stall, which breaks offscreen automation.
47 visibility: "visible",
48 };
49}
apps/web/src/components/preview/PreviewAutomationHosts.tsx+31-12
...@@ -56,6 +56,7 @@ import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationR...@@ -56,6 +56,7 @@ import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationR
56import { createPreviewAutomationClientId } from "./previewAutomationClientId";56import { createPreviewAutomationClientId } from "./previewAutomationClientId";
57import {57import {
58 needsPreviewAutomationSessionSync,58 needsPreviewAutomationSessionSync,
59 resolvePreviewAutomationOpenTab,
59 resolvePreviewAutomationTarget,60 resolvePreviewAutomationTarget,
60} from "./previewAutomationTarget";61} from "./previewAutomationTarget";
61import { isPreviewViewportReady } from "./previewViewportReadiness";62import { isPreviewViewportReady } from "./previewViewportReadiness";
...@@ -230,6 +231,16 @@ const currentStatus = async (...@@ -230,6 +231,16 @@ const currentStatus = async (
230 };231 };
231};232};
232233
234const raiseAtomCommandFailure = (result: Parameters<typeof squashAtomCommandFailure>[0]): never => {
235 throw squashAtomCommandFailure(result);
236};
237
238const raisePreviewAutomationHostError = (
239 error: PreviewAutomationRecordingNotActiveError,
240): never => {
241 throw error;
242};
243
233export function PreviewAutomationHosts() {244export function PreviewAutomationHosts() {
234 const { environments } = useEnvironments();245 const { environments } = useEnvironments();
235 if (!isElectron || !previewBridge?.automation) return null;246 if (!isElectron || !previewBridge?.automation) return null;
...@@ -304,7 +315,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -304,7 +315,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
304 registry.refresh(previewEnvironment.list(listTarget));315 registry.refresh(previewEnvironment.list(listTarget));
305 const result = await listPreviews(listTarget);316 const result = await listPreviews(listTarget);
306 if (result._tag === "Failure") {317 if (result._tag === "Failure") {
307 throw squashAtomCommandFailure(result);318 return raiseAtomCommandFailure(result);
308 }319 }
309 reconcilePreviewServerSessions(threadRef, result.value.sessions);320 reconcilePreviewServerSessions(threadRef, result.value.sessions);
310 state = readThreadPreviewState(threadRef);321 state = readThreadPreviewState(threadRef);
...@@ -332,8 +343,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -332,8 +343,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
332 return await currentStatus(threadRef, tabId);343 return await currentStatus(threadRef, tabId);
333 case "open": {344 case "open": {
334 const input = request.input as PreviewAutomationOpenInput;345 const input = request.input as PreviewAutomationOpenInput;
335 let activeTabId =346 let activeTabId = resolvePreviewAutomationOpenTab(
336 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;347 state,
348 request.tabId,
349 input.reuseExistingTab ?? true,
350 );
337 let activeSnapshot = activeTabId351 let activeSnapshot = activeTabId
338 ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined)352 ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined)
339 : undefined;353 : undefined;
...@@ -348,7 +362,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -348,7 +362,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
348 },362 },
349 });363 });
350 if (result._tag === "Failure") {364 if (result._tag === "Failure") {
351 throw squashAtomCommandFailure(result);365 return raiseAtomCommandFailure(result);
352 }366 }
353 const snapshot = result.value;367 const snapshot = result.value;
354 applyPreviewServerSnapshot(threadRef, snapshot);368 applyPreviewServerSnapshot(threadRef, snapshot);
...@@ -416,7 +430,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -416,7 +430,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
416 },430 },
417 });431 });
418 if (result._tag === "Failure") {432 if (result._tag === "Failure") {
419 throw squashAtomCommandFailure(result);433 return raiseAtomCommandFailure(result);
420 }434 }
421 updatePreviewServerSnapshot(threadRef, result.value);435 updatePreviewServerSnapshot(threadRef, result.value);
422 const viewport = await waitForRenderedViewport(436 const viewport = await waitForRenderedViewport(
...@@ -492,15 +506,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -492,15 +506,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
492 }506 }
493 case "recordingStop": {507 case "recordingStop": {
494 const recordingTabId = readActiveBrowserRecordingTabId();508 const recordingTabId = readActiveBrowserRecordingTabId();
495 const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId);509 const stopTabId = resolveBrowserRecordingStopTarget(
510 recordingTabId,
511 request.tabIdExplicit ? request.tabId : undefined,
512 );
496 const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;513 const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;
497 if (!artifact) {514 if (!artifact) {
498 throw new PreviewAutomationRecordingNotActiveError({515 return raisePreviewAutomationHostError(
499 requestId: request.requestId,516 new PreviewAutomationRecordingNotActiveError({
500 environmentId,517 requestId: request.requestId,
501 threadId: request.threadId,518 environmentId,
502 tabId,519 threadId: request.threadId,
503 });520 tabId,
521 }),
522 );
504 }523 }
505 return artifact;524 return artifact;
506 }525 }
apps/web/src/components/preview/previewAutomationTarget.test.ts+14
...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 needsPreviewAutomationSessionSync,5 needsPreviewAutomationSessionSync,
6 resolvePreviewAutomationOpenTab,
6 resolvePreviewAutomationTarget,7 resolvePreviewAutomationTarget,
7} from "./previewAutomationTarget";8} from "./previewAutomationTarget";
89
...@@ -42,4 +43,17 @@ describe("preview automation target selection", () => {...@@ -42,4 +43,17 @@ describe("preview automation target selection", () => {
42 ),43 ),
43 ).toEqual({ tabId: null, snapshot: null });44 ).toEqual({ tabId: null, snapshot: null });
44 });45 });
46
47 it("reuses the provider session's pinned tab instead of the mutable UI tab", () => {
48 const uiActive = snapshot("tab-ui-active");
49 const agentTab = snapshot("tab-opened-by-agent");
50 const state = {
51 snapshot: uiActive,
52 sessions: { [uiActive.tabId]: uiActive, [agentTab.tabId]: agentTab },
53 };
54
55 expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, true)).toBe(agentTab.tabId);
56 expect(resolvePreviewAutomationOpenTab(state, undefined, true)).toBe(uiActive.tabId);
57 expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, false)).toBeNull();
58 });
45});59});
apps/web/src/components/preview/previewAutomationTarget.ts+12
...@@ -23,3 +23,15 @@ export function resolvePreviewAutomationTarget(...@@ -23,3 +23,15 @@ export function resolvePreviewAutomationTarget(
23 const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot;23 const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot;
24 return { tabId: snapshot?.tabId ?? null, snapshot };24 return { tabId: snapshot?.tabId ?? null, snapshot };
25}25}
26
27export function resolvePreviewAutomationOpenTab(
28 state: PreviewAutomationSessionIndex,
29 requestedTabId: string | undefined,
30 reuseExistingTab: boolean,
31): string | null {
32 if (!reuseExistingTab) return null;
33 if (requestedTabId !== undefined) {
34 return state.sessions[requestedTabId]?.tabId ?? null;
35 }
36 return state.snapshot?.tabId ?? null;
37}
packages/contracts/src/preview.test.ts+16
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11import {11import {
12 PreviewAutomationHost,12 PreviewAutomationHost,
13 PreviewAutomationError,13 PreviewAutomationError,
14 PreviewAutomationOpenInput,
14 PreviewAutomationResizeInput,15 PreviewAutomationResizeInput,
15 PreviewAutomationResizeResult,16 PreviewAutomationResizeResult,
16 PreviewAutomationStatus,17 PreviewAutomationStatus,
...@@ -22,6 +23,7 @@ const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);...@@ -22,6 +23,7 @@ const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
22const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);23const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
23const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);24const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
24const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);25const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
26const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput);
25const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult);27const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult);
26const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);28const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
27const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);29const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
...@@ -128,6 +130,20 @@ describe("PreviewAutomationResizeInput", () => {...@@ -128,6 +130,20 @@ describe("PreviewAutomationResizeInput", () => {
128 });130 });
129});131});
130132
133describe("preview automation tab targeting", () => {
134 it("accepts an explicit tab and rejects contradictory open behavior", () => {
135 expect(decodeResizeInput({ tabId: "tab-app", mode: "fill" })).toMatchObject({
136 tabId: "tab-app",
137 mode: "fill",
138 });
139 expect(decodeOpenInput({ tabId: "tab-app", reuseExistingTab: true })).toMatchObject({
140 tabId: "tab-app",
141 reuseExistingTab: true,
142 });
143 expect(() => decodeOpenInput({ tabId: "tab-app", reuseExistingTab: false })).toThrow();
144 });
145});
146
131describe("PreviewAutomationHost", () => {147describe("PreviewAutomationHost", () => {
132 it("accepts legacy hosts and current operation advertisements", () => {148 it("accepts legacy hosts and current operation advertisements", () => {
133 expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({149 expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({
packages/contracts/src/previewAutomation.ts+38-5
...@@ -50,6 +50,21 @@ export const PREVIEW_AUTOMATION_OPERATIONS = [...@@ -50,6 +50,21 @@ export const PREVIEW_AUTOMATION_OPERATIONS = [
50export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);50export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);
51export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;51export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
5252
53const PreviewAutomationTabTargetFields = {
54 tabId: Schema.optional(
55 PreviewTabId.annotate({
56 description:
57 "Exact collaborative browser tab to target. Omit to use this agent session's current tab.",
58 }),
59 ).annotate({
60 description:
61 "Exact collaborative browser tab to target. Omit to use this agent session's current tab.",
62 }),
63};
64
65export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields);
66export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type;
67
53export const PreviewAutomationStatus = Schema.Struct({68export const PreviewAutomationStatus = Schema.Struct({
54 available: Schema.Boolean,69 available: Schema.Boolean,
55 visible: Schema.Boolean,70 visible: Schema.Boolean,
...@@ -65,6 +80,7 @@ export const PreviewAutomationStatus = Schema.Struct({...@@ -65,6 +80,7 @@ export const PreviewAutomationStatus = Schema.Struct({
65export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;80export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
6681
67export const PreviewAutomationOpenInput = Schema.Struct({82export const PreviewAutomationOpenInput = Schema.Struct({
83 ...PreviewAutomationTabTargetFields,
68 url: Schema.optional(BoundedUrl).annotate({84 url: Schema.optional(BoundedUrl).annotate({
69 description:85 description:
70 "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.",86 "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.",
...@@ -77,13 +93,21 @@ export const PreviewAutomationOpenInput = Schema.Struct({...@@ -77,13 +93,21 @@ export const PreviewAutomationOpenInput = Schema.Struct({
77 reuseExistingTab: Schema.optional(93 reuseExistingTab: Schema.optional(
78 Schema.Boolean.annotate({94 Schema.Boolean.annotate({
79 description:95 description:
80 "Reuse the thread's active browser tab when available. Defaults to true; set false to create a new tab.",96 "Reuse tabId when supplied, otherwise this agent session's current tab. Defaults to true; set false to create a new tab.",
81 }),97 }),
82 ),98 ),
83}).annotate({99})
84 description:100 .check(
85 "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.",101 Schema.makeFilter(
86});102 (input) =>
103 !(input.tabId !== undefined && input.reuseExistingTab === false) ||
104 "tabId cannot be combined with reuseExistingTab=false.",
105 ),
106 )
107 .annotate({
108 description:
109 "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.",
110 });
87export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type;111export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type;
88112
89export const BrowserNavigationTarget = Schema.Union([113export const BrowserNavigationTarget = Schema.Union([
...@@ -117,6 +141,7 @@ export const BrowserNavigationTarget = Schema.Union([...@@ -117,6 +141,7 @@ export const BrowserNavigationTarget = Schema.Union([
117export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type;141export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type;
118142
119export const PreviewAutomationNavigateInput = Schema.Struct({143export const PreviewAutomationNavigateInput = Schema.Struct({
144 ...PreviewAutomationTabTargetFields,
120 url: Schema.optional(BoundedUrl).annotate({145 url: Schema.optional(BoundedUrl).annotate({
121 description:146 description:
122 "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.",147 "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.",
...@@ -155,6 +180,7 @@ export const PreviewAutomationNavigateInput = Schema.Struct({...@@ -155,6 +180,7 @@ export const PreviewAutomationNavigateInput = Schema.Struct({
155export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;180export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
156181
157export const PreviewAutomationResizeInput = Schema.Struct({182export const PreviewAutomationResizeInput = Schema.Struct({
183 ...PreviewAutomationTabTargetFields,
158 mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({184 mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({
159 description:185 description:
160 "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.",186 "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.",
...@@ -239,6 +265,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({...@@ -239,6 +265,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({
239});265});
240266
241export const PreviewAutomationClickInput = Schema.Struct({267export const PreviewAutomationClickInput = Schema.Struct({
268 ...PreviewAutomationTabTargetFields,
242 selector: Schema.optional(LegacySelector).annotate({269 selector: Schema.optional(LegacySelector).annotate({
243 description:270 description:
244 "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.",271 "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.",
...@@ -277,6 +304,7 @@ export const PreviewAutomationClickInput = Schema.Struct({...@@ -277,6 +304,7 @@ export const PreviewAutomationClickInput = Schema.Struct({
277export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;304export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;
278305
279export const PreviewAutomationTypeInput = Schema.Struct({306export const PreviewAutomationTypeInput = Schema.Struct({
307 ...PreviewAutomationTabTargetFields,
280 text: Schema.String.annotate({ description: "Literal text to insert." }),308 text: Schema.String.annotate({ description: "Literal text to insert." }),
281 selector: Schema.optional(LegacySelector).annotate({309 selector: Schema.optional(LegacySelector).annotate({
282 description: "Legacy CSS selector for the input. Prefer locator.",310 description: "Legacy CSS selector for the input. Prefer locator.",
...@@ -306,6 +334,7 @@ export const PreviewAutomationTypeInput = Schema.Struct({...@@ -306,6 +334,7 @@ export const PreviewAutomationTypeInput = Schema.Struct({
306export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type;334export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type;
307335
308export const PreviewAutomationPressInput = Schema.Struct({336export const PreviewAutomationPressInput = Schema.Struct({
337 ...PreviewAutomationTabTargetFields,
309 key: Schema.String.check(Schema.isTrimmed())338 key: Schema.String.check(Schema.isTrimmed())
310 .check(339 .check(
311 Schema.isNonEmpty({340 Schema.isNonEmpty({
...@@ -326,6 +355,7 @@ export const PreviewAutomationPressInput = Schema.Struct({...@@ -326,6 +355,7 @@ export const PreviewAutomationPressInput = Schema.Struct({
326export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type;355export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type;
327356
328export const PreviewAutomationScrollInput = Schema.Struct({357export const PreviewAutomationScrollInput = Schema.Struct({
358 ...PreviewAutomationTabTargetFields,
329 deltaX: Schema.optional(359 deltaX: Schema.optional(
330 Schema.Finite.annotate({360 Schema.Finite.annotate({
331 description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.",361 description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.",
...@@ -360,6 +390,7 @@ export const PreviewAutomationScrollInput = Schema.Struct({...@@ -360,6 +390,7 @@ export const PreviewAutomationScrollInput = Schema.Struct({
360export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;390export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;
361391
362export const PreviewAutomationEvaluateInput = Schema.Struct({392export const PreviewAutomationEvaluateInput = Schema.Struct({
393 ...PreviewAutomationTabTargetFields,
363 expression: Schema.String.check(Schema.isTrimmed())394 expression: Schema.String.check(Schema.isTrimmed())
364 .check(395 .check(
365 Schema.isNonEmpty({396 Schema.isNonEmpty({
...@@ -388,6 +419,7 @@ export const PreviewAutomationEvaluateInput = Schema.Struct({...@@ -388,6 +419,7 @@ export const PreviewAutomationEvaluateInput = Schema.Struct({
388export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type;419export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type;
389420
390export const PreviewAutomationWaitForInput = Schema.Struct({421export const PreviewAutomationWaitForInput = Schema.Struct({
422 ...PreviewAutomationTabTargetFields,
391 selector: Schema.optional(LegacySelector).annotate({423 selector: Schema.optional(LegacySelector).annotate({
392 description: "Legacy CSS selector that must match an element. Prefer locator.",424 description: "Legacy CSS selector that must match an element. Prefer locator.",
393 }),425 }),
...@@ -537,6 +569,7 @@ export const PreviewAutomationRequest = Schema.Struct({...@@ -537,6 +569,7 @@ export const PreviewAutomationRequest = Schema.Struct({
537 requestId: TrimmedNonEmptyString,569 requestId: TrimmedNonEmptyString,
538 threadId: ThreadId,570 threadId: ThreadId,
539 tabId: Schema.optional(PreviewTabId),571 tabId: Schema.optional(PreviewTabId),
572 tabIdExplicit: Schema.optional(Schema.Boolean),
540 operation: PreviewAutomationOperation,573 operation: PreviewAutomationOperation,
541 input: Schema.Unknown,574 input: Schema.Unknown,
542 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),575 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),