From 41c4e00b7340b7f9fb2a2015ae97450bfa51ab98 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:35:15 -0700 Subject: [PATCH] refactor(web): move agent-opened browser and device surfacing out of ChatView useAgentOpenedPreviews holds the three effects that show a browser tab or device an agent or another client opened, and close a floating device once its session ends, with their baseline refs. The effect bodies are unchanged. The hook compiles and joins the compile-required list. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 115 ++----------- .../preview/useAgentOpenedPreviews.ts | 154 ++++++++++++++++++ vite.config.ts | 1 + 3 files changed, 165 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/components/preview/useAgentOpenedPreviews.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5352615f7bbd..eb7166518df5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -462,6 +462,7 @@ import { import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl"; import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; import { useProactivePanels } from "../hooks/useProactivePanels"; +import { useAgentOpenedPreviews } from "./preview/useAgentOpenedPreviews"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadStatusLine } from "./chat/ThreadStatusLine"; import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat"; @@ -670,8 +671,6 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); -const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => - settings.browserAutoShowFloatingPreview; const DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); @@ -4129,110 +4128,16 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "device"); }; const addDeviceSurfaceStable = useStableCallback(addDeviceSurface); - const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); - const previousDeviceSessions = useRef(new Map>()); - useEffect(() => { - if (!activeThreadRef || !deviceStateLoaded) return; - const threadKey = `${activeThreadRef.environmentId}:${activeThreadRef.threadId}`; - const sessions = deviceState.sessions.filter( - (session) => session.threadId === activeThreadRef.threadId, - ); - const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`; - const deviceFor = (session: (typeof sessions)[number]) => - deviceState.devices.find( - (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, - ); - const previous = previousDeviceSessions.current.get(threadKey); - previousDeviceSessions.current.set( - threadKey, - new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)), - ); - if (!previous || shouldUsePlanSidebarSheet) return; - for (const session of sessions) { - if (previous.has(key(session))) continue; - const device = deviceFor(session); - if (!device) continue; - const target = { - hostId: session.hostId, - deviceId: session.deviceId, - platform: device.platform, - name: device.name, - }; - if (autoShowFloatingPreview) { - usePreviewMiniPlayerStore.getState().open(activeThreadRef, { kind: "device", ...target }); - continue; - } - const existing = useRightPanelStore - .getState() - .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some( - (surface) => - surface.kind === "device" && - surface.target?.hostId === session.hostId && - surface.target.deviceId === session.deviceId, - ); - if (existing) continue; - useRightPanelStore.getState().openDevice(activeThreadRef, target, true); - } - }, [ - activeThreadRef, - autoShowFloatingPreview, + useAgentOpenedPreviews({ + threadRef: activeThreadRef, + deviceState, deviceStateLoaded, - shouldUsePlanSidebarSheet, - deviceState.sessions, - deviceState.devices, - ]); - const previousServerPreviewTabs = useRef(new Map>()); - useEffect(() => { - if (!activeThreadRef || !activeEnvironmentServerBrowser || !activePreviewState.listLoaded) - return; - const threadKey = scopedThreadKey(activeThreadRef); - const serverSessions = Object.values(activePreviewState.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 = rightPanelState.surfaces.find( - (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, - ); - if (surface && requested?.force === true) { - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - } else if (!surface) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, browserMiniPlayerSource(session.tabId)); - } - } - }, [ - activeEnvironmentServerBrowser, - activePreviewState.listLoaded, - activePreviewState.sessions, - activeThreadRef, - autoShowFloatingPreview, - rightPanelState.surfaces, - ]); - useEffect(() => { - if (!activeThreadRef || !deviceStateLoaded) return; - const source = activePreviewMiniPlayer?.source; - if (source?.kind !== "device") return; - const sessionStillExists = deviceState.sessions.some( - (session) => - session.threadId === activeThreadRef.threadId && - session.hostId === source.hostId && - session.deviceId === source.deviceId, - ); - if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); - }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); + previewState: activePreviewState, + serverBrowserAvailable: activeEnvironmentServerBrowser, + panelSurfaces: rightPanelState.surfaces, + miniPlayerSource: activePreviewMiniPlayer?.source ?? null, + sheetLayout: shouldUsePlanSidebarSheet, + }); const openFileSurface = (relativePath: string) => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().openFile(activeThreadRef, relativePath); diff --git a/apps/web/src/components/preview/useAgentOpenedPreviews.ts b/apps/web/src/components/preview/useAgentOpenedPreviews.ts new file mode 100644 index 000000000000..a48d00a1a930 --- /dev/null +++ b/apps/web/src/components/preview/useAgentOpenedPreviews.ts @@ -0,0 +1,154 @@ +import type { DeviceServiceState, ScopedThreadRef } from "@t3tools/contracts"; +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"; + +const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => + settings.browserAutoShowFloatingPreview; + +/** + * Shows browsers and devices an agent or another client opened for the thread, + * and closes a floating device once its session ends. + */ +export function useAgentOpenedPreviews(input: { + readonly threadRef: ScopedThreadRef | null; + readonly deviceState: DeviceServiceState; + readonly deviceStateLoaded: boolean; + readonly previewState: ThreadPreviewState; + // Whether the environment hosts browser tabs for this client. + 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 autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); + // A device the agent opens floats over chat like an agent-driven browser, + // or becomes a panel tab when floating previews are off. Sessions opened by + // another client arrive the same way; sheet layouts get neither. The first + // snapshot is a baseline: persisted tabs restore themselves, and existing + // sessions must not resurrect closed tabs. A session whose device summary + // has not arrived yet stays out of the baseline so a later snapshot opens it. + const previousDeviceSessions = useRef(new Map>()); + useEffect(() => { + if (!threadRef || !deviceStateLoaded) return; + const threadKey = `${threadRef.environmentId}:${threadRef.threadId}`; + const sessions = deviceState.sessions.filter( + (session) => session.threadId === threadRef.threadId, + ); + const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`; + const deviceFor = (session: (typeof sessions)[number]) => + deviceState.devices.find( + (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, + ); + const previous = previousDeviceSessions.current.get(threadKey); + previousDeviceSessions.current.set( + threadKey, + new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)), + ); + if (!previous || sheetLayout) return; + for (const session of sessions) { + if (previous.has(key(session))) continue; + const device = deviceFor(session); + if (!device) continue; + const target = { + hostId: session.hostId, + deviceId: session.deviceId, + platform: device.platform, + name: device.name, + }; + if (autoShowFloatingPreview) { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", ...target }); + continue; + } + const existing = useRightPanelStore + .getState() + .byThreadKey[scopedThreadKey(threadRef)]?.surfaces.some( + (surface) => + surface.kind === "device" && + surface.target?.hostId === session.hostId && + surface.target.deviceId === session.deviceId, + ); + if (existing) continue; + useRightPanelStore.getState().openDevice(threadRef, target, true); + } + }, [ + threadRef, + autoShowFloatingPreview, + deviceStateLoaded, + sheetLayout, + 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, + ]); + // A floating device follows its session: once the agent or another client + // closes the device there is nothing left to stream. + useEffect(() => { + if (!threadRef || !deviceStateLoaded) return; + const source = miniPlayerSource; + if (source?.kind !== "device") return; + const sessionStillExists = deviceState.sessions.some( + (session) => + session.threadId === threadRef.threadId && + session.hostId === source.hostId && + session.deviceId === source.deviceId, + ); + if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(threadRef); + }, [miniPlayerSource, threadRef, deviceState.sessions, deviceStateLoaded]); +} diff --git a/vite.config.ts b/vite.config.ts index aec8e595ac92..8657c8197857 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -331,6 +331,7 @@ export default defineConfig({ "apps/web/src/components/chat/useQueuedRunEdit.ts", "apps/web/src/components/chat/useUsageLimitsPanel.ts", "apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts", + "apps/web/src/components/preview/useAgentOpenedPreviews.ts", "apps/web/src/components/terminal/**", "apps/web/src/hooks/useProactivePanels.ts", ],