diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 2b8416ba4aa55..3d29883887a79 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -3,6 +3,7 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router" import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; import { isElectron } from "./env"; @@ -101,6 +102,7 @@ export function startAppServices(): AppRouter { startClientAppearance(); startDocumentTitle(); + startAgentOpenedPreviews(); return router; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7e81eb6124a69..2ccfce10f7b1d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -936,7 +936,6 @@ export default function ChatView(props: ChatViewProps) { ); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; - const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded; const resolvePreviewRuntimeTabId = activeThreadRef ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) : undefined; @@ -988,34 +987,6 @@ export default function ChatView(props: ChatViewProps) { ), ); - useEffect(() => { - if (!activeThreadRef || !previewSessionsReady) return; - const hiddenTabIds = new Set( - Object.values(activePreviewState.sessions) - .filter((session) => session.runtime === "server" && session.reveal === false) - .map((session) => session.tabId), - ); - useRightPanelStore - .getState() - .reconcileBrowserSurfaces( - activeThreadRef, - Object.keys(activePreviewState.sessions), - hiddenTabIds, - ); - }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - - useEffect(() => { - const source = activePreviewMiniPlayer?.source; - if ( - activeThreadRef && - previewSessionsReady && - source?.kind === "browser" && - !activePreviewState.sessions[source.tabId] - ) { - usePreviewMiniPlayerStore.getState().close(activeThreadRef); - } - }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); @@ -1900,9 +1871,6 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, deviceState, deviceStateLoaded, - previewState: activePreviewState, - serverBrowserAvailable: activeEnvironmentServerBrowser, - panelSurfaces: rightPanelState.surfaces, miniPlayerSource: activePreviewMiniPlayer?.source ?? null, sheetLayout: shouldUsePlanSidebarSheet, }); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts new file mode 100644 index 0000000000000..7a0a12ebdc227 --- /dev/null +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -0,0 +1,108 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; +import { __testing as previewStateTesting } from "../../previewStateStore"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { followPreviewSessions, revealServerTabs } from "./agentOpenedPreviews"; + +vi.mock("../../state/entities", () => ({ + readEnvironmentSupportsServerBrowser: () => true, +})); + +const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1")); +const threadKey = scopedThreadKey(ref); + +function setup() { + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {} }); +} + +const miniPlayerSource = () => usePreviewMiniPlayerStore.getState().byThreadKey[threadKey]?.source; + +describe("revealServerTabs", () => { + const tab = (tabId: string, force?: boolean): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId, + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + runtime: "server", + reveal: true, + ...(force === undefined ? {} : { revealRequest: { id: "r1", force } }), + }); + const floatingTabId = () => { + const source = miniPlayerSource(); + return source?.kind === "browser" ? source.tabId : null; + }; + + it("floats revealed tabs, and only forced ones when floating previews are off", () => { + setup(); + revealServerTabs(ref, [tab("tab_plain")], { autoShowFloatingPreview: false }); + expect(floatingTabId()).toBeNull(); + revealServerTabs(ref, [tab("tab_forced", true)], { autoShowFloatingPreview: false }); + expect(floatingTabId()).toBe("tab_forced"); + }); + + it("focuses a tab already in the panel instead of floating it when forced", () => { + setup(); + useRightPanelStore.getState().openBrowser(ref, "tab_a"); + const browserSurfaceId = useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId; + useRightPanelStore.getState().open(ref, "diff"); + + revealServerTabs(ref, [tab("tab_a", false)], { autoShowFloatingPreview: true }); + expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).not.toBe( + browserSurfaceId, + ); + revealServerTabs(ref, [tab("tab_a", true)], { autoShowFloatingPreview: true }); + expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe( + browserSurfaceId, + ); + expect(floatingTabId()).toBeNull(); + }); +}); + +describe("followPreviewSessions", () => { + const snapshot = (tabId: string, reveal = true): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId, + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + runtime: "server", + reveal, + }); + const previewState = (sessions: ReadonlyArray, listLoaded = true) => ({ + ...previewStateTesting.EMPTY_THREAD_PREVIEW_STATE, + listLoaded, + sessions: Object.fromEntries(sessions.map((entry) => [entry.tabId, entry])), + }); + const browserTabs = () => + useRightPanelStore + .getState() + .byThreadKey[threadKey]?.surfaces.filter((surface) => surface.kind === "preview") + .map((surface) => surface.id); + + it("adds visible sessions as tabs and drops tabs whose session ended", () => { + setup(); + useRightPanelStore.getState().openBrowser(ref, "tab_gone"); + followPreviewSessions(ref, previewState([snapshot("tab_a"), snapshot("tab_hidden", false)])); + expect(browserTabs()).toEqual(["browser:tab_a"]); + }); + + it("waits for the first server list before treating missing sessions as closed", () => { + setup(); + useRightPanelStore.getState().openBrowser(ref, "tab_a"); + usePreviewMiniPlayerStore.getState().open(ref, browserMiniPlayerSource("tab_a")); + followPreviewSessions(ref, previewState([], false)); + expect(browserTabs()).toEqual(["browser:tab_a"]); + expect(miniPlayerSource()).toMatchObject({ kind: "browser", tabId: "tab_a" }); + + followPreviewSessions(ref, previewState([])); + expect(browserTabs()).toEqual([]); + expect(miniPlayerSource()).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts new file mode 100644 index 0000000000000..2940cf042d60e --- /dev/null +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -0,0 +1,74 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; + +import { getClientSettings } from "../../hooks/useSettings"; +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; +import { + onPreviewSessionsChanged, + onServerTabsRevealed, + type ThreadPreviewState, +} from "../../previewStateStore"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { readEnvironmentSupportsServerBrowser } from "../../state/entities"; + +/** + * Shows browsers an agent or another client revealed for a thread: floating + * over chat, or focusing an existing panel tab when the reveal is forced. + */ +export function revealServerTabs( + threadRef: ScopedThreadRef, + sessions: ReadonlyArray, + options: { readonly autoShowFloatingPreview: boolean }, +): void { + if (!readEnvironmentSupportsServerBrowser(threadRef.environmentId)) return; + for (const session of sessions) { + const force = session.revealRequest?.force === true; + if (!options.autoShowFloatingPreview && !force) continue; + const surface = useRightPanelStore + .getState() + .byThreadKey[scopedThreadKey(threadRef)]?.surfaces.find( + (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, + ); + if (surface && force) { + useRightPanelStore.getState().activateSurface(threadRef, surface.id); + } else if (!surface) { + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(session.tabId)); + } + } +} + +/** + * Keeps a thread's browser panel tabs and floating browser in step with its + * preview sessions. Server-hosted sessions are only complete once the first + * list has loaded; before that, a missing session is not a closed one. + */ +export function followPreviewSessions(threadRef: ScopedThreadRef, state: ThreadPreviewState): void { + if (readEnvironmentSupportsServerBrowser(threadRef.environmentId) && !state.listLoaded) return; + const sessions = Object.values(state.sessions); + useRightPanelStore.getState().reconcileBrowserSurfaces( + threadRef, + sessions.map((session) => session.tabId), + new Set( + sessions + .filter((session) => session.runtime === "server" && session.reveal === false) + .map((session) => session.tabId), + ), + ); + const miniPlayer = usePreviewMiniPlayerStore.getState(); + const source = miniPlayer.byThreadKey[scopedThreadKey(threadRef)]?.source; + if (source?.kind === "browser" && !state.sessions[source.tabId]) miniPlayer.close(threadRef); +} + +let started = false; + +/** 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, + }), + ); +} diff --git a/apps/web/src/components/preview/useAgentOpenedPreviews.ts b/apps/web/src/components/preview/useAgentOpenedPreviews.ts index 79327db0321f3..7a5dbbd53689a 100644 --- a/apps/web/src/components/preview/useAgentOpenedPreviews.ts +++ b/apps/web/src/components/preview/useAgentOpenedPreviews.ts @@ -3,12 +3,10 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useEffect, useRef } from "react"; import { useClientSettings } from "../../hooks/useSettings"; import { - browserMiniPlayerSource, usePreviewMiniPlayerStore, type PreviewMiniPlayerState, } from "../../previewMiniPlayerStore"; -import type { ThreadPreviewState } from "../../previewStateStore"; -import { useRightPanelStore, type RightPanelSurface } from "../../rightPanelStore"; +import { useRightPanelStore } from "../../rightPanelStore"; const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; @@ -17,23 +15,11 @@ export function useAgentOpenedPreviews(input: { readonly threadRef: ScopedThreadRef | null; readonly deviceState: DeviceServiceState; readonly deviceStateLoaded: boolean; - readonly previewState: ThreadPreviewState; - readonly serverBrowserAvailable: boolean; - readonly panelSurfaces: ReadonlyArray; readonly miniPlayerSource: PreviewMiniPlayerState["source"] | null; // Sheet layouts show neither floating previews nor auto-opened device tabs. readonly sheetLayout: boolean; }) { - const { - threadRef, - deviceState, - deviceStateLoaded, - previewState, - serverBrowserAvailable, - panelSurfaces, - miniPlayerSource, - sheetLayout, - } = input; + const { threadRef, deviceState, deviceStateLoaded, miniPlayerSource, sheetLayout } = input; const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); // Baseline sessions to preserve dismissals on reload; missing device summaries must remain eligible to open // later. @@ -88,46 +74,6 @@ export function useAgentOpenedPreviews(input: { deviceState.sessions, deviceState.devices, ]); - // Baseline loaded tabs so reloads never reopen previews the user dismissed. - const previousServerPreviewTabs = useRef(new Map>()); - useEffect(() => { - if (!threadRef || !serverBrowserAvailable || !previewState.listLoaded) return; - const threadKey = scopedThreadKey(threadRef); - const serverSessions = Object.values(previewState.sessions).filter( - (session) => session.runtime === "server", - ); - const previous = previousServerPreviewTabs.current.get(threadKey); - previousServerPreviewTabs.current.set( - threadKey, - new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])), - ); - if (!previous) return; - for (const session of serverSessions) { - const requested = session.revealRequest; - const fresh = requested - ? previous.get(session.tabId) !== requested.id - : !previous.has(session.tabId); - if (!fresh || session.reveal !== true) continue; - if (!autoShowFloatingPreview && requested?.force !== true) continue; - const surface = panelSurfaces.find( - (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, - ); - if (surface && requested?.force === true) { - useRightPanelStore.getState().activateSurface(threadRef, surface.id); - } else if (!surface) { - usePreviewMiniPlayerStore - .getState() - .open(threadRef, browserMiniPlayerSource(session.tabId)); - } - } - }, [ - serverBrowserAvailable, - previewState.listLoaded, - previewState.sessions, - threadRef, - autoShowFloatingPreview, - panelSurfaces, - ]); useEffect(() => { if (!threadRef || !deviceStateLoaded) return; const source = miniPlayerSource; diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index d7775bbd6117b..986fe2615a1fa 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -19,6 +19,7 @@ import { reconcilePreviewServerSessions, resetPreviewStateForTests, setActivePreviewTab, + onServerTabsRevealed, } from "./previewStateStore"; import { appAtomRegistry } from "./rpc/atomRegistry"; @@ -418,3 +419,76 @@ 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 d885f302209c4..f80464e8ebd2a 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -98,20 +98,111 @@ function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): }); } -function updateThreadPreviewState( +function writeThreadPreviewState( ref: ScopedThreadRef, update: (current: ThreadPreviewState) => ThreadPreviewState, -): void { +): { readonly previous: ThreadPreviewState; readonly next: ThreadPreviewState } | null { const threadKey = scopedThreadKey(ref); const atom = previewStateAtom(threadKey); - let nextState = appAtomRegistry.get(atom); + let previousState = appAtomRegistry.get(atom); + let nextState = previousState; const changed = appAtomRegistry.modify(atom, (current) => { + previousState = current; nextState = update(current); return [nextState !== current, nextState]; }); - if (!changed) return; + 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); } const dedupeRecentUrls = (existing: string[], url: string): string[] => { @@ -166,7 +257,7 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState } export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; if (event.revision < current.serverRevision) return current; const next = (() => { @@ -237,7 +328,7 @@ export function applyPreviewServerSnapshot( ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null, ): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { if (!snapshot && current.snapshot === null) return current; if (!snapshot) { return { @@ -269,7 +360,7 @@ export function updatePreviewServerSnapshot( ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot, ): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { if (current.suppressedTabIds.has(snapshot.tabId)) return current; const existing = current.sessions[snapshot.tabId]; if (existing && existing.updatedAt > snapshot.updatedAt) return current; @@ -292,7 +383,7 @@ export function reconcilePreviewServerSessions( ref: ScopedThreadRef, result: PreviewListResult, ): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { const sameServer = current.serverEpoch === result.serverEpoch; if (sameServer && result.revision < current.serverRevision) { return current;