| author | |
| committer | |
| log | 894d33419dceb366eecda8e5e27c6fc043c3959e |
| tree | c73aa93cc0033ab05ea152d1b74a2e93af4f3eb8 |
| parent | d4cd7d5c33122473da22ae118c93477ef7e38310 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 371 insertions(+), 14 deletions(-)
apps/server/src/mcp/PreviewAutomationBroker.test.ts+126| ... | ... | @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang |
| 685 | 685 | environmentId: scope.environmentId, |
| 686 | 686 | connectionId: "connection-stale", |
| 687 | 687 | focused: true, |
| 688 | liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], | |
| 688 | 689 | }); |
| 689 | 690 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( |
| 690 | 691 | "second", |
| ... | ... | @@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang |
| 725 | 726 | ), |
| 726 | 727 | ); |
| 727 | 728 | |
| 729 | it.effect("prefers the live tab owner for new sessions without moving existing leases", () => | |
| 730 | Effect.scoped( | |
| 731 | Effect.gen(function* () { | |
| 732 | const broker = yield* makeBroker; | |
| 733 | const connections = new Map<string, string>(); | |
| 734 | for (const clientId of ["owner", "other"]) { | |
| 735 | const requests = requestsFrom( | |
| 736 | yield* broker.connect(makeHost({ clientId })), | |
| 737 | (connectionId) => connections.set(clientId, connectionId), | |
| 738 | ); | |
| 739 | yield* Stream.runForEach(requests, (request) => | |
| 740 | broker.respond({ | |
| 741 | clientId, | |
| 742 | connectionId: request.connectionId, | |
| 743 | requestId: request.requestId, | |
| 744 | ok: true, | |
| 745 | result: clientId, | |
| 746 | }), | |
| 747 | ).pipe(Effect.forkScoped); | |
| 748 | } | |
| 749 | yield* Effect.yieldNow; | |
| 750 | yield* broker.focusHost({ | |
| 751 | clientId: "owner", | |
| 752 | environmentId: scope.environmentId, | |
| 753 | connectionId: connections.get("owner")!, | |
| 754 | focused: false, | |
| 755 | liveTabs: [ | |
| 756 | { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, | |
| 757 | ], | |
| 758 | }); | |
| 759 | yield* broker.focusHost({ | |
| 760 | clientId: "other", | |
| 761 | environmentId: scope.environmentId, | |
| 762 | connectionId: connections.get("other")!, | |
| 763 | focused: true, | |
| 764 | liveTabs: [ | |
| 765 | { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, | |
| 766 | { | |
| 767 | threadId: ThreadId.make("another-thread"), | |
| 768 | tabId: PreviewTabId.make("different-tab"), | |
| 769 | visible: true, | |
| 770 | }, | |
| 771 | ], | |
| 772 | }); | |
| 773 | expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe( | |
| 774 | "owner", | |
| 775 | ); | |
| 776 | expect( | |
| 777 | yield* broker.invoke<string>({ | |
| 778 | scope: { ...scope, providerSessionId: "explicit-owner" }, | |
| 779 | tabId: PreviewTabId.make("signed-in"), | |
| 780 | operation: "snapshot", | |
| 781 | input: {}, | |
| 782 | }), | |
| 783 | ).toBe("owner"); | |
| 784 | expect( | |
| 785 | yield* broker.invoke<string>({ | |
| 786 | scope: { ...scope, providerSessionId: "other-tab" }, | |
| 787 | tabId: PreviewTabId.make("different-tab"), | |
| 788 | operation: "evaluate", | |
| 789 | input: {}, | |
| 790 | }), | |
| 791 | ).toBe("other"); | |
| 792 | ||
| 793 | yield* broker.focusHost({ | |
| 794 | clientId: "owner", | |
| 795 | environmentId: scope.environmentId, | |
| 796 | connectionId: connections.get("owner")!, | |
| 797 | focused: false, | |
| 798 | liveTabs: [], | |
| 799 | }); | |
| 800 | expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe( | |
| 801 | "owner", | |
| 802 | ); | |
| 803 | expect( | |
| 804 | yield* broker.invoke<string>({ | |
| 805 | scope: { ...scope, providerSessionId: "after-tab-closed" }, | |
| 806 | operation: "evaluate", | |
| 807 | input: {}, | |
| 808 | }), | |
| 809 | ).toBe("other"); | |
| 810 | }), | |
| 811 | ), | |
| 812 | ); | |
| 813 | ||
| 814 | it.effect("prefers a focused host over unrelated extra capabilities for a new session", () => | |
| 815 | Effect.scoped( | |
| 816 | Effect.gen(function* () { | |
| 817 | const broker = yield* makeBroker; | |
| 818 | let focusedConnectionId = ""; | |
| 819 | for (const [clientId, supportedOperations] of [ | |
| 820 | ["focused", ["status"]], | |
| 821 | ["background", ["status", "resize"]], | |
| 822 | ] as const) { | |
| 823 | const requests = requestsFrom( | |
| 824 | yield* broker.connect(makeHost({ clientId, supportedOperations })), | |
| 825 | (connectionId) => { | |
| 826 | if (clientId === "focused") focusedConnectionId = connectionId; | |
| 827 | }, | |
| 828 | ); | |
| 829 | yield* Stream.runForEach(requests, (request) => | |
| 830 | broker.respond({ | |
| 831 | clientId, | |
| 832 | connectionId: request.connectionId, | |
| 833 | requestId: request.requestId, | |
| 834 | ok: true, | |
| 835 | result: clientId, | |
| 836 | }), | |
| 837 | ).pipe(Effect.forkScoped); | |
| 838 | } | |
| 839 | yield* Effect.yieldNow; | |
| 840 | yield* broker.focusHost({ | |
| 841 | clientId: "focused", | |
| 842 | environmentId: scope.environmentId, | |
| 843 | connectionId: focusedConnectionId, | |
| 844 | focused: true, | |
| 845 | }); | |
| 846 | expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe( | |
| 847 | "focused", | |
| 848 | ); | |
| 849 | }), | |
| 850 | ), | |
| 851 | ); | |
| 852 | ||
| 728 | 853 | it.effect("does not route new operations to legacy hosts that did not advertise support", () => |
| 729 | 854 | Effect.scoped( |
| 730 | 855 | Effect.gen(function* () { |
| ... | ... | @@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" |
| 921 | 1046 | environmentId: scope.environmentId, |
| 922 | 1047 | connectionId: firstConnectionId, |
| 923 | 1048 | focused: true, |
| 1049 | liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], | |
| 924 | 1050 | }); |
| 925 | 1051 | expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ |
| 926 | 1052 | host: "first", |
apps/server/src/mcp/PreviewAutomationBroker.ts+12-1| ... | ... | @@ -73,6 +73,7 @@ interface ClientConnection { |
| 73 | 73 | readonly environmentId: PreviewAutomationHost["environmentId"]; |
| 74 | 74 | readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>; |
| 75 | 75 | readonly focused: boolean; |
| 76 | readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>; | |
| 76 | 77 | readonly focusOrder: number; |
| 77 | 78 | readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>; |
| 78 | 79 | } |
| ... | ... | @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 376 | 377 | environmentId: host.environmentId, |
| 377 | 378 | supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), |
| 378 | 379 | focused: false, |
| 380 | liveTabs: [], | |
| 379 | 381 | focusOrder: 0, |
| 380 | 382 | queue, |
| 381 | 383 | }; |
| ... | ... | @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 432 | 434 | clients.set(host.clientId, { |
| 433 | 435 | ...currentHost, |
| 434 | 436 | focused: host.focused, |
| 437 | liveTabs: host.liveTabs ?? currentHost.liveTabs, | |
| 435 | 438 | focusOrder: host.focused ? focusSequence : currentHost.focusOrder, |
| 436 | 439 | }); |
| 437 | 440 | return { ...current, clients, focusSequence }; |
| ... | ... | @@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 492 | 495 | // operation is not silently moved to a newer client: the caller gets a |
| 493 | 496 | // capability failure and can deliberately start a fresh provider |
| 494 | 497 | // session. A dead lease is pruned above and may fail over. |
| 498 | const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => | |
| 499 | host.liveTabs.some( | |
| 500 | (tab) => | |
| 501 | tab.threadId === input.scope.threadId && | |
| 502 | (!visibleOnly || tab.visible === true) && | |
| 503 | (input.tabId === undefined || tab.tabId === input.tabId), | |
| 504 | ); | |
| 495 | 505 | const connection = |
| 496 | 506 | hasLiveAssignment && supportsOperation(assignedConnection, input.operation) |
| 497 | 507 | ? assignedConnection |
| ... | ... | @@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 505 | 515 | ) |
| 506 | 516 | .sort( |
| 507 | 517 | (left, right) => |
| 508 | right.supportedOperations.size - left.supportedOperations.size || | |
| 518 | Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || | |
| 519 | Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || | |
| 509 | 520 | Number(right.focused) - Number(left.focused) || |
| 510 | 521 | right.focusOrder - left.focusOrder, |
| 511 | 522 | )[0]; |
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx+177-4| ... | ... | @@ -8,13 +8,22 @@ import { |
| 8 | 8 | type PreviewOpenInput, |
| 9 | 9 | type PreviewSessionSnapshot, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | |
| 12 | import * as Cause from "effect/Cause"; | |
| 11 | 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 12 | 14 | import { act } from "react"; |
| 13 | 15 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 14 | 16 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 15 | 17 | |
| 18 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | |
| 19 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | |
| 16 | 20 | import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; |
| 17 | import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; | |
| 21 | import { | |
| 22 | applyPreviewServerSnapshot, | |
| 23 | readThreadPreviewState, | |
| 24 | resetPreviewStateForTests, | |
| 25 | applyPreviewDesktopState, | |
| 26 | } from "~/previewStateStore"; | |
| 18 | 27 | import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; |
| 19 | 28 | |
| 20 | 29 | import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; |
| ... | ... | @@ -31,7 +40,7 @@ const mocks = vi.hoisted(() => ({ |
| 31 | 40 | vi.fn< |
| 32 | 41 | (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void> |
| 33 | 42 | >(), |
| 34 | focus: vi.fn(async () => undefined), | |
| 43 | focus: vi.fn<() => Promise<AtomCommandResult<void, Error>>>(), | |
| 35 | 44 | })); |
| 36 | 45 | |
| 37 | 46 | vi.mock("~/localApi", () => ({ |
| ... | ... | @@ -110,12 +119,21 @@ beforeEach(async () => { |
| 110 | 119 | vi.clearAllMocks(); |
| 111 | 120 | mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); |
| 112 | 121 | mocks.respond.mockReset(); |
| 122 | mocks.focus.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 113 | 123 | __resetClientSettingsPersistenceForTests(); |
| 114 | 124 | resetPreviewStateForTests(); |
| 125 | useBrowserSurfaceStore.setState({ byTabId: {} }); | |
| 115 | 126 | appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); |
| 116 | 127 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 117 | 128 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); |
| 118 | vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); | |
| 129 | vi.stubGlobal( | |
| 130 | "document", | |
| 131 | Object.assign(new EventTarget(), { | |
| 132 | hasFocus: () => false, | |
| 133 | visibilityState: "visible", | |
| 134 | querySelectorAll: () => [], | |
| 135 | }), | |
| 136 | ); | |
| 119 | 137 | await act(() => { |
| 120 | 138 | renderer = create( |
| 121 | 139 | <AppAtomRegistryProvider> |
| ... | ... | @@ -161,7 +179,11 @@ describe("PreviewAutomationHosts open", () => { |
| 161 | 179 | input: { threadId, viewport, profileId: "work" }, |
| 162 | 180 | }); |
| 163 | 181 | expect(mocks.getClientSettings).toHaveBeenCalledOnce(); |
| 164 | await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); | |
| 182 | await expect(response.promise).resolves.toMatchObject({ | |
| 183 | requestId: "open-request", | |
| 184 | ok: true, | |
| 185 | result: { available: false, tabId: snapshot.tabId }, | |
| 186 | }); | |
| 165 | 187 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); |
| 166 | 188 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); |
| 167 | 189 | }); |
| ... | ... | @@ -188,3 +210,154 @@ describe("PreviewAutomationHosts open", () => { |
| 188 | 210 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); |
| 189 | 211 | }); |
| 190 | 212 | }); |
| 213 | ||
| 214 | describe("PreviewAutomationHosts ownership", () => { | |
| 215 | it("reports only local live tabs and removes ownership when their web contents close", async () => { | |
| 216 | await act(() => { | |
| 217 | appAtomRegistry.set( | |
| 218 | requestsAtom, | |
| 219 | AsyncResult.success({ type: "connected", connectionId: "automation-connection" }), | |
| 220 | ); | |
| 221 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 222 | }); | |
| 223 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 224 | expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }), | |
| 225 | ); | |
| 226 | const overlay = { | |
| 227 | hasWebContents: true, | |
| 228 | canGoBack: false, | |
| 229 | canGoForward: false, | |
| 230 | loading: false, | |
| 231 | zoomFactor: 1, | |
| 232 | pictureInPicture: false, | |
| 233 | colorScheme: "system" as const, | |
| 234 | audioMuted: false, | |
| 235 | audible: false, | |
| 236 | controller: "none" as const, | |
| 237 | favicon: null, | |
| 238 | }; | |
| 239 | await act(() => applyPreviewDesktopState(threadRef, snapshot.tabId, overlay)); | |
| 240 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 241 | expect.objectContaining({ | |
| 242 | input: expect.objectContaining({ | |
| 243 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: false }], | |
| 244 | }), | |
| 245 | }), | |
| 246 | ); | |
| 247 | const reportCount = mocks.focus.mock.calls.length; | |
| 248 | await act(() => | |
| 249 | applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, loading: true }), | |
| 250 | ); | |
| 251 | expect(mocks.focus).toHaveBeenCalledTimes(reportCount); | |
| 252 | const runtimeTabId = previewRuntimeTabId(threadRef, null, snapshot.tabId); | |
| 253 | const owner = Symbol(); | |
| 254 | await act(() => { | |
| 255 | useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); | |
| 256 | useBrowserSurfaceStore | |
| 257 | .getState() | |
| 258 | .present(runtimeTabId, owner, { x: 0, y: 0, width: 800, height: 600 }, true, 0, 1); | |
| 259 | }); | |
| 260 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 261 | expect.objectContaining({ | |
| 262 | input: expect.objectContaining({ | |
| 263 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }], | |
| 264 | }), | |
| 265 | }), | |
| 266 | ); | |
| 267 | for (const visibilityState of ["hidden", "visible"]) { | |
| 268 | await act(() => { | |
| 269 | Object.assign(document, { visibilityState }); | |
| 270 | document.dispatchEvent(new Event("visibilitychange")); | |
| 271 | }); | |
| 272 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 273 | expect.objectContaining({ | |
| 274 | input: expect.objectContaining({ | |
| 275 | focused: false, | |
| 276 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: visibilityState === "visible" }], | |
| 277 | }), | |
| 278 | }), | |
| 279 | ); | |
| 280 | } | |
| 281 | await act(() => { | |
| 282 | appAtomRegistry.set( | |
| 283 | requestsAtom, | |
| 284 | AsyncResult.success({ type: "connected", connectionId: "reconnected" }), | |
| 285 | ); | |
| 286 | }); | |
| 287 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 288 | expect.objectContaining({ | |
| 289 | input: expect.objectContaining({ | |
| 290 | connectionId: "reconnected", | |
| 291 | liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }], | |
| 292 | }), | |
| 293 | }), | |
| 294 | ); | |
| 295 | await act(() => | |
| 296 | applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, hasWebContents: false }), | |
| 297 | ); | |
| 298 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 299 | expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }), | |
| 300 | ); | |
| 301 | }); | |
| 302 | ||
| 303 | it.each([false, true])( | |
| 304 | "retries failed reports without clearing newer connection reports (reconnect: %s)", | |
| 305 | async (reconnect) => { | |
| 306 | const report = deferred<Awaited<ReturnType<typeof mocks.focus>>>(); | |
| 307 | mocks.focus.mockReturnValueOnce(report.promise); | |
| 308 | await act(() => { | |
| 309 | appAtomRegistry.set( | |
| 310 | requestsAtom, | |
| 311 | AsyncResult.success({ type: "connected", connectionId: "first" }), | |
| 312 | ); | |
| 313 | }); | |
| 314 | if (reconnect) { | |
| 315 | await act(() => { | |
| 316 | appAtomRegistry.set( | |
| 317 | requestsAtom, | |
| 318 | AsyncResult.success({ type: "connected", connectionId: "second" }), | |
| 319 | ); | |
| 320 | }); | |
| 321 | } | |
| 322 | await act(async () => { | |
| 323 | report.resolve(AsyncResult.failure(Cause.fail(new Error("Focus report failed")))); | |
| 324 | await report.promise; | |
| 325 | }); | |
| 326 | expect(mocks.focus).toHaveBeenCalledTimes(reconnect ? 2 : 1); | |
| 327 | await act(() => { | |
| 328 | document.dispatchEvent(new Event("visibilitychange")); | |
| 329 | }); | |
| 330 | expect(mocks.focus).toHaveBeenCalledTimes(2); | |
| 331 | expect(mocks.focus).toHaveBeenLastCalledWith( | |
| 332 | expect.objectContaining({ | |
| 333 | input: expect.objectContaining({ connectionId: reconnect ? "second" : "first" }), | |
| 334 | }), | |
| 335 | ); | |
| 336 | }, | |
| 337 | ); | |
| 338 | ||
| 339 | it("does not claim an available runtime from a server snapshot alone", async () => { | |
| 340 | const response = deferred<PreviewAutomationResponse>(); | |
| 341 | mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); | |
| 342 | await act(async () => { | |
| 343 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 344 | appAtomRegistry.set( | |
| 345 | requestsAtom, | |
| 346 | AsyncResult.success({ | |
| 347 | ...requestEvent, | |
| 348 | request: { | |
| 349 | ...requestEvent.request, | |
| 350 | operation: "status", | |
| 351 | tabId: snapshot.tabId, | |
| 352 | input: {}, | |
| 353 | }, | |
| 354 | }), | |
| 355 | ); | |
| 356 | await response.promise; | |
| 357 | }); | |
| 358 | await expect(response.promise).resolves.toMatchObject({ | |
| 359 | ok: true, | |
| 360 | result: { available: false, tabId: snapshot.tabId }, | |
| 361 | }); | |
| 362 | }); | |
| 363 | }); |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+47-9| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 4 | 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | 6 | import { |
| 6 | 7 | FILL_PREVIEW_VIEWPORT, |
| ... | ... | @@ -22,12 +23,14 @@ import { |
| 22 | 23 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; |
| 23 | 24 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; |
| 24 | 25 | import { Atom } from "effect/unstable/reactivity"; |
| 26 | import { useShallow } from "zustand/react/shallow"; | |
| 25 | 27 | |
| 26 | 28 | import { |
| 27 | 29 | applyPreviewServerSnapshot, |
| 28 | 30 | readThreadPreviewState, |
| 29 | 31 | reconcilePreviewServerSessions, |
| 30 | 32 | updatePreviewServerSnapshot, |
| 33 | useActivePreviewSessions, | |
| 31 | 34 | } from "~/previewStateStore"; |
| 32 | 35 | import { |
| 33 | 36 | browserMiniPlayerSource, |
| ... | ... | @@ -250,7 +253,7 @@ const currentStatus = async ( |
| 250 | 253 | } |
| 251 | 254 | const navStatus = snapshot?.navStatus; |
| 252 | 255 | return { |
| 253 | available: Boolean(previewBridge?.automation), | |
| 256 | available: false, | |
| 254 | 257 | visible, |
| 255 | 258 | tabId, |
| 256 | 259 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, |
| ... | ... | @@ -292,6 +295,30 @@ export function PreviewAutomationHosts() { |
| 292 | 295 | |
| 293 | 296 | function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { |
| 294 | 297 | const { environmentId } = props; |
| 298 | const previewSessions = useActivePreviewSessions(); | |
| 299 | const visibleRuntimeTabIds = useBrowserSurfaceStore( | |
| 300 | useShallow((state) => | |
| 301 | Object.keys(state.byTabId).filter((tabId) => state.byTabId[tabId]?.visible), | |
| 302 | ), | |
| 303 | ); | |
| 304 | const liveTabs = useMemo( | |
| 305 | () => | |
| 306 | Object.entries(previewSessions).flatMap(([key, state]) => { | |
| 307 | const ref = parseScopedThreadKey(key); | |
| 308 | if (ref?.environmentId !== environmentId) return []; | |
| 309 | return Object.values(state.sessions) | |
| 310 | .filter((tab) => state.desktopByTabId[tab.tabId]?.hasWebContents) | |
| 311 | .map((tab) => ({ | |
| 312 | threadId: ref.threadId, | |
| 313 | tabId: tab.tabId, | |
| 314 | visible: visibleRuntimeTabIds.includes( | |
| 315 | previewRuntimeTabId(ref, state.serverEpoch, tab.tabId), | |
| 316 | ), | |
| 317 | })); | |
| 318 | }), | |
| 319 | [environmentId, previewSessions, visibleRuntimeTabIds], | |
| 320 | ); | |
| 321 | const lastFocusReportRef = useRef<string | null>(null); | |
| 295 | 322 | const registry = useContext(RegistryContext); |
| 296 | 323 | const [automationClientId] = useState(createPreviewAutomationClientId); |
| 297 | 324 | const initialAutomationHost = useMemo<PreviewAutomationHostState>( |
| ... | ... | @@ -804,24 +831,35 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 804 | 831 | useEffect(() => { |
| 805 | 832 | const report = () => { |
| 806 | 833 | if (!automationConnectionId) return; |
| 807 | void focusAutomationHost({ | |
| 834 | const input = { | |
| 835 | clientId: automationClientId, | |
| 808 | 836 | environmentId, |
| 809 | input: { | |
| 810 | clientId: automationClientId, | |
| 811 | environmentId, | |
| 812 | connectionId: automationConnectionId, | |
| 813 | focused: document.hasFocus(), | |
| 814 | }, | |
| 837 | connectionId: automationConnectionId, | |
| 838 | focused: document.hasFocus() && document.visibilityState === "visible", | |
| 839 | liveTabs: liveTabs.map((tab) => ({ | |
| 840 | ...tab, | |
| 841 | visible: tab.visible && document.visibilityState === "visible", | |
| 842 | })), | |
| 843 | }; | |
| 844 | const reportKey = JSON.stringify(input); | |
| 845 | if (lastFocusReportRef.current === reportKey) return; | |
| 846 | lastFocusReportRef.current = reportKey; | |
| 847 | void focusAutomationHost({ environmentId, input }).then((result) => { | |
| 848 | if (result._tag === "Failure" && lastFocusReportRef.current === reportKey) { | |
| 849 | lastFocusReportRef.current = null; | |
| 850 | } | |
| 815 | 851 | }); |
| 816 | 852 | }; |
| 817 | 853 | report(); |
| 818 | 854 | window.addEventListener("focus", report); |
| 819 | 855 | window.addEventListener("blur", report); |
| 856 | document.addEventListener("visibilitychange", report); | |
| 820 | 857 | return () => { |
| 821 | 858 | window.removeEventListener("focus", report); |
| 822 | 859 | window.removeEventListener("blur", report); |
| 860 | document.removeEventListener("visibilitychange", report); | |
| 823 | 861 | }; |
| 824 | }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); | |
| 862 | }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost, liveTabs]); | |
| 825 | 863 | |
| 826 | 864 | return null; |
| 827 | 865 | } |
packages/contracts/src/previewAutomation.ts+9| ... | ... | @@ -590,6 +590,15 @@ export const PreviewAutomationHostFocus = Schema.Struct({ |
| 590 | 590 | ...PreviewAutomationHostIdentity.fields, |
| 591 | 591 | connectionId: PreviewAutomationConnectionId, |
| 592 | 592 | focused: Schema.Boolean, |
| 593 | liveTabs: Schema.optional( | |
| 594 | Schema.Array( | |
| 595 | Schema.Struct({ | |
| 596 | threadId: ThreadId, | |
| 597 | tabId: PreviewTabId, | |
| 598 | visible: Schema.optional(Schema.Boolean), | |
| 599 | }), | |
| 600 | ), | |
| 601 | ), | |
| 593 | 602 | }); |
| 594 | 603 | export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; |
| 595 | 604 |