diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index 7a0a12ebdc227..c6e7492e5cc82 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -3,9 +3,21 @@ import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3to import { describe, expect, it, vi } from "vite-plus/test"; import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; -import { __testing as previewStateTesting } from "../../previewStateStore"; +import { + __testing as previewStateTesting, + applyPreviewServerEvent, + applyPreviewServerSnapshot, + beginPreviewSessionClose, + cancelPreviewSessionClose, + reconcilePreviewServerSessions, + resetPreviewStateForTests, +} from "../../previewStateStore"; import { useRightPanelStore } from "../../rightPanelStore"; -import { followPreviewSessions, revealServerTabs } from "./agentOpenedPreviews"; +import { + followPreviewSessions, + revealServerTabs, + startAgentOpenedPreviews, +} from "./agentOpenedPreviews"; vi.mock("../../state/entities", () => ({ readEnvironmentSupportsServerBrowser: () => true, @@ -15,6 +27,7 @@ const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1")); const threadKey = scopedThreadKey(ref); function setup() { + resetPreviewStateForTests(); usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); useRightPanelStore.setState({ byThreadKey: {} }); } @@ -106,3 +119,126 @@ describe("followPreviewSessions", () => { expect(miniPlayerSource()).toBeUndefined(); }); }); + +describe("startAgentOpenedPreviews", () => { + const serverEpoch = "server-a"; + const serverTab = (overrides: Partial = {}): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab_a", + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + runtime: "server", + reveal: true, + ...overrides, + }); + const following = () => { + setup(); + return { [Symbol.dispose]: startAgentOpenedPreviews() }; + }; + const navigated = (revision: number, snapshot: PreviewSessionSnapshot) => + applyPreviewServerEvent(ref, { + type: "navigated", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + serverEpoch, + revision, + }); + const floatingTabId = () => { + const source = miniPlayerSource(); + return source?.kind === "browser" ? source.tabId : null; + }; + const browserTabs = (threadKeyToRead = threadKey) => + useRightPanelStore + .getState() + .byThreadKey[threadKeyToRead]?.surfaces.filter((surface) => surface.kind === "preview") + .map((surface) => surface.id); + + it("treats the first list as a baseline and floats tabs opened later", () => { + using _following = following(); + reconcilePreviewServerSessions(ref, { + sessions: [serverTab({ tabId: "tab_restored" })], + serverEpoch, + revision: 1, + }); + expect(floatingTabId()).toBeNull(); + expect(browserTabs()).toEqual(["browser:tab_restored"]); + + const opened = serverTab({ tabId: "tab_agent" }); + applyPreviewServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: opened.tabId, + createdAt: opened.updatedAt, + snapshot: opened, + serverEpoch, + revision: 2, + }); + expect(floatingTabId()).toBe("tab_agent"); + }); + + it("reveals an existing tab only for a new reveal request", () => { + using _following = following(); + const tab = serverTab({ revealRequest: { id: "r1", force: false } }); + reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 }); + + navigated(2, { ...tab, updatedAt: "2026-01-01T00:00:01.000Z" }); + expect(floatingTabId()).toBeNull(); + + navigated(3, { + ...tab, + revealRequest: { id: "r2", force: true }, + updatedAt: "2026-01-01T00:00:02.000Z", + }); + expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe( + "browser:tab_a", + ); + }); + + it("never reveals tabs this client opened, desktop tabs, or a tab whose close failed", () => { + using _following = following(); + const tab = serverTab({ tabId: "tab_closing" }); + reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 }); + applyPreviewServerSnapshot(ref, serverTab({ tabId: "tab_own", reveal: undefined })); + applyPreviewServerSnapshot(ref, { ...serverTab({ tabId: "tab_desktop" }), runtime: "desktop" }); + beginPreviewSessionClose(ref, tab.tabId); + cancelPreviewSessionClose(ref, tab, tab.tabId); + expect(floatingTabId()).toBeNull(); + }); + + it("follows background threads and closes the floating browser when its session ends", () => { + using _following = following(); + const other = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-2")); + reconcilePreviewServerSessions(other, { + sessions: [serverTab({ threadId: "thread-2", tabId: "tab_b" })], + serverEpoch, + revision: 1, + }); + expect(browserTabs(scopedThreadKey(other))).toEqual(["browser:tab_b"]); + + reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 }); + const opened = serverTab(); + applyPreviewServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: opened.tabId, + createdAt: opened.updatedAt, + snapshot: opened, + serverEpoch, + revision: 2, + }); + expect(floatingTabId()).toBe("tab_a"); + applyPreviewServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: opened.tabId, + createdAt: opened.updatedAt, + serverEpoch, + revision: 3, + }); + expect(floatingTabId()).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index 2940cf042d60e..dbc171eaf531d 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -4,11 +4,12 @@ import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts import { getClientSettings } from "../../hooks/useSettings"; import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; import { - onPreviewSessionsChanged, - onServerTabsRevealed, + previewStateAtom, + previewThreadRefsAtom, type ThreadPreviewState, } from "../../previewStateStore"; import { useRightPanelStore } from "../../rightPanelStore"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { readEnvironmentSupportsServerBrowser } from "../../state/entities"; /** @@ -59,16 +60,58 @@ export function followPreviewSessions(threadRef: ScopedThreadRef, state: ThreadP if (source?.kind === "browser" && !state.sessions[source.tabId]) miniPlayer.close(threadRef); } -let started = false; +/** + * Server tabs that are new, or carry a new reveal request, since `previous`. + * Nothing is fresh until the first list has loaded: that list is the baseline, + * so a reload never reopens previews the user dismissed. A tab this client was + * closing comes back without a reveal if the close fails. + */ +function freshlyRevealedServerTabs( + previous: ThreadPreviewState, + next: ThreadPreviewState, +): ReadonlyArray { + if (!previous.listLoaded || previous.sessions === next.sessions) return []; + return Object.values(next.sessions).filter((session) => { + if (session.runtime !== "server" || session.reveal !== true) return false; + if (previous.suppressedTabIds.has(session.tabId)) return false; + const before = previous.sessions[session.tabId]; + const requested = session.revealRequest; + return requested ? before?.revealRequest?.id !== requested.id : before === undefined; + }); +} + +function followThread(threadKey: string, threadRef: ScopedThreadRef): () => void { + const atom = previewStateAtom(threadKey); + let previous = appAtomRegistry.get(atom); + return appAtomRegistry.subscribe(atom, (next) => { + const before = previous; + previous = next; + // Reveals run first so a revealed tab floats before it gains a panel tab. + const revealed = freshlyRevealedServerTabs(before, next); + if (revealed.length > 0) { + revealServerTabs(threadRef, revealed, { + autoShowFloatingPreview: getClientSettings().browserAutoShowFloatingPreview, + }); + } + if (before.sessions !== next.sessions || before.listLoaded !== next.listLoaded) { + followPreviewSessions(threadRef, next); + } + }); +} /** Follows every thread's browser previews for the life of the page, not just the open thread's. */ -export function startAgentOpenedPreviews(): void { - if (started) return; - started = true; - onPreviewSessionsChanged(followPreviewSessions); - onServerTabsRevealed((threadRef, sessions) => - revealServerTabs(threadRef, sessions, { - autoShowFloatingPreview: getClientSettings().browserAutoShowFloatingPreview, - }), - ); +export function startAgentOpenedPreviews(): () => void { + const followed = new Map void>(); + const followNewThreads = (refs: ReadonlyMap) => { + for (const [threadKey, threadRef] of refs) { + if (!followed.has(threadKey)) followed.set(threadKey, followThread(threadKey, threadRef)); + } + }; + const stopIndex = appAtomRegistry.subscribe(previewThreadRefsAtom, followNewThreads, { + immediate: true, + }); + return () => { + stopIndex(); + for (const stop of followed.values()) stop(); + }; } diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 986fe2615a1fa..d7775bbd6117b 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -19,7 +19,6 @@ import { reconcilePreviewServerSessions, resetPreviewStateForTests, setActivePreviewTab, - onServerTabsRevealed, } from "./previewStateStore"; import { appAtomRegistry } from "./rpc/atomRegistry"; @@ -419,76 +418,3 @@ describe("previewStateStore (single-tab)", () => { }); }); }); - -describe("previewStateStore server reveals", () => { - const collectReveals = () => { - const tabIds: string[] = []; - const stop = onServerTabsRevealed((_ref, sessions) => { - tabIds.push(...sessions.map((session) => session.tabId)); - }); - return { tabIds, [Symbol.dispose]: stop }; - }; - const serverTab = (overrides: Partial = {}) => - makeSnapshot({ runtime: "server", reveal: true, ...overrides }); - - it("treats the first list as a baseline and reveals tabs opened later", () => { - using reveals = collectReveals(); - const revealed = reveals.tabIds; - reconcilePreviewServerSessions(ref, { - sessions: [serverTab({ tabId: "tab_restored" })], - serverEpoch, - revision: 1, - }); - expect(revealed).toEqual([]); - - const opened = serverTab({ tabId: "tab_agent" }); - applyPreviewServerEvent(ref, { - type: "opened", - threadId: "thread-1", - tabId: opened.tabId, - createdAt: opened.updatedAt, - snapshot: opened, - }); - expect(revealed).toEqual(["tab_agent"]); - }); - - it("reveals an existing tab only for a new reveal request", () => { - using reveals = collectReveals(); - const revealed = reveals.tabIds; - const tab = serverTab({ revealRequest: { id: "r1", force: false } }); - reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 }); - - const navigated = { ...tab, updatedAt: "2026-01-01T00:00:01.000Z" }; - applyPreviewServerEvent(ref, { - type: "navigated", - threadId: "thread-1", - tabId: tab.tabId, - createdAt: navigated.updatedAt, - snapshot: navigated, - }); - expect(revealed).toEqual([]); - - const requested = { - ...navigated, - revealRequest: { id: "r2", force: true }, - updatedAt: "2026-01-01T00:00:02.000Z", - }; - applyPreviewServerEvent(ref, { - type: "navigated", - threadId: "thread-1", - tabId: tab.tabId, - createdAt: requested.updatedAt, - snapshot: requested, - }); - expect(revealed).toEqual([tab.tabId]); - }); - - it("never reveals tabs this client opened or desktop tabs", () => { - using reveals = collectReveals(); - const revealed = reveals.tabIds; - reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 }); - applyPreviewServerSnapshot(ref, serverTab({ tabId: "tab_own", reveal: undefined })); - applyPreviewServerSnapshot(ref, makeSnapshot({ tabId: "tab_desktop", reveal: true })); - expect(revealed).toEqual([]); - }); -}); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index f80464e8ebd2a..ce9be08062aa4 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -85,7 +85,15 @@ const activePreviewSessionsAtom = Atom.make((get) => { return byThreadKey; }).pipe(Atom.withLabel("preview:active-sessions")); -const changedPreviewThreadKeys = new Set(); +/** + * Every thread whose preview state has been written. Panel tabs and the mini + * player follow those threads' states from a store this one cannot import + * (rightPanelStore -> closedViewStore -> lib/utils -> composerDraftStore -> + * previewStateStore is a cycle), so they subscribe through this index. + */ +export const previewThreadRefsAtom = Atom.make>( + new Map(), +).pipe(Atom.keepAlive, Atom.withLabel("preview:thread-refs")); function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): void { const active = Object.keys(state.sessions).length > 0; @@ -98,111 +106,22 @@ function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): }); } -function writeThreadPreviewState( +function updateThreadPreviewState( ref: ScopedThreadRef, update: (current: ThreadPreviewState) => ThreadPreviewState, -): { readonly previous: ThreadPreviewState; readonly next: ThreadPreviewState } | null { +): void { const threadKey = scopedThreadKey(ref); + // Indexed before the write so a new thread's subscribers see this change. + appAtomRegistry.update(previewThreadRefsAtom, (refs) => + refs.has(threadKey) ? refs : new Map(refs).set(threadKey, ref), + ); const atom = previewStateAtom(threadKey); - let previousState = appAtomRegistry.get(atom); - let nextState = previousState; + let nextState = appAtomRegistry.get(atom); const changed = appAtomRegistry.modify(atom, (current) => { - previousState = current; nextState = update(current); return [nextState !== current, nextState]; }); - if (!changed) return null; - changedPreviewThreadKeys.add(threadKey); - syncActivePreviewThread(threadKey, nextState); - return { previous: previousState, next: nextState }; -} - -/** - * Server tabs that are new, or carry a new reveal request, since `previous`. - * Nothing is fresh until the first list has loaded: that list is the baseline, - * so a reload never reopens previews the user dismissed. - */ -function freshlyRevealedServerTabs( - previous: ThreadPreviewState, - next: ThreadPreviewState, -): ReadonlyArray { - if (!previous.listLoaded) return []; - return Object.values(next.sessions).filter((session) => { - if (session.runtime !== "server" || session.reveal !== true) return false; - const before = previous.sessions[session.tabId]; - const requested = session.revealRequest; - return requested ? before?.revealRequest?.id !== requested.id : before === undefined; - }); -} - -type PreviewSessionsChangedListener = (ref: ScopedThreadRef, next: ThreadPreviewState) => void; - -const previewSessionsChangedListeners = new Set(); - -/** - * Panel tabs and the mini player follow the sessions held here. They live in - * stores this one cannot import (rightPanelStore -> closedViewStore -> - * lib/utils -> composerDraftStore -> previewStateStore is a cycle). - */ -export function onPreviewSessionsChanged(listener: PreviewSessionsChangedListener): () => void { - previewSessionsChangedListeners.add(listener); - return () => { - previewSessionsChangedListeners.delete(listener); - }; -} - -function notifyPreviewSessionsChanged( - ref: ScopedThreadRef, - result: { readonly previous: ThreadPreviewState; readonly next: ThreadPreviewState }, -): void { - if ( - result.previous.sessions === result.next.sessions && - result.previous.listLoaded === result.next.listLoaded - ) { - return; - } - for (const listener of previewSessionsChangedListeners) listener(ref, result.next); -} - -function updateThreadPreviewState( - ref: ScopedThreadRef, - update: (current: ThreadPreviewState) => ThreadPreviewState, -): void { - const result = writeThreadPreviewState(ref, update); - if (result) notifyPreviewSessionsChanged(ref, result); -} - -type ServerTabsRevealedListener = ( - ref: ScopedThreadRef, - sessions: ReadonlyArray, -) => void; - -const serverTabsRevealedListeners = new Set(); - -/** - * Where a revealed tab shows depends on client settings, which this store - * cannot import: lib/utils -> composerDraftStore -> previewStateStore -> - * hooks/useSettings -> ui/toast -> lib/utils is a cycle. - */ -export function onServerTabsRevealed(listener: ServerTabsRevealedListener): () => void { - serverTabsRevealedListeners.add(listener); - return () => { - serverTabsRevealedListeners.delete(listener); - }; -} - -function updateThreadPreviewStateFromServer( - ref: ScopedThreadRef, - update: (current: ThreadPreviewState) => ThreadPreviewState, -): void { - const result = writeThreadPreviewState(ref, update); - if (!result) return; - // Reveals run first so a revealed tab floats before it gains a panel tab. - const revealed = freshlyRevealedServerTabs(result.previous, result.next); - if (revealed.length > 0) { - for (const listener of serverTabsRevealedListeners) listener(ref, revealed); - } - notifyPreviewSessionsChanged(ref, result); + if (changed) syncActivePreviewThread(threadKey, nextState); } const dedupeRecentUrls = (existing: string[], url: string): string[] => { @@ -257,7 +176,7 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState } export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { - updateThreadPreviewStateFromServer(ref, (current) => { + updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; if (event.revision < current.serverRevision) return current; const next = (() => { @@ -328,7 +247,7 @@ export function applyPreviewServerSnapshot( ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null, ): void { - updateThreadPreviewStateFromServer(ref, (current) => { + updateThreadPreviewState(ref, (current) => { if (!snapshot && current.snapshot === null) return current; if (!snapshot) { return { @@ -360,7 +279,7 @@ export function updatePreviewServerSnapshot( ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot, ): void { - updateThreadPreviewStateFromServer(ref, (current) => { + updateThreadPreviewState(ref, (current) => { if (current.suppressedTabIds.has(snapshot.tabId)) return current; const existing = current.sessions[snapshot.tabId]; if (existing && existing.updatedAt > snapshot.updatedAt) return current; @@ -383,7 +302,7 @@ export function reconcilePreviewServerSessions( ref: ScopedThreadRef, result: PreviewListResult, ): void { - updateThreadPreviewStateFromServer(ref, (current) => { + updateThreadPreviewState(ref, (current) => { const sameServer = current.serverEpoch === result.serverEpoch; if (sameServer && result.revision < current.serverRevision) { return current; @@ -594,10 +513,10 @@ export function clearThreadPreviewState(ref: ScopedThreadRef): void { } export function resetPreviewStateForTests(): void { - for (const threadKey of changedPreviewThreadKeys) { + for (const threadKey of appAtomRegistry.get(previewThreadRefsAtom).keys()) { appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); } - changedPreviewThreadKeys.clear(); + appAtomRegistry.set(previewThreadRefsAtom, new Map()); appAtomRegistry.set(activePreviewThreadKeysAtom, { keys: new Set() }); }