diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 973dd5749027..ae0cdc8581ff 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -126,7 +126,7 @@ describe("floating browser preview", () => { const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1")); const panels = useRightPanelStore.getState(); const revision = panels.getUserActionRevision(ref); - usePreviewMiniPlayerStore.getState().open(ref, "agent-tab"); + usePreviewMiniPlayerStore.getState().open(ref, { kind: "browser", tabId: "agent-tab" }); panels.reconcileBrowserSurfaces(ref, ["agent-tab"]); const intent = selectThreadPreviewMiniPlayer( usePreviewMiniPlayerStore.getState().byThreadKey, @@ -135,7 +135,7 @@ describe("floating browser preview", () => { const isFloating = () => shouldRenderPreviewMiniPlayer( selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref) - ?.tabId ?? null, + ?.source ?? null, selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref), ); @@ -152,22 +152,61 @@ describe("floating browser preview", () => { }); it("only hides the duplicate while the same browser is rendered in the panel", () => { + const tab = { kind: "browser", tabId: "tab-1" } as const; expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); expect( - shouldRenderPreviewMiniPlayer("tab-1", { + shouldRenderPreviewMiniPlayer(tab, { id: "browser:one", kind: "preview", resourceId: "tab-1", }), ).toBe(false); expect( - shouldRenderPreviewMiniPlayer("tab-1", { + shouldRenderPreviewMiniPlayer(tab, { id: "browser:two", kind: "preview", resourceId: "tab-2", }), ).toBe(true); - expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); + expect(shouldRenderPreviewMiniPlayer(tab, { id: "diff", kind: "diff" })).toBe(true); + }); + + it("only hides a floating device while that device is rendered in the panel", () => { + const pixel = { + kind: "device", + hostId: "nucbox", + deviceId: "emulator-5580", + platform: "android", + name: "Pixel", + } as const; + const target = { + hostId: "nucbox", + deviceId: "emulator-5580", + platform: "android", + name: "Pixel", + } as const; + expect( + shouldRenderPreviewMiniPlayer(pixel, { + id: "device:nucbox:emulator-5580", + kind: "device", + target, + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayer(pixel, { + id: "device:nucbox:emulator-5554", + kind: "device", + target: { ...target, deviceId: "emulator-5554" }, + }), + ).toBe(true); + expect(shouldRenderPreviewMiniPlayer(pixel, { id: "device", kind: "device" })).toBe(true); + expect( + shouldRenderPreviewMiniPlayer(pixel, { + id: "browser:one", + kind: "preview", + resourceId: "emulator-5580", + }), + ).toBe(true); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 772a0f3cf2fa..b6bb98b4c50a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -50,6 +50,7 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; +import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; import { @@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +/** The floating player hides only while the same source is rendered in the panel. */ export function shouldRenderPreviewMiniPlayer( - miniPlayerTabId: string | null, + source: PreviewMiniPlayerSource | null, renderedRightPanelSurface: RightPanelSurface | null, ): boolean { - return ( - miniPlayerTabId !== null && - !( + if (source === null) return false; + if (source.kind === "browser") { + return !( renderedRightPanelSurface?.kind === "preview" && - renderedRightPanelSurface.resourceId === miniPlayerTabId - ) + renderedRightPanelSurface.resourceId === source.tabId + ); + } + return !( + renderedRightPanelSurface?.kind === "device" && + renderedRightPanelSurface.target?.hostId === source.hostId && + renderedRightPanelSurface.target.deviceId === source.deviceId ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fc529b02a73b..0e786ae54af7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -195,6 +195,8 @@ import { useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { + browserMiniPlayerSource, + previewMiniPlayerSourceKey, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; @@ -566,6 +568,8 @@ 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 })), ); @@ -1958,7 +1962,7 @@ export default function ChatView(props: ChatViewProps) { const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.tabId ?? null, + activePreviewMiniPlayer?.source ?? null, renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; @@ -1974,8 +1978,10 @@ export default function ChatView(props: ChatViewProps) { }, [activePreviewState.sessions, activeThreadRef]); useEffect(() => { - if (!activeThreadRef || !activePreviewMiniPlayer) return; - const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); + if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return; + const miniTabStillExists = Boolean( + activePreviewState.sessions[activePreviewMiniPlayer.source.tabId], + ); if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } @@ -4215,9 +4221,13 @@ export default function ChatView(props: ChatViewProps) { } useRightPanelStore.getState().open(activeThreadRef, "device"); }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]); - // Reconcile new server sessions into separate tabs, including sessions opened - // by an agent or another client. The first snapshot is a baseline: persisted - // tabs restore themselves, and existing sessions must not resurrect closed tabs. + // 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 autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); const previousDeviceSessions = useRef(new Map>()); useEffect(() => { if (!activeThreadRef || !deviceStateLoaded) return; @@ -4226,11 +4236,30 @@ export default function ChatView(props: ChatViewProps) { (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.map(key))); + previousDeviceSessions.current.set( + threadKey, + new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)), + ); if (!previous || shouldUseRightPanelSheet) return; for (const session of sessions) { - if (previous?.has(key(session))) continue; + 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( @@ -4240,28 +4269,30 @@ export default function ChatView(props: ChatViewProps) { surface.target.deviceId === session.deviceId, ); if (existing) continue; - const device = deviceState.devices.find( - (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, - ); - if (!device) continue; - useRightPanelStore.getState().openDevice( - activeThreadRef, - { - hostId: session.hostId, - deviceId: session.deviceId, - platform: device.platform, - name: device.name, - }, - true, - ); + useRightPanelStore.getState().openDevice(activeThreadRef, target, true); } }, [ activeThreadRef, + autoShowFloatingPreview, deviceStateLoaded, shouldUseRightPanelSheet, deviceState.sessions, deviceState.devices, ]); + // A floating device follows its session: once the agent or another client + // closes the device there is nothing left to stream. + 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]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4421,10 +4452,15 @@ export default function ChatView(props: ChatViewProps) { ]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { + // Closing the panel on a live browser or device floats it instead of dropping it. if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { usePreviewMiniPlayerStore .getState() - .open(activeThreadRef, activeRightPanelSurface.resourceId); + .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); + } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); } setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); @@ -8834,9 +8870,9 @@ export default function ChatView(props: ChatViewProps) { {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( ) : null} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 5b312f7fc4f4..950a202a7b9f 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -7,6 +7,7 @@ import type { import { ChevronLeft, Home, + PictureInPicture2, Power, RotateCcw, SlidersHorizontal, @@ -16,6 +17,7 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; import { Button } from "~/components/ui/button"; import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; @@ -116,6 +118,19 @@ export function DevicePanel(props: { } }; + // Floating the device closes the panel, like the browser's floating preview. + const floatActive = () => { + if (!activeDevice) return; + usePreviewMiniPlayerStore.getState().open(props.threadRef, { + kind: "device", + hostId: activeDevice.hostId, + deviceId: activeDevice.id, + platform: activeDevice.platform, + name: activeDevice.name, + }); + useRightPanelStore.getState().close(props.threadRef); + }; + const closeActive = (powerOff: boolean) => { if (!powerOff) { useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); @@ -218,6 +233,9 @@ export function DevicePanel(props: { > + + + closeActive(true)}> diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index d1fc12821730..a793e8a2c8c0 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -29,7 +29,11 @@ import { reconcilePreviewServerSessions, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTargets, @@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ?.has(runtimeTabId) ?? false, }) ) { - usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId); + usePreviewMiniPlayerStore + .getState() + .open(threadRef, browserMiniPlayerSource(readyTabId)); } } browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); @@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) new Set([activeRuntimeTabId]), ); } - const miniPlayer = selectThreadPreviewMiniPlayer( + const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId( usePreviewMiniPlayerStore.getState().byThreadKey, threadRef, ); - if (miniPlayer?.tabId === activeTabId) { + if (miniPlayerTabId === activeTabId) { usePreviewMiniPlayerStore.getState().close(threadRef); } } else if (shouldPresentPreview) { @@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } } if (shouldPresentPreview) { - usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); + usePreviewMiniPlayerStore + .getState() + .open(threadRef, browserMiniPlayerSource(activeTabId)); } if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { await requireReadyTab(); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2a146834012e..ea28a93235ee 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -171,7 +171,7 @@ vi.mock("~/previewMiniPlayerStore", () => { byThreadKey: mocks.miniPlayerTabId ? { "environment-1:thread-1": { - tabId: mocks.miniPlayerTabId, + source: { kind: "browser", tabId: mocks.miniPlayerTabId }, position: null, }, } @@ -185,9 +185,10 @@ vi.mock("~/previewMiniPlayerStore", () => { }, ); return { - selectThreadPreviewMiniPlayer: ( - byThreadKey: Record, - ) => byThreadKey["environment-1:thread-1"] ?? null, + browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }), + selectThreadPreviewMiniPlayerTabId: ( + byThreadKey: Record, + ) => byThreadKey["environment-1:thread-1"]?.source.tabId ?? null, usePreviewMiniPlayerStore, }; }); @@ -485,7 +486,10 @@ describe("PreviewView navigation", () => { renderToStaticMarkup(); expect(mocks.pictureInPicturePressed).toBe(false); mocks.togglePictureInPicture?.(); - expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, "tab-1"); + expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, { + kind: "browser", + tabId: "tab-1", + }); expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef); mocks.miniPlayerTabId = "tab-1"; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e6ad2758bc48..6086e049ab11 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -34,7 +34,11 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; -import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; @@ -114,8 +118,8 @@ export function PreviewView({ threadRef, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, ); - const miniPlayer = usePreviewMiniPlayerStore((state) => - selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + const miniPlayerTabId = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef), ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); @@ -311,13 +315,13 @@ export function PreviewView({ const handlePictureInPicture = useCallback(() => { if (!tabId) return; - if (miniPlayer?.tabId === tabId) { + if (miniPlayerTabId === tabId) { usePreviewMiniPlayerStore.getState().close(threadRef); return; } - usePreviewMiniPlayerStore.getState().open(threadRef, tabId); + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId)); useRightPanelStore.getState().close(threadRef); - }, [miniPlayer?.tabId, tabId, threadRef]); + }, [miniPlayerTabId, tabId, threadRef]); const handleNativePictureInPicture = useCallback(() => { if (!previewBridge || !runtimeTabId) return; @@ -722,7 +726,7 @@ export function PreviewView({ captureDisabled={!desktopOverlay || isUnreachable} recording={recordingRuntimeTabId !== null} onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} - pictureInPicture={miniPlayer?.tabId === tabId} + pictureInPicture={miniPlayerTabId === tabId} pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable} onPickElement={previewBridge && tabId ? handlePickElement : undefined} pickActive={pickActive} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 4384019abbad..432e41cb4ab3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,7 +2,14 @@ import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; @@ -15,17 +22,25 @@ import { cn } from "~/lib/utils"; import { useThreadPreviewState } from "~/previewStateStore"; import { type PreviewMiniPlayerSize, - selectThreadPreviewMiniPlayer, + type PreviewMiniPlayerSource, + type PreviewMiniPlayerState, + previewMiniPlayerSourceKey, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { useDeviceState } from "~/state/device"; +import { DeviceStreamView } from "../device/DeviceStreamView"; +import type { DeviceScreenSize } from "../device/deviceStream"; import { previewBridge } from "./previewBridge"; import { clampPreviewMiniPlayerPosition, + PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, resizePreviewMiniPlayer, + resolveDeviceMiniPlayerCornerRadius, + resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerFrame, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; @@ -40,11 +55,11 @@ interface PointerGesture { interface Props { readonly threadRef: ScopedThreadRef; - readonly tabId: string; + readonly miniPlayer: PreviewMiniPlayerState; readonly bottomInset: number; } -const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; +const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; // Invisible grab zones straddling each edge; the cursor is the only affordance. const RESIZE_HANDLES: ReadonlyArray<{ @@ -61,17 +76,34 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; -/** - * Floats the thread's browser surface over chat. Native clipping and the DOM - * frame use the same radius so their separately composited edges stay aligned. - */ -export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { - const containerRef = useRef(null); - const gestureRef = useRef(null); - const [container, setContainer] = useState(null); - const miniPlayer = usePreviewMiniPlayerStore((state) => - selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), +/** Floats the thread's browser tab or device stream over chat. */ +export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) { + const { source } = miniPlayer; + return source.kind === "browser" ? ( + + ) : ( + ); +} + +function BrowserMiniPlayer({ + threadRef, + tabId, + miniPlayer, + bottomInset, +}: Props & { readonly tabId: string }) { const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); @@ -79,25 +111,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const fittedSourceContent = useBrowserSurfaceStore( (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, ); - const source = resolvePreviewMiniPlayerSourceSize( + const sourceSize = resolvePreviewMiniPlayerSourceSize( snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, fittedSourceContent, desktopOverlay?.zoomFactor ?? 1, ); - const frame = - container && miniPlayer?.tabId === tabId - ? resolvePreviewMiniPlayerFrame({ - width: miniPlayer.width, - position: miniPlayer.position, - source, - container, - bottomInset, - }) - : null; - - const close = () => { - usePreviewMiniPlayerStore.getState().close(threadRef); - }; const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); @@ -118,6 +136,176 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props }); }; + if (!snapshot) return null; + + return ( + + event.stopPropagation()} + onClick={toggleNativePictureInPicture} + /> + } + > + + + + {desktopOverlay?.pictureInPicture + ? "Close separate window" + : "Pop into separate window"} + + + } + > + {(frame) => ( + <> + + {!desktopOverlay?.hasWebContents ? ( +
+ Reconnecting preview… +
+ ) : null} + + )} +
+ ); +} + +function DeviceMiniPlayer({ + threadRef, + source, + miniPlayer, + bottomInset, +}: Props & { readonly source: Extract }) { + const { state: deviceState } = useDeviceState(threadRef.environmentId); + const [screen, setScreen] = useState(null); + const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); + const device = deviceState.devices.find( + (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, + ); + const hostLabel = + deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host"; + const cornerRadius = useCallback( + (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player), + [source.platform], + ); + + const openInPanel = () => { + usePreviewMiniPlayerStore.getState().close(threadRef); + useRightPanelStore.getState().openDevice(threadRef, { + hostId: source.hostId, + deviceId: source.deviceId, + platform: source.platform, + name: source.name, + }); + }; + + return ( + + {() => ( + // The stream is DOM, so it takes the band the browser's native webview would. +
+ +
+ )} +
+ ); +} + +/** + * The frame, drag/resize gestures, and hover pill shared by every floating + * source. Native clipping and the DOM frame use the same radius so their + * separately composited edges stay aligned. + */ +function MiniPlayerShell({ + threadRef, + miniPlayer, + sourceSize, + bottomInset, + label, + onOpenInPanel, + pillActions, + cornerRadius = frameCornerRadius, + children, +}: { + readonly threadRef: ScopedThreadRef; + readonly miniPlayer: PreviewMiniPlayerState; + readonly sourceSize: PreviewMiniPlayerSize; + readonly bottomInset: number; + readonly label: string; + readonly onOpenInPanel: () => void; + readonly pillActions?: ReactNode; + /** The clip radius for a given frame; the pill stays inside the curve. */ + readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number; + readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; +}) { + const containerRef = useRef(null); + const gestureRef = useRef(null); + const [container, setContainer] = useState(null); + const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); + const frame = container + ? resolvePreviewMiniPlayerFrame({ + width: miniPlayer.width, + position: miniPlayer.position, + source: sourceSize, + container, + bottomInset, + }) + : null; + + const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; + // Inside a wide curve the default 8px inset would land on the clipped-away corner. + const pillInset = Math.max(8, Math.round(radius * 0.55)); + + const close = () => { + usePreviewMiniPlayerStore.getState().close(threadRef); + }; + useLayoutEffect(() => { const element = containerRef.current; if (!element) return; @@ -160,7 +348,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props if (gesture.direction === null) { store.move( threadRef, - tabId, + sourceKey, clampPreviewMiniPlayerPosition( { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, container, @@ -174,12 +362,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props start: gesture.frame, direction: gesture.direction, delta, - source, + source: sourceSize, container, bottomInset, }); - store.resize(threadRef, tabId, next.width); - store.move(threadRef, tabId, { x: next.x, y: next.y }); + store.resize(threadRef, sourceKey, next.width); + store.move(threadRef, sourceKey, { x: next.x, y: next.y }); }; const endGesture = (event: ReactPointerEvent) => { @@ -190,24 +378,25 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props } }; - if (!snapshot || miniPlayer?.tabId !== tabId) return null; - return (
{frame ? (
-
+