import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; import { previewPdfAssetUrl } from "@t3tools/shared/preview"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef, useState, } from "react"; import { create } from "zustand"; import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useChatCanvas } from "../chat/ChatCanvasContext"; import type { ChatCanvasPreview } from "../chat/chatCanvasLayout"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { findActiveBrowserRecordingRuntimeTabId, useActiveBrowserRecordingTabIds, } from "~/browser/browserRecording"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { rendersServerTabNatively } from "~/browser/previewRuntime"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { closeServerPictureInPicture, openServerPictureInPicture, serverPictureInPictureKey, supportsServerPictureInPicture, useServerPictureInPictureKey, } from "~/browser/serverPictureInPicture"; import { useBrowserLayoutZoomFactor } from "~/browser/useBrowserLayoutZoomFactor"; import { Button } from "~/components/ui/button"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { cn } from "~/lib/utils"; import { useThreadPreviewState } from "~/previewStateStore"; import { type PreviewMiniPlayerSize, type PreviewMiniPlayerSource, type PreviewMiniPlayerState, browserMiniPlayerSource, previewMiniPlayerSourceKey, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { useDeviceState } from "~/state/device"; import { DeviceStreamView } from "../device/DeviceStreamView"; import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; import { Atom } from "effect/reactivity"; import { deviceScreenKey, deviceStreamScreenAtom } from "../device/deviceStreamSession"; import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; import { previewBridge } from "./previewBridge"; import { showPreviewPopup } from "./showPreviewPopup"; import { clampPreviewMiniPlayerPosition, PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, resizePreviewMiniPlayer, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; interface PointerGesture { readonly pointerId: number; readonly pointerType: string; readonly pointerX: number; readonly pointerY: number; readonly frame: PreviewMiniPlayerFrame; readonly direction: BrowserViewportResizeDirection | null; moved: boolean; } const HANDLE_TAP_SLOP_PX = 6; interface Props { readonly threadRef: ScopedThreadRef; readonly miniPlayer: PreviewMiniPlayerState; } const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; const RESIZE_HANDLES: ReadonlyArray<{ readonly direction: BrowserViewportResizeDirection; readonly className: string; }> = [ { direction: "north", className: "inset-x-0 -top-1 h-2 cursor-ns-resize" }, { direction: "south", className: "inset-x-0 -bottom-1 h-2 cursor-ns-resize" }, { direction: "west", className: "inset-y-0 -left-1 w-2 cursor-ew-resize" }, { direction: "east", className: "inset-y-0 -right-1 w-2 cursor-ew-resize" }, { direction: "northwest", className: "-left-2 -top-2 size-4 cursor-nwse-resize" }, { direction: "northeast", className: "-right-2 -top-2 size-4 cursor-nesw-resize" }, { direction: "southwest", className: "-bottom-2 -left-2 size-4 cursor-nesw-resize" }, { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; // What the stream reports about its source. It lives outside the player so the // chat canvas lays out the frame from the same source size the player uses. One // slot is enough, since only the shown thread renders a player, and it keeps the // store from collecting an entry for every tab a session ever streamed. const useStreamedSourceSize = create<{ readonly key: string; readonly viewport: PreviewStreamViewport; } | null>(() => null); const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe( Atom.withLabel("device-stream-screen:none"), ); const streamedSourceKey = (threadRef: ScopedThreadRef, sourceKey: string) => `${scopedThreadKey(threadRef)}\u0000${sourceKey}`; // The mini player's device stream view, named so the canvas can read the // screen size that view reports. const miniPlayerDeviceViewId = ( threadRef: ScopedThreadRef, source: Extract, ) => `preview-mini-player\u0000${scopedThreadKey(threadRef)}\u0000${deviceScreenKey(threadRef.environmentId, source.hostId, source.deviceId)}`; function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: string | null) { const previewState = useThreadPreviewState(threadRef); const snapshot = tabId === null ? null : (previewState.sessions[tabId] ?? null); const runtimeTabId = threadRef && tabId !== null ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) : null; const desktopOverlay = tabId === null ? null : (previewState.desktopByTabId[tabId] ?? null); const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); const fittedSourceContent = useBrowserSurfaceStore((state) => runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), ); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); const serverTab = threadRef !== null && snapshot?.runtime === "server" && !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); const streamViewport = useStreamedSourceSize((state) => threadRef && tabId !== null && state?.key === streamedSourceKey(threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId))) ? state.viewport : null, ); const sourceSize: PreviewMiniPlayerSize = serverTab && streamViewport ? streamViewport : resolvePreviewMiniPlayerSourceSize( snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, fittedSourceContent, layoutZoomFactor, ); return { serverEpoch: previewState.serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize }; } /** The mini player as the chat canvas lays it out; null when no player renders. */ export function useThreadPreviewCanvasSource( threadRef: ScopedThreadRef | null, miniPlayer: PreviewMiniPlayerState | null, ): ChatCanvasPreview | null { const source = miniPlayer?.source ?? null; const browser = useBrowserMiniPlayerSource( threadRef, source?.kind === "browser" ? source.tabId : null, ); const sourceKey = source ? previewMiniPlayerSourceKey(source) : null; const screen = useAtomValue( threadRef && source?.kind === "device" ? deviceStreamScreenAtom(miniPlayerDeviceViewId(threadRef, source)) : NO_DEVICE_SCREEN_ATOM, ); if (!miniPlayer || !source || sourceKey === null) return null; if (source.kind === "browser" && !browser.snapshot) return null; return { key: sourceKey, width: miniPlayer.width, position: miniPlayer.position, lastInteraction: miniPlayer.lastInteraction, source: source.kind === "browser" ? browser.sourceSize : resolveDeviceMiniPlayerSourceSize(source.platform, screen), }; } export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { const { source } = miniPlayer; return source.kind === "browser" ? ( ) : ( ); } function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { const httpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); const { serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize } = useBrowserMiniPlayerSource(threadRef, tabId); const runtimeTabId = previewRuntimeTabId(threadRef, serverEpoch, tabId); const recordingTabIds = useActiveBrowserRecordingTabIds(); const recording = recordingTabIds.has(runtimeTabId) || findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; const serverSurfaceRef = useRef(null); const serverPictureInPicture = useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId); const viewportKey = streamedSourceKey( threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId)), ); const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openBrowser(threadRef, tabId); }; const toggleNativePictureInPicture = async () => { try { if (serverTab) { if (serverPictureInPicture) closeServerPictureInPicture(); else await openServerPictureInPicture({ ...threadRef, tabId, seed: serverSurfaceRef.current?.canvas() ?? null, }); } else if (previewBridge) { const operation = desktopOverlay?.pictureInPicture ? previewBridge.pictureInPicture.close : previewBridge.pictureInPicture.open; await operation(runtimeTabId); } } catch (error) { toastManager.add({ type: "error", title: serverTab ? "Unable to pop out preview" : "Unable to update popped-out preview", description: error instanceof Error ? error.message : "An error occurred.", }); } }; if (!snapshot) return null; const pdfUrl = snapshot.navStatus._tag === "Success" ? previewPdfAssetUrl(snapshot.navStatus.url, httpBaseUrl) : null; const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture); const canPopOut = serverTab ? poppedOut || (pdfUrl === null && supportsServerPictureInPicture()) : true; return ( event.stopPropagation()} onClick={toggleNativePictureInPicture} /> } > {poppedOut ? "Close separate window" : "Pop into separate window"} ) : null } > {(frame) => serverTab ? (
useStreamedSourceSize.setState( (state) => state?.key === viewportKey && state.viewport.width === viewport.width && state.viewport.height === viewport.height ? state : { key: viewportKey, viewport }, true, ) } onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} className="size-full" />
) : ( <> {!desktopOverlay?.hasWebContents ? (
Reconnecting preview…
) : null} ) }
); } function DeviceMiniPlayer({ threadRef, source, miniPlayer, }: Props & { readonly source: Extract }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); const viewId = miniPlayerDeviceViewId(threadRef, source); const screen = useAtomValue(deviceStreamScreenAtom(viewId)); const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, ); const host = deviceState.hosts.find((entry) => entry.id === source.hostId); const hostLabel = host?.label ?? "Device host"; const cornerRadius = (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player); const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openDevice(threadRef, { hostId: source.hostId, deviceId: source.deviceId, platform: source.platform, name: source.name, }); }; return ( {() => (
)}
); } /** Match native clipping and DOM radii to align their separately composited edges. */ function MiniPlayerShell({ threadRef, miniPlayer, sourceSize, label, onOpenInPanel, pillActions, recording = false, cornerRadius = frameCornerRadius, children, }: { readonly threadRef: ScopedThreadRef; readonly miniPlayer: PreviewMiniPlayerState; readonly sourceSize: PreviewMiniPlayerSize; readonly label: string; readonly onOpenInPanel: () => void; readonly pillActions?: ReactNode; readonly recording?: boolean; readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number; readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; }) { const canvas = useChatCanvas(); const gestureRef = useRef(null); // Touch toggles the pill after the tap's click so that click cannot land on the newly opened pill. const [pillOpen, setPillOpen] = useState(false); const handleTappedRef = useRef(false); const handleRef = useRef(null); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- document pointerdown listener closes the open pill on an outside press useEffect(() => { if (!pillOpen) return; const dismiss = (event: PointerEvent) => { if (event.target instanceof Node && handleRef.current?.contains(event.target)) return; setPillOpen(false); }; document.addEventListener("pointerdown", dismiss, true); return () => document.removeEventListener("pointerdown", dismiss, true); }, [pillOpen]); const container = canvas?.container ?? null; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; const pillInset = Math.max(8, Math.round(radius * 0.55)); const close = () => { usePreviewMiniPlayerStore.getState().close(threadRef); }; const beginGesture = ( event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { if (event.button !== 0 || !frame) return; gestureRef.current = { pointerId: event.pointerId, pointerType: event.pointerType, pointerX: event.clientX, pointerY: event.clientY, frame, direction, moved: false, }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; const handlePointerMove = (event: ReactPointerEvent) => { const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId || !container) return; const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; gesture.moved = true; const store = usePreviewMiniPlayerStore.getState(); if (gesture.direction === null) { store.move( threadRef, sourceKey, clampPreviewMiniPlayerPosition( { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, container, gesture.frame, ), ); return; } const next = resizePreviewMiniPlayer({ start: gesture.frame, direction: gesture.direction, delta, source: sourceSize, container, }); store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); }; const endGesture = (event: ReactPointerEvent) => { const gesture = gestureRef.current; if (gesture?.pointerId !== event.pointerId) return; gestureRef.current = null; handleTappedRef.current = event.type === "pointerup" && gesture.direction === null && gesture.pointerType === "touch" && !gesture.moved; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; return (
{frame ? (
beginGesture(event, null)} onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} onClick={() => { if (!handleTappedRef.current) return; handleTappedRef.current = false; setPillOpen((open) => !open); }} >
{recording ? ( ) : null} event.stopPropagation()} onClick={onOpenInPanel} /> } > Open in right panel {pillActions} event.stopPropagation()} onClick={close} /> } > Close floating preview
{children(frame)}
{RESIZE_HANDLES.map(({ direction, className }) => (
beginGesture(event, direction)} onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} /> ))}
) : null}
); }