From 021d3ca413705f0b846eac67cbf8591edbfed022 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:26:39 -0700 Subject: [PATCH 1/3] fix(web): devices agents open in background threads now show up Problem: agent-opened devices were followed by two effects in ChatView's useAgentOpenedPreviews, scoped to the open thread. The first device snapshot a thread saw became its baseline, so a device an agent opened in another thread was already "seen" by the time you switched there and never floated or got a panel tab. A floating device whose session ended in a thread you were not viewing stayed open until you came back. Fix: startAgentOpenedPreviews (already started from appInit for browser reveals) now also watches device state for every connected environment and applies each snapshot with a per-environment baseline: new sessions float (or open a panel tab when floating previews are off, nothing in sheet layouts) for whichever thread owns them, and floating devices whose session ended close. Effects: useAgentOpenedPreviews.ts 2 -> 0 (file deleted); ChatView.tsx unchanged count (hook call removed). The device watch is an atom subscription owned by the app-lifetime starter. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 12 +- .../preview/agentOpenedPreviews.test.ts | 124 ++++++++++++++- .../components/preview/agentOpenedPreviews.ts | 147 +++++++++++++++++- .../preview/useAgentOpenedPreviews.ts | 89 ----------- 4 files changed, 268 insertions(+), 104 deletions(-) delete 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 8bf2ab68558ca..f7f72db6ec2c5 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"; @@ -1840,16 +1839,7 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( - activeThreadRef?.environmentId ?? null, - ); - useAgentOpenedPreviews({ - threadRef: activeThreadRef, - deviceState, - deviceStateLoaded, - miniPlayerSource: activePreviewMiniPlayer?.source ?? null, - sheetLayout: shouldUsePlanSidebarSheet, - }); + const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null); // 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( diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index c6e7492e5cc82..57cc4d587f8e1 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,9 @@ import { } from "../../previewStateStore"; import { useRightPanelStore } from "../../rightPanelStore"; import { + applyDeviceSnapshot, followPreviewSessions, + resetDeviceSessionBaselinesForTests, revealServerTabs, startAgentOpenedPreviews, } from "./agentOpenedPreviews"; @@ -26,14 +33,129 @@ 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; + 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 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("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..34adfbe073c6b 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -1,5 +1,17 @@ -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"; @@ -9,8 +21,11 @@ import { 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 +114,113 @@ 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. */ +const sessionKey = (session: { + readonly threadId: string; + readonly hostId: string; + readonly deviceId: string; +}) => `${session.threadId}:${session.hostId}:${session.deviceId}`; + +/** + * 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); + } +} + +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 +231,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]); -} From a8fa74d95154c2aa1a703c07b4bedc225dec0260 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:31:16 -0700 Subject: [PATCH 2/3] fix(web): closing the panel no longer floats a device whose session ended Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 5 +++- .../components/chat/useRightPanelActions.ts | 11 +++++--- .../preview/agentOpenedPreviews.test.ts | 27 +++++++++++++++++++ .../components/preview/agentOpenedPreviews.ts | 20 +++++++++++++- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f7f72db6ec2c5..f0188dd3aa387 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1839,7 +1839,9 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null); + const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( + activeThreadRef?.environmentId ?? null, + ); // 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( @@ -2319,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 57cc4d587f8e1..5f483e8fb8d12 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -20,6 +20,7 @@ import { import { useRightPanelStore } from "../../rightPanelStore"; import { applyDeviceSnapshot, + floatDeviceSurface, followPreviewSessions, resetDeviceSessionBaselinesForTests, revealServerTabs, @@ -156,6 +157,32 @@ describe("applyDeviceSnapshot", () => { }); }); +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 34adfbe073c6b..03f48a3ab8f1a 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -14,7 +14,11 @@ 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, @@ -197,6 +201,20 @@ export function applyDeviceSnapshot( } } +/** + * 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(); } From 4b4e8067455fba5147a57d1d7ab96a01019be56e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:08:32 -0700 Subject: [PATCH 3/3] fix(web): a device session reopened during a disconnect opens its preview The device-session baseline key now includes openedAt, so a session that ended and reopened for the same thread and device while disconnected no longer looks like the old one and still opens its preview. Co-Authored-By: Claude Opus 5.5 --- .../components/preview/agentOpenedPreviews.test.ts | 14 +++++++++++++- .../src/components/preview/agentOpenedPreviews.ts | 9 ++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index 5f483e8fb8d12..9464d832d7f79 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -53,7 +53,9 @@ const session = { } as const; const deviceState = (input: { - readonly sessions?: ReadonlyArray; + readonly sessions?: ReadonlyArray< + Omit & { readonly openedAt: string } + >; readonly devices?: ReadonlyArray; }) => ({ @@ -141,6 +143,16 @@ describe("applyDeviceSnapshot", () => { 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")); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index 03f48a3ab8f1a..ccb133074baad 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -118,11 +118,10 @@ function followThread(threadKey: string, threadRef: ScopedThreadRef): () => void }); } -const sessionKey = (session: { - readonly threadId: string; - readonly hostId: string; - readonly deviceId: string; -}) => `${session.threadId}:${session.hostId}:${session.deviceId}`; +// `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