diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 5d1e02658d587..8df5bdbcb3d1c 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -5,8 +5,10 @@ import { ThreadId, type ClientSettings, type DesktopPreviewBridge, + type PreviewEvent, type PreviewSessionSnapshot, } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -20,6 +22,31 @@ const mocks = vi.hoisted(() => ({ getPreviewConfig: vi.fn(), })); +// Every preview event of the primary environment, for any thread. +const primary = vi.hoisted(() => ({ + environmentId: "host-primary", + events: null as unknown as Atom.Writable>, +})); + +vi.mock(import("~/state/primaryEnvironment"), async () => { + const { Atom } = await import("effect/reactivity"); + return { primaryEnvironmentIdAtom: Atom.make(primary.environmentId as EnvironmentId) }; +}); + +vi.mock(import("~/state/preview"), async (original) => { + const actual = await original(); + const { AsyncResult, Atom } = await import("effect/reactivity"); + primary.events = Atom.make>(AsyncResult.initial()); + return { + ...actual, + previewEnvironment: { + ...actual.previewEnvironment, + events: () => primary.events, + list: () => Atom.make(AsyncResult.initial()), + } as never, + }; +}); + vi.mock(import("~/env"), async (original) => ({ ...(await original()), isElectron: true })); vi.mock(import("~/state/session"), async (original) => ({ @@ -56,7 +83,7 @@ import { ensureClientSettingsHydrated, } from "~/hooks/useSettings"; import { reconcilePreviewServerSessions, resetPreviewStateForTests } from "~/previewStateStore"; -import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; +import { AppAtomRegistryProvider, appAtomRegistry } from "~/rpc/atomRegistry"; import { ElectronBrowserHost } from "./ElectronBrowserHost"; const threadRef = scopeThreadRef( @@ -160,4 +187,44 @@ describe("ElectronBrowserHost", () => { expect(after).toHaveLength(2); expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); + + it("hosts a server tab an agent opens in a thread the user is not viewing", async () => { + root = createRoot(container); + await act(async () => { + root!.render( + + + , + ); + await ensureClientSettingsHydrated(); + }); + + const backgroundThreadId = ThreadId.make("thread-agent-background"); + await act(async () => { + appAtomRegistry.set( + primary.events, + AsyncResult.success({ + type: "opened", + threadId: backgroundThreadId, + tabId: "tab_1", + createdAt: "2026-01-01T00:00:00.000Z", + serverEpoch: "server-a", + revision: 1, + snapshot: { + threadId: backgroundThreadId, + tabId: "tab_1", + runtime: "server", + automationOwner: "agent-session", + navStatus: { _tag: "Loading", url: "https://example.com/", title: "Example" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + } as PreviewEvent), + ); + }); + + const webviews = [...container.querySelectorAll("[data-electron-browser-host] webview")]; + expect(webviews.map((node) => node.getAttribute("data-preview-server-tab"))).toEqual(["tab_1"]); + }); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 02e45bb950bef..8a94063e575ef 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,19 +1,28 @@ "use client"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, type DesktopPreviewOpenLinkEvent, + type EnvironmentId, FILL_PREVIEW_VIEWPORT, + type ScopedThreadRef, + ThreadId, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; import { type ComponentProps, useEffect, useEffectEvent, useState } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { isElectron } from "~/env"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; -import { useActivePreviewSessions } from "~/previewStateStore"; +import { usePreviewSession } from "~/components/preview/usePreviewSession"; +import { applyPreviewServerEvent, useActivePreviewSessions } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -26,9 +35,40 @@ import { openUrlInPreview } from "./openFileInPreview"; import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; +/** + * The server binds a tab to this desktop's browser profile only if the desktop attaches it within + * seconds of opening; otherwise the tab runs in the server's own browser, without the user's + * sign-ins. Agents open tabs in threads the user is not viewing, so every server tab opened in the + * primary environment is seeded here instead of waiting for its thread's view to subscribe. + */ +const serverTabOpeningsAtom = Atom.family((environmentId: EnvironmentId) => { + const events = previewEnvironment.events({ environmentId, input: {} }); + return Atom.make((get) => { + get.subscribe(events, (result) => { + if (!AsyncResult.isSuccess(result)) return; + const event = result.value; + if (event.type !== "opened" || event.snapshot.runtime !== "server") return; + applyPreviewServerEvent(scopeThreadRef(environmentId, ThreadId.make(event.threadId)), event); + }); + get.mount(events); + }); +}); + +const noServerTabOpeningsAtom = Atom.make(() => undefined); + export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + useAtomValue( + isElectron && primaryEnvironmentId !== null + ? serverTabOpeningsAtom(primaryEnvironmentId) + : noServerTabOpeningsAtom, + ); + // A seeded tab's thread stays synced so its navigation and closing reach the webview below. + const syncedThreadRefs = Object.keys(previewByThreadKey).flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef?.environmentId === primaryEnvironmentId ? [threadRef] : []; + }); const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef @@ -116,6 +156,9 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
+ {syncedThreadRefs.map((threadRef) => ( + + ))} {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( @@ -145,6 +188,11 @@ export function ElectronBrowserHost() { ); } +function PreviewSessionSync({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + usePreviewSession(threadRef); + return null; +} + function AuthorizedBrowserWebview(props: ComponentProps) { const canOperatePreview = useEnvironmentScope( props.threadRef.environmentId,