diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index ef552f9b04..bdad30d3c3 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang environmentId: scope.environmentId, connectionId: "connection-stale", focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", @@ -725,6 +726,190 @@ it.effect("pins a provider session to its initial host despite later focus chang ), ); +it.effect("prefers the live tab owner for new sessions without moving existing leases", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connections = new Map(); + for (const clientId of ["owner", "other"]) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId })), + (connectionId) => connections.set(clientId, connectionId), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + ], + }); + yield* broker.focusHost({ + clientId: "other", + environmentId: scope.environmentId, + connectionId: connections.get("other")!, + focused: true, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, + { threadId: scope.threadId, tabId: PreviewTabId.make("other-signed-in"), visible: false }, + { + threadId: ThreadId.make("another-thread"), + tabId: PreviewTabId.make("different-tab"), + visible: true, + }, + ], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + const wrongHostError = yield* broker + .invoke({ + scope, + tabId: PreviewTabId.make("other-signed-in"), + operation: "evaluate", + input: {}, + }) + .pipe(Effect.flip); + expect(wrongHostError).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "explicit-owner" }, + tabId: PreviewTabId.make("signed-in"), + operation: "snapshot", + input: {}, + }), + ).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "other-tab" }, + tabId: PreviewTabId.make("different-tab"), + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "after-tab-closed" }, + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + }), + ), +); + +it.effect("retains capability-rich host preference when no host owns the target tab", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let focusedConnectionId = ""; + for (const [clientId, supportedOperations] of [ + ["focused", ["status"]], + ["background", ["status", "resize"]], + ] as const) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId, supportedOperations })), + (connectionId) => { + if (clientId === "focused") focusedConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "focused", + environmentId: scope.environmentId, + connectionId: focusedConnectionId, + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "background", + ); + }), + ), +); + +it.effect("fails closed when a named tab's owner cannot perform the requested operation", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let ownerConnectionId = ""; + for (const [clientId, supportedOperations] of [ + ["owner", ["status"]], + ["other", ["status", "snapshot"]], + ] as const) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId, supportedOperations })), + (connectionId) => { + if (clientId === "owner") ownerConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + const tabId = PreviewTabId.make("named-tab"); + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: ownerConnectionId, + focused: false, + liveTabs: [{ threadId: scope.threadId, tabId, visible: true }], + }); + const error = yield* broker + .invoke({ + scope: { ...scope, providerSessionId: "fresh-explicit-tab" }, + tabId, + operation: "snapshot", + input: {}, + }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + }), + ), +); + it.effect("does not route new operations to legacy hosts that did not advertise support", () => Effect.scoped( Effect.gen(function* () { @@ -921,6 +1106,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" environmentId: scope.environmentId, connectionId: firstConnectionId, focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], }); expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ host: "first", diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 527f71f591..73680eb79e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -73,6 +73,7 @@ interface ClientConnection { readonly environmentId: PreviewAutomationHost["environmentId"]; readonly supportedOperations: ReadonlySet; readonly focused: boolean; + readonly liveTabs: NonNullable; readonly focusOrder: number; readonly queue: Queue.Queue; } @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { environmentId: host.environmentId, supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), focused: false, + liveTabs: [], focusOrder: 0, queue, }; @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { clients.set(host.clientId, { ...currentHost, focused: host.focused, + liveTabs: host.liveTabs ?? currentHost.liveTabs, focusOrder: host.focused ? focusSequence : currentHost.focusOrder, }); return { ...current, clients, focusSequence }; @@ -492,19 +495,38 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // operation is not silently moved to a newer client: the caller gets a // capability failure and can deliberately start a fresh provider // session. A dead lease is pruned above and may fail over. + const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => + host.liveTabs.some( + (tab) => + tab.threadId === input.scope.threadId && + (!visibleOnly || tab.visible === true) && + (input.tabId === undefined || tab.tabId === input.tabId), + ); + const environmentHosts = Array.from(current.clients.values()).filter( + (host) => host.environmentId === input.scope.environmentId, + ); + const explicitTabOwners = + input.tabId === undefined ? [] : environmentHosts.filter((host) => ownsTargetTab(host)); + const assignedOwnsNamedTab = + explicitTabOwners.length === 0 || + explicitTabOwners.some((host) => host.clientId === assignedConnection?.clientId); + // A named tab with a reporting owner must stay on an owning host. If + // none of those owners supports the operation, fail instead of sending + // the request to a different browser with unrelated cookies and DOM. + const candidateHosts = explicitTabOwners.length > 0 ? explicitTabOwners : environmentHosts; const connection = - hasLiveAssignment && supportsOperation(assignedConnection, input.operation) + hasLiveAssignment && + assignedOwnsNamedTab && + supportsOperation(assignedConnection, input.operation) ? assignedConnection : hasLiveAssignment ? undefined - : Array.from(current.clients.values()) - .filter( - (host) => - host.environmentId === input.scope.environmentId && - supportsOperation(host, input.operation), - ) + : candidateHosts + .filter((host) => supportsOperation(host, input.operation)) .sort( (left, right) => + Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || + Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || right.supportedOperations.size - left.supportedOperations.size || Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx index 75ed20cc4f..542b84907b 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -8,13 +8,22 @@ import { type PreviewOpenInput, type PreviewSessionSnapshot, } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { act } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; -import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, + applyPreviewDesktopState, +} from "~/previewStateStore"; import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; @@ -31,7 +40,7 @@ const mocks = vi.hoisted(() => ({ vi.fn< (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise >(), - focus: vi.fn(async () => undefined), + focus: vi.fn<() => Promise>>(), })); vi.mock("~/localApi", () => ({ @@ -110,12 +119,21 @@ beforeEach(async () => { vi.clearAllMocks(); mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); mocks.respond.mockReset(); + mocks.focus.mockReset().mockResolvedValue(AsyncResult.success(undefined)); __resetClientSettingsPersistenceForTests(); resetPreviewStateForTests(); + useBrowserSurfaceStore.setState({ byTabId: {} }); appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); - vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + vi.stubGlobal( + "document", + Object.assign(new EventTarget(), { + hasFocus: () => false, + visibilityState: "visible", + querySelectorAll: () => [], + }), + ); await act(() => { renderer = create( @@ -161,7 +179,11 @@ describe("PreviewAutomationHosts open", () => { input: { threadId, viewport, profileId: "work" }, }); expect(mocks.getClientSettings).toHaveBeenCalledOnce(); - await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: true, + result: { available: false, tabId: snapshot.tabId }, + }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(mocks.setClientSettings).not.toHaveBeenCalled(); }); @@ -188,3 +210,154 @@ describe("PreviewAutomationHosts open", () => { expect(mocks.setClientSettings).not.toHaveBeenCalled(); }); }); + +describe("PreviewAutomationHosts ownership", () => { + it("reports only local live tabs and removes ownership when their web contents close", async () => { + await act(() => { + appAtomRegistry.set( + requestsAtom, + AsyncResult.success({ type: "connected", connectionId: "automation-connection" }), + ); + applyPreviewServerSnapshot(threadRef, snapshot); + }); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }), + ); + const overlay = { + hasWebContents: true, + canGoBack: false, + canGoForward: false, + loading: false, + zoomFactor: 1, + pictureInPicture: false, + colorScheme: "system" as const, + audioMuted: false, + audible: false, + controller: "none" as const, + favicon: null, + }; + await act(() => applyPreviewDesktopState(threadRef, snapshot.tabId, overlay)); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + liveTabs: [{ threadId, tabId: snapshot.tabId, visible: false }], + }), + }), + ); + const reportCount = mocks.focus.mock.calls.length; + await act(() => + applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, loading: true }), + ); + expect(mocks.focus).toHaveBeenCalledTimes(reportCount); + const runtimeTabId = previewRuntimeTabId(threadRef, null, snapshot.tabId); + const owner = Symbol(); + await act(() => { + useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); + useBrowserSurfaceStore + .getState() + .present(runtimeTabId, owner, { x: 0, y: 0, width: 800, height: 600 }, true, 0, 1); + }); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }], + }), + }), + ); + for (const visibilityState of ["hidden", "visible"]) { + await act(() => { + Object.assign(document, { visibilityState }); + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + focused: false, + liveTabs: [{ threadId, tabId: snapshot.tabId, visible: visibilityState === "visible" }], + }), + }), + ); + } + await act(() => { + appAtomRegistry.set( + requestsAtom, + AsyncResult.success({ type: "connected", connectionId: "reconnected" }), + ); + }); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + connectionId: "reconnected", + liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }], + }), + }), + ); + await act(() => + applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, hasWebContents: false }), + ); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }), + ); + }); + + it.each([false, true])( + "retries failed reports without clearing newer connection reports (reconnect: %s)", + async (reconnect) => { + const report = deferred>>(); + mocks.focus.mockReturnValueOnce(report.promise); + await act(() => { + appAtomRegistry.set( + requestsAtom, + AsyncResult.success({ type: "connected", connectionId: "first" }), + ); + }); + if (reconnect) { + await act(() => { + appAtomRegistry.set( + requestsAtom, + AsyncResult.success({ type: "connected", connectionId: "second" }), + ); + }); + } + await act(async () => { + report.resolve(AsyncResult.failure(Cause.fail(new Error("Focus report failed")))); + await report.promise; + }); + expect(mocks.focus).toHaveBeenCalledTimes(reconnect ? 2 : 1); + await act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(mocks.focus).toHaveBeenCalledTimes(2); + expect(mocks.focus).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ connectionId: reconnect ? "second" : "first" }), + }), + ); + }, + ); + + it("does not claim an available runtime from a server snapshot alone", async () => { + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + await act(async () => { + applyPreviewServerSnapshot(threadRef, snapshot); + appAtomRegistry.set( + requestsAtom, + AsyncResult.success({ + ...requestEvent, + request: { + ...requestEvent.request, + operation: "status", + tabId: snapshot.tabId, + input: {}, + }, + }), + ); + await response.promise; + }); + await expect(response.promise).resolves.toMatchObject({ + ok: true, + result: { available: false, tabId: snapshot.tabId }, + }); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 78f1ea8f87..b7d8b5c9db 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -1,6 +1,7 @@ "use client"; import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { FILL_PREVIEW_VIEWPORT, @@ -22,12 +23,14 @@ import { import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { useShallow } from "zustand/react/shallow"; import { applyPreviewServerSnapshot, readThreadPreviewState, reconcilePreviewServerSessions, updatePreviewServerSnapshot, + useActivePreviewSessions, } from "~/previewStateStore"; import { browserMiniPlayerSource, @@ -254,7 +257,7 @@ const currentStatus = async ( } const navStatus = snapshot?.navStatus; return { - available: Boolean(previewBridge?.automation), + available: false, visible, tabId, url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, @@ -296,6 +299,30 @@ export function PreviewAutomationHosts() { function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { const { environmentId } = props; + const previewSessions = useActivePreviewSessions(); + const visibleRuntimeTabIds = useBrowserSurfaceStore( + useShallow((state) => + Object.keys(state.byTabId).filter((tabId) => state.byTabId[tabId]?.visible), + ), + ); + const liveTabs = useMemo( + () => + Object.entries(previewSessions).flatMap(([key, state]) => { + const ref = parseScopedThreadKey(key); + if (ref?.environmentId !== environmentId) return []; + return Object.values(state.sessions) + .filter((tab) => state.desktopByTabId[tab.tabId]?.hasWebContents) + .map((tab) => ({ + threadId: ref.threadId, + tabId: tab.tabId, + visible: visibleRuntimeTabIds.includes( + previewRuntimeTabId(ref, state.serverEpoch, tab.tabId), + ), + })); + }), + [environmentId, previewSessions, visibleRuntimeTabIds], + ); + const lastFocusReportRef = useRef(null); const registry = useContext(RegistryContext); const [automationClientId] = useState(createPreviewAutomationClientId); const initialAutomationHost = useMemo( @@ -816,24 +843,35 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) useEffect(() => { const report = () => { if (!automationConnectionId) return; - void focusAutomationHost({ + const input = { + clientId: automationClientId, environmentId, - input: { - clientId: automationClientId, - environmentId, - connectionId: automationConnectionId, - focused: document.hasFocus(), - }, + connectionId: automationConnectionId, + focused: document.hasFocus() && document.visibilityState === "visible", + liveTabs: liveTabs.map((tab) => ({ + ...tab, + visible: tab.visible && document.visibilityState === "visible", + })), + }; + const reportKey = JSON.stringify(input); + if (lastFocusReportRef.current === reportKey) return; + lastFocusReportRef.current = reportKey; + void focusAutomationHost({ environmentId, input }).then((result) => { + if (result._tag === "Failure" && lastFocusReportRef.current === reportKey) { + lastFocusReportRef.current = null; + } }); }; report(); window.addEventListener("focus", report); window.addEventListener("blur", report); + document.addEventListener("visibilitychange", report); return () => { window.removeEventListener("focus", report); window.removeEventListener("blur", report); + document.removeEventListener("visibilitychange", report); }; - }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); + }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost, liveTabs]); return null; } diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 1ec6d74f55..445dd6fc46 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -590,6 +590,15 @@ export const PreviewAutomationHostFocus = Schema.Struct({ ...PreviewAutomationHostIdentity.fields, connectionId: PreviewAutomationConnectionId, focused: Schema.Boolean, + liveTabs: Schema.optional( + Schema.Array( + Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + visible: Schema.optional(Schema.Boolean), + }), + ), + ), }); export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;