diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8bf2ab68558ca..f0188dd3aa387 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -303,7 +303,6 @@ import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullReq import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import { useAgentOpenedPreviews } from "./preview/useAgentOpenedPreviews"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; @@ -1843,13 +1842,6 @@ export default function ChatView(props: ChatViewProps) { const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); - useAgentOpenedPreviews({ - threadRef: activeThreadRef, - deviceState, - deviceStateLoaded, - miniPlayerSource: activePreviewMiniPlayer?.source ?? null, - sheetLayout: shouldUsePlanSidebarSheet, - }); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const activeProjectRepository = sourceControlClients.repositorySelector( @@ -2329,6 +2321,7 @@ export default function ChatView(props: ChatViewProps) { activeRightPanelSurface, activePreviewState, deviceState, + deviceStateLoaded, pullRequestPanelTarget, threadPanelPresentation, isServerThread, diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index d6b8e88ac32bb..be4c9dd9cbfd5 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -23,6 +23,7 @@ import { BrowserSettingsReadError } from "../../browser/openFileInPreview"; import { addBrowserSurface } from "../preview/addBrowserSurface"; import { focusActivePanelTerminal, focusTerminal } from "../../lib/terminalFocus"; import { subscribePreviewAction } from "../preview/previewActionBus"; +import { floatDeviceSurface } from "../preview/agentOpenedPreviews"; import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; import type { threadPullRequestPanelTarget } from "../pullRequest/pullRequestDetail.logic"; import type { useDeviceState } from "../../state/device"; @@ -37,6 +38,7 @@ interface RightPanelActionsInput { readonly activeRightPanelSurface: RightPanelSurface | null; readonly activePreviewState: ThreadPreviewState; readonly deviceState: ReturnType["state"]; + readonly deviceStateLoaded: boolean; readonly pullRequestPanelTarget: ReturnType | null; readonly threadPanelPresentation: ThreadPanelPresentation; readonly isServerThread: boolean; @@ -69,6 +71,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { activeRightPanelSurface, activePreviewState, deviceState, + deviceStateLoaded, pullRequestPanelTarget, threadPanelPresentation, isServerThread, @@ -164,9 +167,11 @@ export function useRightPanelActions(input: RightPanelActionsInput) { .getState() .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); + floatDeviceSurface( + activeThreadRef, + { kind: "device", ...activeRightPanelSurface.target }, + deviceStateLoaded ? deviceState : null, + ); } useRightPanelStore.getState().setMaximized(activeThreadRef, false); useRightPanelStore.getState().close(activeThreadRef); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index c6e7492e5cc82..9464d832d7f79 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -1,5 +1,10 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { + type DeviceServiceState, + type EnvironmentId, + type PreviewSessionSnapshot, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; @@ -14,7 +19,10 @@ import { } from "../../previewStateStore"; import { useRightPanelStore } from "../../rightPanelStore"; import { + applyDeviceSnapshot, + floatDeviceSurface, followPreviewSessions, + resetDeviceSessionBaselinesForTests, revealServerTabs, startAgentOpenedPreviews, } from "./agentOpenedPreviews"; @@ -26,14 +34,167 @@ vi.mock("../../state/entities", () => ({ const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1")); const threadKey = scopedThreadKey(ref); +const device = { + hostId: "local", + id: "pixel", + platform: "android", + name: "Pixel 8", + version: "15", + booted: true, + physical: false, +} as const; + +const session = { + threadId: ref.threadId, + hostId: "local", + deviceId: "pixel", + platform: "android", + openedAt: "2026-01-01T00:00:00.000Z", +} as const; + +const deviceState = (input: { + readonly sessions?: ReadonlyArray< + Omit & { readonly openedAt: string } + >; + readonly devices?: ReadonlyArray; +}) => + ({ + hosts: [], + hostStatus: "ready", + hostStatuses: {}, + devices: input.devices ?? [device], + sessions: input.sessions ?? [], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "/api/device-hub", + revision: 0, + }) as unknown as DeviceServiceState; + +const floating = { sheetLayout: false, autoShowFloatingPreview: true }; + function setup() { resetPreviewStateForTests(); + resetDeviceSessionBaselinesForTests(); usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); useRightPanelStore.setState({ byThreadKey: {} }); } const miniPlayerSource = () => usePreviewMiniPlayerStore.getState().byThreadKey[threadKey]?.source; +describe("applyDeviceSnapshot", () => { + it("treats the first snapshot as a baseline and floats sessions that start later", () => { + setup(); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), floating); + expect(miniPlayerSource()).toBeUndefined(); + + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [] }), floating); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), floating); + expect(miniPlayerSource()).toMatchObject({ kind: "device", deviceId: "pixel" }); + }); + + it("opens a session once its device summary arrives", () => { + setup(); + applyDeviceSnapshot(ref.environmentId, deviceState({}), floating); + applyDeviceSnapshot( + ref.environmentId, + deviceState({ sessions: [session], devices: [] }), + floating, + ); + expect(miniPlayerSource()).toBeUndefined(); + + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), floating); + expect(miniPlayerSource()).toMatchObject({ kind: "device", name: "Pixel 8" }); + }); + + it("opens a panel tab once when floating previews are off, and nothing in sheet layouts", () => { + setup(); + const panel = { sheetLayout: false, autoShowFloatingPreview: false }; + applyDeviceSnapshot(ref.environmentId, deviceState({}), { ...panel, sheetLayout: true }); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), { + ...panel, + sheetLayout: true, + }); + expect(useRightPanelStore.getState().byThreadKey[threadKey]).toBeUndefined(); + + applyDeviceSnapshot(ref.environmentId, deviceState({}), panel); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), panel); + applyDeviceSnapshot(ref.environmentId, deviceState({}), panel); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), panel); + const devices = useRightPanelStore + .getState() + .byThreadKey[threadKey]?.surfaces.filter((surface) => surface.kind === "device"); + expect(devices).toHaveLength(1); + expect(miniPlayerSource()).toBeUndefined(); + }); + + it("closes a floating device when its session ends, even before a baseline", () => { + setup(); + usePreviewMiniPlayerStore.getState().open(ref, { + kind: "device", + hostId: "local", + deviceId: "pixel", + platform: "android", + name: "Pixel 8", + }); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), floating); + expect(miniPlayerSource()).toMatchObject({ kind: "device" }); + + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [] }), floating); + expect(miniPlayerSource()).toBeUndefined(); + }); + + it("opens a session that ended and reopened between two snapshots", () => { + setup(); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), floating); + expect(miniPlayerSource()).toBeUndefined(); + + const reopened = { ...session, openedAt: "2026-01-01T00:05:00.000Z" }; + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [reopened] }), floating); + expect(miniPlayerSource()).toMatchObject({ kind: "device", deviceId: "pixel" }); + }); + + it("opens a device for a thread that had no sessions when the baseline was taken", () => { + setup(); + const otherRef = scopeThreadRef(ref.environmentId, ThreadId.make("thread-2")); + applyDeviceSnapshot(ref.environmentId, deviceState({ sessions: [session] }), floating); + applyDeviceSnapshot( + ref.environmentId, + deviceState({ sessions: [session, { ...session, threadId: otherRef.threadId }] }), + floating, + ); + expect(miniPlayerSource()).toBeUndefined(); + expect( + usePreviewMiniPlayerStore.getState().byThreadKey[scopedThreadKey(otherRef)]?.source, + ).toMatchObject({ kind: "device", deviceId: "pixel" }); + }); +}); + +describe("floatDeviceSurface", () => { + const source = { + kind: "device", + hostId: "local", + deviceId: "pixel", + platform: "android", + name: "Pixel 8", + } as const; + + it("floats a device with a live session, or before device state loads", () => { + setup(); + floatDeviceSurface(ref, source, deviceState({ sessions: [session] })); + expect(miniPlayerSource()).toMatchObject({ kind: "device", deviceId: "pixel" }); + + setup(); + floatDeviceSurface(ref, source, null); + expect(miniPlayerSource()).toMatchObject({ kind: "device", deviceId: "pixel" }); + }); + + it("does not float a device whose session ended", () => { + setup(); + floatDeviceSurface(ref, source, deviceState({ sessions: [] })); + expect(miniPlayerSource()).toBeUndefined(); + }); +}); + describe("revealServerTabs", () => { const tab = (tabId: string, force?: boolean): PreviewSessionSnapshot => ({ threadId: "thread-1", diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index dbc171eaf531d..ccb133074baad 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -1,16 +1,35 @@ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import type { + DeviceServiceState, + EnvironmentId, + PreviewSessionSnapshot, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; import { getClientSettings } from "../../hooks/useSettings"; -import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; +import { + browserMiniPlayerSource, + type PreviewMiniPlayerSource, + usePreviewMiniPlayerStore, +} from "../../previewMiniPlayerStore"; import { previewStateAtom, previewThreadRefsAtom, type ThreadPreviewState, } from "../../previewStateStore"; import { useRightPanelStore } from "../../rightPanelStore"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../../rightPanelLayout"; import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { deviceEnvironment } from "../../state/device"; import { readEnvironmentSupportsServerBrowser } from "../../state/entities"; +import { environmentSummaries } from "../../state/presentation"; /** * Shows browsers an agent or another client revealed for a thread: floating @@ -99,7 +118,126 @@ function followThread(threadKey: string, threadRef: ScopedThreadRef): () => void }); } -/** Follows every thread's browser previews for the life of the page, not just the open thread's. */ +// `openedAt` tells a session apart from one that ended and reopened on the same device while +// this client was disconnected. +const sessionKey = (session: DeviceServiceState["sessions"][number]) => + `${session.threadId}:${session.hostId}:${session.deviceId}:${session.openedAt}`; + +/** + * Per environment: the device sessions seen in the last snapshot. The first + * snapshot is a baseline, so persisted tabs restore themselves and existing + * sessions never resurrect tabs the user closed. Baselines outlive a + * disconnect, so a device an agent opened during the gap still shows. + */ +const deviceSessionBaselines = new Map>(); + +function hasLiveSession( + state: DeviceServiceState, + threadId: ThreadId, + target: { readonly hostId: string; readonly deviceId: string }, +): boolean { + return state.sessions.some( + (session) => + session.threadId === threadId && + session.hostId === target.hostId && + session.deviceId === target.deviceId, + ); +} + +/** + * A device the agent opens floats over its thread's chat, or becomes a panel tab + * when floating previews are off; sheet layouts get neither. A session without a + * device summary yet stays out of the baseline so a later snapshot opens it. + * Floating devices whose session ended close here. + */ +export function applyDeviceSnapshot( + environmentId: EnvironmentId, + state: DeviceServiceState, + options: { readonly sheetLayout: boolean; readonly autoShowFloatingPreview: boolean }, +): void { + const deviceFor = (session: DeviceServiceState["sessions"][number]) => + state.devices.find((entry) => entry.hostId === session.hostId && entry.id === session.deviceId); + + const miniPlayer = usePreviewMiniPlayerStore.getState(); + for (const [threadKey, entry] of Object.entries(miniPlayer.byThreadKey)) { + if (entry.source.kind !== "device") continue; + const ref = parseScopedThreadKey(threadKey); + if (ref?.environmentId === environmentId && !hasLiveSession(state, ref.threadId, entry.source)) + miniPlayer.close(ref); + } + + const previous = deviceSessionBaselines.get(environmentId); + deviceSessionBaselines.set( + environmentId, + new Set(state.sessions.filter((session) => deviceFor(session) !== undefined).map(sessionKey)), + ); + if (!previous || options.sheetLayout) return; + for (const session of state.sessions) { + if (previous.has(sessionKey(session))) continue; + const device = deviceFor(session); + if (!device) continue; + const threadRef = scopeThreadRef(environmentId, session.threadId); + const target = { + hostId: session.hostId, + deviceId: session.deviceId, + platform: device.platform, + name: device.name, + }; + if (options.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); + } +} + +/** + * Floats a device panel tab over chat. A tab whose device session already + * ended has nothing to stream, so it only closes the panel. Before the first + * device snapshot the tab floats, and that snapshot closes it if needed. + */ +export function floatDeviceSurface( + threadRef: ScopedThreadRef, + source: Extract, + state: DeviceServiceState | null, +): void { + if (state !== null && !hasLiveSession(state, threadRef.threadId, source)) return; + usePreviewMiniPlayerStore.getState().open(threadRef, source); +} + +export function resetDeviceSessionBaselinesForTests(): void { + deviceSessionBaselines.clear(); +} + +function watchEnvironmentDevices(environmentId: EnvironmentId): () => void { + return appAtomRegistry.subscribe( + deviceEnvironment.state({ environmentId, input: {} }), + (result) => { + const state = Option.getOrNull(AsyncResult.value(result)); + if (state === null) return; + applyDeviceSnapshot(environmentId, state, { + sheetLayout: window.matchMedia(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY).matches, + autoShowFloatingPreview: getClientSettings().browserAutoShowFloatingPreview, + }); + }, + { immediate: true }, + ); +} + +/** + * Follows every thread's browser previews and every connected environment's + * device sessions for the life of the page, not just the open thread's: an + * agent in a background thread can open either. + */ export function startAgentOpenedPreviews(): () => void { const followed = new Map void>(); const followNewThreads = (refs: ReadonlyMap) => { @@ -110,8 +248,28 @@ export function startAgentOpenedPreviews(): () => void { const stopIndex = appAtomRegistry.subscribe(previewThreadRefsAtom, followNewThreads, { immediate: true, }); + const watchedEnvironments = new Map void>(); + const watchConnectedEnvironments = (environmentIds: ReadonlyArray) => { + for (const [environmentId, stop] of watchedEnvironments) { + if (environmentIds.includes(environmentId)) continue; + stop(); + watchedEnvironments.delete(environmentId); + } + for (const environmentId of environmentIds) { + if (!watchedEnvironments.has(environmentId)) { + watchedEnvironments.set(environmentId, watchEnvironmentDevices(environmentId)); + } + } + }; + const stopEnvironments = appAtomRegistry.subscribe( + environmentSummaries.connectedEnvironmentIdsAtom, + watchConnectedEnvironments, + { immediate: true }, + ); return () => { stopIndex(); + stopEnvironments(); for (const stop of followed.values()) stop(); + for (const stop of watchedEnvironments.values()) stop(); }; } diff --git a/apps/web/src/components/preview/useAgentOpenedPreviews.ts b/apps/web/src/components/preview/useAgentOpenedPreviews.ts deleted file mode 100644 index 7a5dbbd53689a..0000000000000 --- a/apps/web/src/components/preview/useAgentOpenedPreviews.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 { - usePreviewMiniPlayerStore, - type PreviewMiniPlayerState, -} from "../../previewMiniPlayerStore"; -import { useRightPanelStore } from "../../rightPanelStore"; - -const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => - settings.browserAutoShowFloatingPreview; - -export function useAgentOpenedPreviews(input: { - readonly threadRef: ScopedThreadRef | null; - readonly deviceState: DeviceServiceState; - readonly deviceStateLoaded: boolean; - readonly miniPlayerSource: PreviewMiniPlayerState["source"] | null; - // Sheet layouts show neither floating previews nor auto-opened device tabs. - readonly sheetLayout: boolean; -}) { - 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. - 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, - ]); - 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]); -}