| author | |
| committer | |
| log | 44fb34ad5383badfb02c9f7daa1d8486277eeb01 |
| tree | 3ce4b090b77b1eb13c0f1d12b0cdf3f286c75be9 |
| parent | 6245c547c2d88e26434ac7b8e08213f2d9ef8577 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 15 | const environmentId = EnvironmentId.make("environment-mcp-test"); | 15 | const environmentId = EnvironmentId.make("environment-mcp-test"); |
| 16 | const threadId = ThreadId.make("thread-mcp-test"); | 16 | const threadId = ThreadId.make("thread-mcp-test"); |
| 17 | const tabId = PreviewTabId.make("tab-mcp-test"); | 17 | const tabId = PreviewTabId.make("tab-mcp-test"); |
| 18 | const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); | ||
| 18 | const invocation = { | 19 | const 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.void | 168 | 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 | ? undefined | 194 | ? 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; |
| 201 | 206 | ||
| 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); |
| 240 | 245 | ||
| 241 | const snapshot = yield* server | 246 | 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 | ); | ||
| 252 | 260 | ||
| 253 | const press = yield* server | 261 | 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"; |
| 17 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Deferred from "effect/Deferred"; | ||
| 18 | import * as Fiber from "effect/Fiber"; | 19 | import * as Fiber from "effect/Fiber"; |
| 19 | import * as Result from "effect/Result"; | 20 | import * as Result from "effect/Result"; |
| 20 | import * as Stream from "effect/Stream"; | 21 | import * 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 | ); |
| 86 | 87 | ||
| 88 | it.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 | |||
| 131 | it.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 | |||
| 177 | it.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 | |||
| 211 | it.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 | |||
| 87 | it.effect("announces a live replacement stream before delivering requests", () => | 269 | it.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; |
| 664 | 849 | ||
| 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 | }); | ||
| 672 | 860 | ||
| 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 | ); |
| 742 | 931 | ||
| 932 | it.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 | |||
| 743 | it.effect("fails requests assigned to the stream that is replaced", () => | 979 | it.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"; |
| 24 | import * as Context from "effect/Context"; | 24 | import * as Context from "effect/Context"; |
| 25 | import * as Clock from "effect/Clock"; | 25 | import * 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"; |
| 29 | import * as Layer from "effect/Layer"; | 29 | import * as Layer from "effect/Layer"; |
| 30 | import * as Option from "effect/Option"; | 30 | import * as Option from "effect/Option"; |
| 31 | import * as Queue from "effect/Queue"; | 31 | import * as Queue from "effect/Queue"; |
| 32 | import * as Schema from "effect/Schema"; | ||
| 32 | import * as Stream from "effect/Stream"; | 33 | import * as Stream from "effect/Stream"; |
| 33 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 34 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 34 | 35 | ||
| ... | @@ -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 | } |
| 83 | 86 | ||
| 84 | interface PreviewAutomationRequestErrorContext { | 87 | interface PreviewAutomationRequestErrorContext { |
| ... | @@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = ( | ... | @@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = ( |
| 144 | const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => | 147 | const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => |
| 145 | `${scope.environmentId}\u0000${scope.providerSessionId}`; | 148 | `${scope.environmentId}\u0000${scope.providerSessionId}`; |
| 146 | 149 | ||
| 150 | const isPreviewTabId = Schema.is(PreviewTabId); | ||
| 151 | |||
| 152 | const 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 | |||
| 147 | const supportsOperation = ( | 158 | const 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 | }); |
| 463 | 482 | ||
| 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 | }); |
| 531 | 581 | ||
| 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"; |
| 10 | 11 | ||
| 11 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 12 | import * 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 | }); |
| 33 | 36 | ||
| 37 | const 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 | |||
| 34 | const handlers = { | 49 | const 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]; |
| 57 | 77 | ||
| 58 | const { preview_snapshot, ...standardHandlers } = handlers; | 78 | const { 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 => |
| 37 | 38 | ||
| 38 | export const PreviewStatusTool = Tool.make("preview_status", { | 39 | export 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", { |
| 50 | export const PreviewOpenTool = browserTool( | 52 | export 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( |
| 63 | export const PreviewNavigateTool = safeBrowserTool( | 65 | export 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( |
| 74 | export const PreviewResizeTool = safeBrowserTool( | 76 | export 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( |
| 87 | export const PreviewSnapshotTool = readonlyBrowserTool( | 89 | export 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( |
| 97 | export const PreviewClickTool = browserTool( | 100 | export 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( |
| 108 | export const PreviewTypeTool = browserTool( | 111 | export 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( |
| 119 | export const PreviewPressTool = browserTool( | 122 | export 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( |
| 130 | export const PreviewScrollTool = safeBrowserTool( | 133 | export 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( |
| 141 | export const PreviewEvaluateTool = browserTool( | 144 | export 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( |
| 152 | export const PreviewWaitForTool = readonlyBrowserTool( | 155 | export 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( |
| 163 | export const PreviewRecordingStartTool = safeBrowserTool( | 166 | export 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( |
| 173 | export const PreviewRecordingStopTool = safeBrowserTool( | 177 | export 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 | } |
| 1023 | 1023 | ||
| 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 | ); | ||
| 1042 | 1037 | ||
| 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"; |
| 2 | 2 | ||
| 3 | import { useEffect, useRef } from "react"; | 3 | import { useLayoutEffect, useRef } from "react"; |
| 4 | 4 | ||
| 5 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | 5 | import { acquireBrowserSurface } from "./browserSurfaceStore"; |
| 6 | 6 | ||
| 7 | export function BrowserSurfaceSlot(props: { | 7 | export 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); |
| 14 | 14 | ||
| 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]); |
| 43 | 43 |
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"; |
| 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { cn } from "~/lib/utils"; | 9 | import { cn } from "~/lib/utils"; |
| 10 | 10 | ||
| 11 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; | 11 | import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; |
| 12 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | 12 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 13 | import { browserViewportSettingKey } from "./browserViewportLayout"; | 13 | import { browserViewportSettingKey } from "./browserViewportLayout"; |
| 14 | import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState"; | ||
| 15 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; | 14 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; |
| 16 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; | 15 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; |
| 17 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; | 16 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 17 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; | ||
| 18 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; | 18 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 19 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; | 19 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; |
| 20 | 20 | ||
| ... | @@ -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 }); |
| 62 | 61 | ||
| 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: { |
| 170 | 176 | ||
| 171 | if (!config) return null; | 177 | if (!config) return null; |
| 172 | 178 | ||
| 173 | const wrapperStyle = | 179 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
| 174 | active && lastRect | 180 | 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 | }; | ||
| 191 | 184 | ||
| 192 | return ( | 185 | return ( |
| 193 | <div | 186 | <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 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | const { 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 | |||
| 31 | vi.mock("~/components/preview/previewBridge", () => ({ | ||
| 32 | previewBridge: { | ||
| 33 | recording: { onFrame, save, startScreencast, stopScreencast }, | ||
| 34 | }, | ||
| 35 | })); | ||
| 36 | |||
| 37 | vi.mock("~/rpc/atomRegistry", () => ({ | ||
| 38 | appAtomRegistry: { set: registrySet }, | ||
| 39 | })); | ||
| 40 | |||
| 41 | vi.mock("./browserSurfaceStore", () => ({ | ||
| 42 | useBrowserSurfaceStore: { | ||
| 43 | getState: () => surfaceState, | ||
| 44 | }, | ||
| 45 | })); | ||
| 46 | |||
| 47 | import { | ||
| 48 | BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, | ||
| 49 | BrowserRecordingConflictError, | ||
| 50 | BrowserRecordingOperationError, | ||
| 51 | BrowserRecordingRequiresVisibleTabError, | ||
| 52 | startBrowserRecording, | ||
| 53 | stopBrowserRecording, | ||
| 54 | } from "./browserRecording"; | ||
| 55 | |||
| 56 | class 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 | |||
| 83 | describe("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 | } |
| 48 | 48 | ||
| 49 | export 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 | |||
| 49 | export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( | 60 | export 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 | } |
| 70 | 82 | ||
| 83 | const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); | ||
| 84 | |||
| 85 | type BrowserRecordingLifecycle = | ||
| 86 | | { readonly phase: "starting" } | ||
| 87 | | { readonly phase: "recording" } | ||
| 88 | | { | ||
| 89 | readonly phase: "stopping"; | ||
| 90 | readonly stopPromise: Promise<DesktopPreviewRecordingArtifact | null>; | ||
| 91 | }; | ||
| 92 | |||
| 71 | interface ActiveRecording { | 93 | interface 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 | } |
| 80 | 104 | ||
| 81 | const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe( | 105 | const 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 { |
| 90 | let active: ActiveRecording | null = null; | 114 | let active: ActiveRecording | null = null; |
| 91 | let unsubscribeFrames: (() => void) | null = null; | 115 | let unsubscribeFrames: (() => void) | null = null; |
| 92 | 116 | ||
| 117 | export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; | ||
| 118 | |||
| 93 | export function readActiveBrowserRecordingTabId(): string | null { | 119 | export 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 | }; |
| 133 | 159 | ||
| 160 | const 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 | |||
| 170 | const isRecordingStarting = (recording: ActiveRecording): boolean => | ||
| 171 | active === recording && recording.lifecycle.phase === "starting"; | ||
| 172 | |||
| 173 | const 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 | |||
| 195 | const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => | ||
| 196 | isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; | ||
| 197 | |||
| 134 | export async function startBrowserRecording(tabId: string): Promise<string> { | 198 | export 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 === undefined | 275 | throw new BrowserRecordingOperationError({ |
| 215 | ? cause | 276 | 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 | } |
| 226 | 333 | ||
| 227 | export async function stopBrowserRecording( | 334 | const 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 | } |
| 273 | 380 | ||
| 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 | |||
| 420 | const 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 | |||
| 433 | export 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"; |
| 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; | 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; |
| 4 | 4 | ||
| 5 | describe("resolveBrowserRecordingStopTarget", () => { | 5 | describe("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 @@ |
| 1 | export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null { | 1 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | 3 | import { |
| 4 | acquireBrowserSurface, | ||
| 5 | resolveBrowserSurfacePanelRect, | ||
| 6 | useBrowserSurfaceStore, | ||
| 7 | } from "./browserSurfaceStore"; | ||
| 4 | 8 | ||
| 5 | describe("browserSurfaceStore", () => { | 9 | describe("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 | } |
| 16 | 17 | ||
| 17 | export interface BrowserSurfaceContentPresentation { | 18 | export interface BrowserSurfaceContentPresentation { |
| ... | @@ -26,9 +27,20 @@ export interface BrowserSurfaceContentPresentation { | ... | @@ -26,9 +27,20 @@ export interface BrowserSurfaceContentPresentation { |
| 26 | 27 | ||
| 27 | interface BrowserSurfaceStoreState { | 28 | interface 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 | |||
| 41 | export interface BrowserSurfaceLease { | ||
| 42 | readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; | ||
| 43 | readonly release: () => void; | ||
| 32 | } | 44 | } |
| 33 | 45 | ||
| 34 | export function resolveBrowserSurfacePanelRect( | 46 | export function resolveBrowserSurfacePanelRect( |
| ... | @@ -60,14 +72,32 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): | ... | @@ -60,14 +72,32 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): |
| 60 | 72 | ||
| 61 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ | 73 | export 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 | |||
| 154 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | HIDDEN_BROWSER_WEBVIEW_OFFSET, | ||
| 5 | resolveHostedBrowserWebviewWrapperStyle, | ||
| 6 | } from "./hostedBrowserWebviewStyle"; | ||
| 7 | |||
| 8 | describe("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 @@ | ||
| 1 | import type { BrowserSurfaceRect } from "./browserSurfaceStore"; | ||
| 2 | |||
| 3 | export interface HostedBrowserWebviewSize { | ||
| 4 | readonly width: number; | ||
| 5 | readonly height: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | export 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 | |||
| 18 | export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; | ||
| 19 | |||
| 20 | export 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 |
| 56 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; | 56 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; |
| 57 | import { | 57 | import { |
| 58 | needsPreviewAutomationSessionSync, | 58 | needsPreviewAutomationSessionSync, |
| 59 | resolvePreviewAutomationOpenTab, | ||
| 59 | resolvePreviewAutomationTarget, | 60 | resolvePreviewAutomationTarget, |
| 60 | } from "./previewAutomationTarget"; | 61 | } from "./previewAutomationTarget"; |
| 61 | import { isPreviewViewportReady } from "./previewViewportReadiness"; | 62 | import { isPreviewViewportReady } from "./previewViewportReadiness"; |
| ... | @@ -230,6 +231,16 @@ const currentStatus = async ( | ... | @@ -230,6 +231,16 @@ const currentStatus = async ( |
| 230 | }; | 231 | }; |
| 231 | }; | 232 | }; |
| 232 | 233 | ||
| 234 | const raiseAtomCommandFailure = (result: Parameters<typeof squashAtomCommandFailure>[0]): never => { | ||
| 235 | throw squashAtomCommandFailure(result); | ||
| 236 | }; | ||
| 237 | |||
| 238 | const raisePreviewAutomationHostError = ( | ||
| 239 | error: PreviewAutomationRecordingNotActiveError, | ||
| 240 | ): never => { | ||
| 241 | throw error; | ||
| 242 | }; | ||
| 243 | |||
| 233 | export function PreviewAutomationHosts() { | 244 | export 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 = activeTabId | 351 | 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"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | needsPreviewAutomationSessionSync, | 5 | needsPreviewAutomationSessionSync, |
| 6 | resolvePreviewAutomationOpenTab, | ||
| 6 | resolvePreviewAutomationTarget, | 7 | resolvePreviewAutomationTarget, |
| 7 | } from "./previewAutomationTarget"; | 8 | } from "./previewAutomationTarget"; |
| 8 | 9 | ||
| ... | @@ -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 | |||
| 27 | export 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 { |
| 11 | import { | 11 | import { |
| 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); |
| 22 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); | 23 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); |
| 23 | const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); | 24 | const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); |
| 24 | const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); | 25 | const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); |
| 26 | const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); | ||
| 25 | const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); | 27 | const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); |
| 26 | const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); | 28 | const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); |
| 27 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); | 29 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); |
| ... | @@ -128,6 +130,20 @@ describe("PreviewAutomationResizeInput", () => { | ... | @@ -128,6 +130,20 @@ describe("PreviewAutomationResizeInput", () => { |
| 128 | }); | 130 | }); |
| 129 | }); | 131 | }); |
| 130 | 132 | ||
| 133 | describe("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 | |||
| 131 | describe("PreviewAutomationHost", () => { | 147 | describe("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 = [ |
| 50 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); | 50 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); |
| 51 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; | 51 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; |
| 52 | 52 | ||
| 53 | const 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 | |||
| 65 | export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); | ||
| 66 | export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; | ||
| 67 | |||
| 53 | export const PreviewAutomationStatus = Schema.Struct({ | 68 | export 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({ |
| 65 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; | 80 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; |
| 66 | 81 | ||
| 67 | export const PreviewAutomationOpenInput = Schema.Struct({ | 82 | export 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 | }); | ||
| 87 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; | 111 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; |
| 88 | 112 | ||
| 89 | export const BrowserNavigationTarget = Schema.Union([ | 113 | export const BrowserNavigationTarget = Schema.Union([ |
| ... | @@ -117,6 +141,7 @@ export const BrowserNavigationTarget = Schema.Union([ | ... | @@ -117,6 +141,7 @@ export const BrowserNavigationTarget = Schema.Union([ |
| 117 | export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; | 141 | export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; |
| 118 | 142 | ||
| 119 | export const PreviewAutomationNavigateInput = Schema.Struct({ | 143 | export 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({ |
| 155 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; | 180 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; |
| 156 | 181 | ||
| 157 | export const PreviewAutomationResizeInput = Schema.Struct({ | 182 | export 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 | }); |
| 240 | 266 | ||
| 241 | export const PreviewAutomationClickInput = Schema.Struct({ | 267 | export 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({ |
| 277 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; | 304 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; |
| 278 | 305 | ||
| 279 | export const PreviewAutomationTypeInput = Schema.Struct({ | 306 | export 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({ |
| 306 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; | 334 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; |
| 307 | 335 | ||
| 308 | export const PreviewAutomationPressInput = Schema.Struct({ | 336 | export 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({ |
| 326 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; | 355 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; |
| 327 | 356 | ||
| 328 | export const PreviewAutomationScrollInput = Schema.Struct({ | 357 | export 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({ |
| 360 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; | 390 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; |
| 361 | 391 | ||
| 362 | export const PreviewAutomationEvaluateInput = Schema.Struct({ | 392 | export 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({ |
| 388 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; | 419 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; |
| 389 | 420 | ||
| 390 | export const PreviewAutomationWaitForInput = Schema.Struct({ | 421 | export 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)), |