authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-24 04:29:40+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-23 18:29:40-07:00
log894d33419dceb366eecda8e5e27c6fc043c3959e
treec73aa93cc0033ab05ea152d1b74a2e93af4f3eb8
parentd4cd7d5c33122473da22ae118c93477ef7e38310
signature Signed by PGP key B5690EEEBB952194

fix(preview): use the visible browser for new agent sessions (#13064)


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,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
685 environmentId: scope.environmentId,685 environmentId: scope.environmentId,
686 connectionId: "connection-stale",686 connectionId: "connection-stale",
687 focused: true,687 focused: true,
688 liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }],
688 });689 });
689 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(690 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
690 "second",691 "second",
...@@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang...@@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang
725 ),726 ),
726);727);
727728
729it.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
814it.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
728it.effect("does not route new operations to legacy hosts that did not advertise support", () =>853it.effect("does not route new operations to legacy hosts that did not advertise support", () =>
729 Effect.scoped(854 Effect.scoped(
730 Effect.gen(function* () {855 Effect.gen(function* () {
...@@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"...@@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"
921 environmentId: scope.environmentId,1046 environmentId: scope.environmentId,
922 connectionId: firstConnectionId,1047 connectionId: firstConnectionId,
923 focused: true,1048 focused: true,
1049 liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }],
924 });1050 });
925 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({1051 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
926 host: "first",1052 host: "first",
apps/server/src/mcp/PreviewAutomationBroker.ts+12-1
...@@ -73,6 +73,7 @@ interface ClientConnection {...@@ -73,6 +73,7 @@ interface ClientConnection {
73 readonly environmentId: PreviewAutomationHost["environmentId"];73 readonly environmentId: PreviewAutomationHost["environmentId"];
74 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;74 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
75 readonly focused: boolean;75 readonly focused: boolean;
76 readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>;
76 readonly focusOrder: number;77 readonly focusOrder: number;
77 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;78 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;
78}79}
...@@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
376 environmentId: host.environmentId,377 environmentId: host.environmentId,
377 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),378 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
378 focused: false,379 focused: false,
380 liveTabs: [],
379 focusOrder: 0,381 focusOrder: 0,
380 queue,382 queue,
381 };383 };
...@@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
432 clients.set(host.clientId, {434 clients.set(host.clientId, {
433 ...currentHost,435 ...currentHost,
434 focused: host.focused,436 focused: host.focused,
437 liveTabs: host.liveTabs ?? currentHost.liveTabs,
435 focusOrder: host.focused ? focusSequence : currentHost.focusOrder,438 focusOrder: host.focused ? focusSequence : currentHost.focusOrder,
436 });439 });
437 return { ...current, clients, focusSequence };440 return { ...current, clients, focusSequence };
...@@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
492 // operation is not silently moved to a newer client: the caller gets a495 // operation is not silently moved to a newer client: the caller gets a
493 // capability failure and can deliberately start a fresh provider496 // capability failure and can deliberately start a fresh provider
494 // session. A dead lease is pruned above and may fail over.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 const connection =505 const connection =
496 hasLiveAssignment && supportsOperation(assignedConnection, input.operation)506 hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
497 ? assignedConnection507 ? assignedConnection
...@@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
505 )515 )
506 .sort(516 .sort(
507 (left, right) =>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 Number(right.focused) - Number(left.focused) ||520 Number(right.focused) - Number(left.focused) ||
510 right.focusOrder - left.focusOrder,521 right.focusOrder - left.focusOrder,
511 )[0];522 )[0];
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx+177-4
...@@ -8,13 +8,22 @@ import {...@@ -8,13 +8,22 @@ import {
8 type PreviewOpenInput,8 type PreviewOpenInput,
9 type PreviewSessionSnapshot,9 type PreviewSessionSnapshot,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
12import * as Cause from "effect/Cause";
11import { AsyncResult, Atom } from "effect/unstable/reactivity";13import { AsyncResult, Atom } from "effect/unstable/reactivity";
12import { act } from "react";14import { act } from "react";
13import { create, type ReactTestRenderer } from "react-test-renderer";15import { create, type ReactTestRenderer } from "react-test-renderer";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";16import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1517
18import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
19import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
16import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings";20import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings";
17import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";21import {
22 applyPreviewServerSnapshot,
23 readThreadPreviewState,
24 resetPreviewStateForTests,
25 applyPreviewDesktopState,
26} from "~/previewStateStore";
18import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry";27import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry";
1928
20import { PreviewAutomationHosts } from "./PreviewAutomationHosts";29import { PreviewAutomationHosts } from "./PreviewAutomationHosts";
...@@ -31,7 +40,7 @@ const mocks = vi.hoisted(() => ({...@@ -31,7 +40,7 @@ const mocks = vi.hoisted(() => ({
31 vi.fn<40 vi.fn<
32 (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void>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}));
3645
37vi.mock("~/localApi", () => ({46vi.mock("~/localApi", () => ({
...@@ -110,12 +119,21 @@ beforeEach(async () => {...@@ -110,12 +119,21 @@ beforeEach(async () => {
110 vi.clearAllMocks();119 vi.clearAllMocks();
111 mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings);120 mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings);
112 mocks.respond.mockReset();121 mocks.respond.mockReset();
122 mocks.focus.mockReset().mockResolvedValue(AsyncResult.success(undefined));
113 __resetClientSettingsPersistenceForTests();123 __resetClientSettingsPersistenceForTests();
114 resetPreviewStateForTests();124 resetPreviewStateForTests();
125 useBrowserSurfaceStore.setState({ byTabId: {} });
115 appAtomRegistry.set(requestsAtom, AsyncResult.initial(false));126 appAtomRegistry.set(requestsAtom, AsyncResult.initial(false));
116 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);127 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
117 vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });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 await act(() => {137 await act(() => {
120 renderer = create(138 renderer = create(
121 <AppAtomRegistryProvider>139 <AppAtomRegistryProvider>
...@@ -161,7 +179,11 @@ describe("PreviewAutomationHosts open", () => {...@@ -161,7 +179,11 @@ describe("PreviewAutomationHosts open", () => {
161 input: { threadId, viewport, profileId: "work" },179 input: { threadId, viewport, profileId: "work" },
162 });180 });
163 expect(mocks.getClientSettings).toHaveBeenCalledOnce();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 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);187 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
166 expect(mocks.setClientSettings).not.toHaveBeenCalled();188 expect(mocks.setClientSettings).not.toHaveBeenCalled();
167 });189 });
...@@ -188,3 +210,154 @@ describe("PreviewAutomationHosts open", () => {...@@ -188,3 +210,154 @@ describe("PreviewAutomationHosts open", () => {
188 expect(mocks.setClientSettings).not.toHaveBeenCalled();210 expect(mocks.setClientSettings).not.toHaveBeenCalled();
189 });211 });
190});212});
213
214describe("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,6 +1,7 @@
1"use client";1"use client";
22
3import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";3import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
4import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {6import {
6 FILL_PREVIEW_VIEWPORT,7 FILL_PREVIEW_VIEWPORT,
...@@ -22,12 +23,14 @@ import {...@@ -22,12 +23,14 @@ import {
22import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";23import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
23import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";24import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
24import { Atom } from "effect/unstable/reactivity";25import { Atom } from "effect/unstable/reactivity";
26import { useShallow } from "zustand/react/shallow";
2527
26import {28import {
27 applyPreviewServerSnapshot,29 applyPreviewServerSnapshot,
28 readThreadPreviewState,30 readThreadPreviewState,
29 reconcilePreviewServerSessions,31 reconcilePreviewServerSessions,
30 updatePreviewServerSnapshot,32 updatePreviewServerSnapshot,
33 useActivePreviewSessions,
31} from "~/previewStateStore";34} from "~/previewStateStore";
32import {35import {
33 browserMiniPlayerSource,36 browserMiniPlayerSource,
...@@ -250,7 +253,7 @@ const currentStatus = async (...@@ -250,7 +253,7 @@ const currentStatus = async (
250 }253 }
251 const navStatus = snapshot?.navStatus;254 const navStatus = snapshot?.navStatus;
252 return {255 return {
253 available: Boolean(previewBridge?.automation),256 available: false,
254 visible,257 visible,
255 tabId,258 tabId,
256 url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,259 url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
...@@ -292,6 +295,30 @@ export function PreviewAutomationHosts() {...@@ -292,6 +295,30 @@ export function PreviewAutomationHosts() {
292295
293function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) {296function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) {
294 const { environmentId } = props;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 const registry = useContext(RegistryContext);322 const registry = useContext(RegistryContext);
296 const [automationClientId] = useState(createPreviewAutomationClientId);323 const [automationClientId] = useState(createPreviewAutomationClientId);
297 const initialAutomationHost = useMemo<PreviewAutomationHostState>(324 const initialAutomationHost = useMemo<PreviewAutomationHostState>(
...@@ -804,24 +831,35 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -804,24 +831,35 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
804 useEffect(() => {831 useEffect(() => {
805 const report = () => {832 const report = () => {
806 if (!automationConnectionId) return;833 if (!automationConnectionId) return;
807 void focusAutomationHost({834 const input = {
835 clientId: automationClientId,
808 environmentId,836 environmentId,
809 input: {837 connectionId: automationConnectionId,
810 clientId: automationClientId,838 focused: document.hasFocus() && document.visibilityState === "visible",
811 environmentId,839 liveTabs: liveTabs.map((tab) => ({
812 connectionId: automationConnectionId,840 ...tab,
813 focused: document.hasFocus(),841 visible: tab.visible && document.visibilityState === "visible",
814 },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 report();853 report();
818 window.addEventListener("focus", report);854 window.addEventListener("focus", report);
819 window.addEventListener("blur", report);855 window.addEventListener("blur", report);
856 document.addEventListener("visibilitychange", report);
820 return () => {857 return () => {
821 window.removeEventListener("focus", report);858 window.removeEventListener("focus", report);
822 window.removeEventListener("blur", report);859 window.removeEventListener("blur", report);
860 document.removeEventListener("visibilitychange", report);
823 };861 };
824 }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]);862 }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost, liveTabs]);
825863
826 return null;864 return null;
827}865}
packages/contracts/src/previewAutomation.ts+9
...@@ -590,6 +590,15 @@ export const PreviewAutomationHostFocus = Schema.Struct({...@@ -590,6 +590,15 @@ export const PreviewAutomationHostFocus = Schema.Struct({
590 ...PreviewAutomationHostIdentity.fields,590 ...PreviewAutomationHostIdentity.fields,
591 connectionId: PreviewAutomationConnectionId,591 connectionId: PreviewAutomationConnectionId,
592 focused: Schema.Boolean,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});
594export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;603export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;
595604