1"use client";
2
3import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts";
4import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";
5import {
6 type PointerEvent as ReactPointerEvent,
7 type ReactNode,
8 useCallback,
9 useEffect,
10 useLayoutEffect,
11 useRef,
12 useState,
13} from "react";
14
15import { useChatCanvas } from "../chat/ChatCanvasContext";
16import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
17import {
18 findActiveBrowserRecordingRuntimeTabId,
19 useActiveBrowserRecordingTabIds,
20} from "~/browser/browserRecording";
21import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
22import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
23import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
24import { useRendersServerTabNatively } from "~/browser/previewRuntime";
25import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface";
26import {
27 closeServerPictureInPicture,
28 openServerPictureInPicture,
29 serverPictureInPictureKey,
30 supportsServerPictureInPicture,
31 useServerPictureInPictureKey,
32} from "~/browser/serverPictureInPicture";
33import { Button } from "~/components/ui/button";
34import { toastManager } from "~/components/ui/toast";
35import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
36import { cn } from "~/lib/utils";
37import { useThreadPreviewState } from "~/previewStateStore";
38import {
39 type PreviewMiniPlayerSize,
40 type PreviewMiniPlayerSource,
41 type PreviewMiniPlayerState,
42 previewMiniPlayerSourceKey,
43 usePreviewMiniPlayerStore,
44} from "~/previewMiniPlayerStore";
45import { useRightPanelStore } from "~/rightPanelStore";
46import { useDeviceState } from "~/state/device";
47
48import { DeviceStreamView } from "../device/DeviceStreamView";
49import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
50import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream";
51import { previewBridge } from "./previewBridge";
52import { showPreviewPopup } from "./showPreviewPopup";
53import {
54 clampPreviewMiniPlayerPosition,
55 NO_PREVIEW_MINI_PLAYER_OBSTACLES,
56 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
57 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
58 type PreviewMiniPlayerFrame,
59 resizePreviewMiniPlayer,
60 resolveDeviceMiniPlayerCornerRadius,
61 resolveDeviceMiniPlayerSourceSize,
62 resolvePreviewMiniPlayerSourceSize,
63} from "./previewMiniPlayerLayout";
64
65interface PointerGesture {
66 readonly target: HTMLElement;
67 readonly pointerId: number;
68 readonly pointerType: string;
69 readonly pointerX: number;
70 readonly pointerY: number;
71 readonly frame: PreviewMiniPlayerFrame;
72 readonly direction: BrowserViewportResizeDirection | null;
73 moved: boolean;
74}
75
76// A touch that travels less than this is a tap on the handle, not a drag.
77const HANDLE_TAP_SLOP_PX = 6;
78
79interface Props {
80 readonly threadRef: ScopedThreadRef;
81 readonly miniPlayer: PreviewMiniPlayerState;
82}
83
84const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
85
86const RESIZE_HANDLES: ReadonlyArray<{
87 readonly direction: BrowserViewportResizeDirection;
88 readonly className: string;
89}> = [
90 { direction: "north", className: "inset-x-0 -top-1.5 h-3 cursor-ns-resize" },
91 { direction: "south", className: "inset-x-0 -bottom-1.5 h-3 cursor-ns-resize" },
92 { direction: "west", className: "inset-y-0 -left-1.5 w-3 cursor-ew-resize" },
93 { direction: "east", className: "inset-y-0 -right-1.5 w-3 cursor-ew-resize" },
94 { direction: "northwest", className: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize" },
95 { direction: "northeast", className: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize" },
96 { direction: "southwest", className: "-bottom-2.5 -left-2.5 size-5 cursor-nesw-resize" },
97 { direction: "southeast", className: "-bottom-2.5 -right-2.5 size-5 cursor-nwse-resize" },
98];
99
100/** Floats the thread's browser tab or device stream over chat. */
101export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) {
102 const { source } = miniPlayer;
103 return source.kind === "browser" ? (
104 <BrowserMiniPlayer
105 key={source.tabId}
106 threadRef={threadRef}
107 tabId={source.tabId}
108 miniPlayer={miniPlayer}
109 />
110 ) : (
111 <DeviceMiniPlayer
112 key={previewMiniPlayerSourceKey(source)}
113 threadRef={threadRef}
114 source={source}
115 miniPlayer={miniPlayer}
116 />
117 );
118}
119
120function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) {
121 const previewState = useThreadPreviewState(threadRef);
122 const snapshot = previewState.sessions[tabId] ?? null;
123 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);
124 const recordingTabIds = useActiveBrowserRecordingTabIds();
125 const recording =
126 recordingTabIds.has(runtimeTabId) ||
127 findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null;
128 const desktopOverlay = previewState.desktopByTabId[tabId] ?? null;
129 const fittedSourceContent = useBrowserSurfaceStore(
130 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,
131 );
132 const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot);
133 const serverTab = snapshot?.runtime === "server" && !nativeServerTab;
134 const [streamViewport, setStreamViewport] = useState<PreviewStreamViewport | null>(null);
135 const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null);
136 const serverPictureInPicture =
137 useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId);
138 const sourceSize =
139 serverTab && streamViewport
140 ? streamViewport
141 : resolvePreviewMiniPlayerSourceSize(
142 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,
143 fittedSourceContent,
144 desktopOverlay?.zoomFactor ?? 1,
145 );
146
147 const openInPanel = () => {
148 usePreviewMiniPlayerStore.getState().close(threadRef);
149 useRightPanelStore.getState().openBrowser(threadRef, tabId);
150 };
151
152 const toggleNativePictureInPicture = async () => {
153 try {
154 if (serverTab) {
155 if (serverPictureInPicture) closeServerPictureInPicture();
156 else
157 await openServerPictureInPicture({
158 ...threadRef,
159 tabId,
160 seed: serverSurfaceRef.current?.canvas() ?? null,
161 });
162 } else if (previewBridge) {
163 const operation = desktopOverlay?.pictureInPicture
164 ? previewBridge.pictureInPicture.close
165 : previewBridge.pictureInPicture.open;
166 await operation(runtimeTabId);
167 }
168 } catch (error) {
169 toastManager.add({
170 type: "error",
171 title: serverTab ? "Unable to pop out preview" : "Unable to update popped-out preview",
172 description: error instanceof Error ? error.message : "An error occurred.",
173 });
174 }
175 };
176
177 if (!snapshot) return null;
178 const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture);
179 const canPopOut = serverTab ? supportsServerPictureInPicture() : true;
180
181 return (
182 <MiniPlayerShell
183 threadRef={threadRef}
184 miniPlayer={miniPlayer}
185 sourceSize={sourceSize}
186 label="Floating browser preview"
187 recording={recording}
188 onOpenInPanel={openInPanel}
189 pillActions={
190 canPopOut ? (
191 <Tooltip>
192 <TooltipTrigger
193 render={
194 <Button
195 variant={poppedOut ? "secondary" : "ghost"}
196 size="icon-xs"
197 aria-label={
198 poppedOut ? "Close popped-out preview" : "Pop preview into separate window"
199 }
200 disabled={!serverTab && !desktopOverlay?.hasWebContents}
201 onPointerDown={(event) => event.stopPropagation()}
202 onClick={toggleNativePictureInPicture}
203 />
204 }
205 >
206 <PictureInPicture2 />
207 </TooltipTrigger>
208 <TooltipPopup side="top">
209 {poppedOut ? "Close separate window" : "Pop into separate window"}
210 </TooltipPopup>
211 </Tooltip>
212 ) : null
213 }
214 >
215 {(frame) =>
216 serverTab ? (
217 <div
218 className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]"
219 style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }}
220 >
221 <ServerBrowserSurface
222 ref={serverSurfaceRef}
223 environmentId={threadRef.environmentId}
224 threadId={threadRef.threadId}
225 tabId={tabId}
226 visible
227 followSize={false}
228 controlPosition="bottom"
229 onViewport={setStreamViewport}
230 onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")}
231 className="size-full"
232 />
233 </div>
234 ) : (
235 <>
236 <BrowserSurfaceSlot
237 tabId={runtimeTabId}
238 visible={Boolean(desktopOverlay?.hasWebContents)}
239 cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS}
240 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
241 fitSourceContent
242 layoutVersion={`${frame.x}:${frame.y}`}
243 className="absolute inset-0"
244 />
245 {!desktopOverlay?.hasWebContents ? (
246 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-[inherit] bg-muted text-xs text-muted-foreground">
247 Reconnecting preview…
248 </div>
249 ) : null}
250 </>
251 )
252 }
253 </MiniPlayerShell>
254 );
255}
256
257function DeviceMiniPlayer({
258 threadRef,
259 source,
260 miniPlayer,
261}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {
262 const { state: deviceState } = useDeviceState(threadRef.environmentId);
263 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
264 const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen);
265 const device = deviceState.devices.find(
266 (entry) => entry.hostId === source.hostId && entry.id === source.deviceId,
267 );
268 const hostLabel =
269 deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host";
270 const cornerRadius = useCallback(
271 (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player),
272 [source.platform],
273 );
274
275 const openInPanel = () => {
276 usePreviewMiniPlayerStore.getState().close(threadRef);
277 useRightPanelStore.getState().openDevice(threadRef, {
278 hostId: source.hostId,
279 deviceId: source.deviceId,
280 platform: source.platform,
281 name: source.name,
282 });
283 };
284
285 return (
286 <MiniPlayerShell
287 threadRef={threadRef}
288 miniPlayer={miniPlayer}
289 sourceSize={sourceSize}
290 label="Floating device preview"
291 onOpenInPanel={openInPanel}
292 cornerRadius={cornerRadius}
293 >
294 {() => (
295 // The stream is DOM, so it takes the band the browser's native webview would.
296 <div
297 className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]"
298 style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }}
299 >
300 <DeviceStreamView
301 environmentId={threadRef.environmentId}
302 platform={source.platform}
303 deviceId={source.deviceId}
304 hostId={source.hostId}
305 deviceName={device?.name ?? source.name}
306 deviceDescription={`${hostLabel} · ${device?.version ?? source.platform}`}
307 visible
308 onScreen={setScreen}
309 />
310 </div>
311 )}
312 </MiniPlayerShell>
313 );
314}
315
316/**
317 * The frame, drag/resize gestures, and hover pill shared by every floating
318 * source. Native clipping and the DOM frame use the same radius so their
319 * separately composited edges stay aligned.
320 */
321function MiniPlayerShell({
322 threadRef,
323 miniPlayer,
324 sourceSize,
325 label,
326 onOpenInPanel,
327 pillActions,
328 recording = false,
329 cornerRadius = frameCornerRadius,
330 children,
331}: {
332 readonly threadRef: ScopedThreadRef;
333 readonly miniPlayer: PreviewMiniPlayerState;
334 readonly sourceSize: PreviewMiniPlayerSize;
335 readonly label: string;
336 readonly onOpenInPanel: () => void;
337 readonly pillActions?: ReactNode;
338 readonly recording?: boolean;
339 /** The clip radius for a given frame; the pill stays inside the curve. */
340 readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number;
341 readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode;
342}) {
343 const canvas = useChatCanvas();
344 const gestureRef = useRef<PointerGesture | null>(null);
345 // Touch has no hover, so tapping the handle toggles the pill instead. The
346 // toggle waits for the tap's click, which would otherwise land on the pill.
347 const [pillOpen, setPillOpen] = useState(false);
348 const handleTappedRef = useRef(false);
349 const handleRef = useRef<HTMLDivElement | null>(null);
350 // The pill covers the handle, so a tap anywhere else dismisses it.
351 useEffect(() => {
352 if (!pillOpen) return;
353 const dismiss = (event: PointerEvent) => {
354 if (event.target instanceof Node && handleRef.current?.contains(event.target)) return;
355 setPillOpen(false);
356 };
357 document.addEventListener("pointerdown", dismiss, true);
358 return () => document.removeEventListener("pointerdown", dismiss, true);
359 }, [pillOpen]);
360 const container = canvas?.container ?? null;
361 const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES;
362 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
363 const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null;
364 const { width: sourceWidth, height: sourceHeight } = sourceSize;
365 const reportPreview = canvas?.reportPreview;
366 const clearPreview = canvas?.clearPreview;
367 useLayoutEffect(() => {
368 reportPreview?.({
369 key: sourceKey,
370 width: miniPlayer.width,
371 position: miniPlayer.position,
372 lastInteraction: miniPlayer.lastInteraction,
373 source: { width: sourceWidth, height: sourceHeight },
374 });
375 }, [
376 reportPreview,
377 sourceKey,
378 miniPlayer.width,
379 miniPlayer.position,
380 miniPlayer.lastInteraction,
381 sourceWidth,
382 sourceHeight,
383 ]);
384 useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]);
385
386 const releaseGesture = useCallback(() => {
387 const gesture = gestureRef.current;
388 gestureRef.current = null;
389 handleTappedRef.current = false;
390 if (gesture?.target.hasPointerCapture(gesture.pointerId)) {
391 gesture.target.releasePointerCapture(gesture.pointerId);
392 }
393 }, []);
394
395 useEffect(() => {
396 const onBlur = () => releaseGesture();
397 window.addEventListener("blur", onBlur);
398 return () => {
399 window.removeEventListener("blur", onBlur);
400 releaseGesture();
401 };
402 }, [releaseGesture, sourceKey, threadRef.environmentId, threadRef.threadId]);
403
404 const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS;
405 // Inside a wide curve the default 8px inset would land on the clipped-away corner.
406 const pillInset = Math.max(8, Math.round(radius * 0.55));
407
408 const close = () => {
409 usePreviewMiniPlayerStore.getState().close(threadRef);
410 };
411
412 const beginGesture = (
413 event: ReactPointerEvent<HTMLElement>,
414 direction: BrowserViewportResizeDirection | null,
415 ) => {
416 if (event.button !== 0 || !frame || gestureRef.current || event.target !== event.currentTarget)
417 return;
418 gestureRef.current = {
419 target: event.currentTarget,
420 pointerId: event.pointerId,
421 pointerType: event.pointerType,
422 pointerX: event.clientX,
423 pointerY: event.clientY,
424 frame,
425 direction,
426 moved: false,
427 };
428 try {
429 event.currentTarget.setPointerCapture(event.pointerId);
430 } catch {
431 gestureRef.current = null;
432 return;
433 }
434 event.preventDefault();
435 event.stopPropagation();
436 };
437
438 const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
439 const gesture = gestureRef.current;
440 if (!gesture || gesture.pointerId !== event.pointerId || !container) return;
441 const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY };
442 if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return;
443 gesture.moved = true;
444 const store = usePreviewMiniPlayerStore.getState();
445 if (gesture.direction === null) {
446 store.move(
447 threadRef,
448 sourceKey,
449 clampPreviewMiniPlayerPosition(
450 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
451 container,
452 gesture.frame,
453 obstacles,
454 ),
455 );
456 return;
457 }
458 const next = resizePreviewMiniPlayer({
459 start: gesture.frame,
460 direction: gesture.direction,
461 delta,
462 source: sourceSize,
463 container,
464 obstacles,
465 });
466 store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y });
467 };
468
469 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
470 const gesture = gestureRef.current;
471 if (gesture?.pointerId !== event.pointerId) return;
472 if (event.type === "pointerup") handlePointerMove(event);
473 releaseGesture();
474 handleTappedRef.current =
475 event.type === "pointerup" &&
476 gesture.direction === null &&
477 gesture.pointerType === "touch" &&
478 !gesture.moved;
479 };
480
481 return (
482 <div className="pointer-events-none absolute inset-0">
483 {frame ? (
484 <section
485 aria-label={label}
486 data-preview-mini-player={sourceKey}
487 className="pointer-events-none absolute select-none"
488 style={{
489 left: frame.x,
490 top: frame.y,
491 width: frame.width,
492 height: frame.height,
493 borderRadius: radius,
494 }}
495 >
496 <div
497 ref={handleRef}
498 data-pill-open={pillOpen ? "" : undefined}
499 className="group pointer-events-auto absolute z-[49] size-3 touch-none cursor-grab active:cursor-grabbing pointer-coarse:-m-2.5 pointer-coarse:size-8"
500 style={{ right: pillInset, top: pillInset }}
501 onPointerDown={(event) => beginGesture(event, null)}
502 onPointerMove={handlePointerMove}
503 onPointerUp={endGesture}
504 onPointerCancel={endGesture}
505 onLostPointerCapture={endGesture}
506 onClick={() => {
507 if (!handleTappedRef.current) return;
508 handleTappedRef.current = false;
509 setPillOpen((open) => !open);
510 }}
511 >
512 <div
513 role={recording ? "status" : undefined}
514 aria-label={recording ? "Recording preview" : undefined}
515 aria-hidden={!recording}
516 className="absolute right-0 top-0 size-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0 group-data-pill-open:opacity-0 pointer-coarse:right-2.5 pointer-coarse:top-2.5"
517 >
518 <span
519 className={cn(
520 "block size-2 rounded-full shadow-sm ring-1 ring-background/70",
521 recording
522 ? "bg-destructive motion-safe:animate-status-pulse"
523 : "bg-foreground/25",
524 )}
525 />
526 </div>
527 <div className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-data-pill-open:pointer-events-auto group-data-pill-open:opacity-100 active:cursor-grabbing pointer-coarse:right-2.5 pointer-coarse:top-2.5">
528 {recording ? (
529 <span aria-hidden className="flex size-6 shrink-0 items-center justify-center">
530 <span className="size-2 rounded-full bg-destructive motion-safe:animate-status-pulse" />
531 </span>
532 ) : null}
533 <Tooltip>
534 <TooltipTrigger
535 render={
536 <Button
537 variant="ghost"
538 size="icon-xs"
539 aria-label="Open preview in right panel"
540 onPointerDown={(event) => event.stopPropagation()}
541 onClick={onOpenInPanel}
542 />
543 }
544 >
545 <PanelRightIcon />
546 </TooltipTrigger>
547 <TooltipPopup side="top">Open in right panel</TooltipPopup>
548 </Tooltip>
549 {pillActions}
550 <Tooltip>
551 <TooltipTrigger
552 render={
553 <Button
554 variant="ghost"
555 size="icon-xs"
556 aria-label="Close floating preview"
557 onPointerDown={(event) => event.stopPropagation()}
558 onClick={close}
559 />
560 }
561 >
562 <XIcon />
563 </TooltipTrigger>
564 <TooltipPopup side="top">Close floating preview</TooltipPopup>
565 </Tooltip>
566 </div>
567 </div>
568
569 <div className="absolute inset-0 z-[47] rounded-[inherit] bg-muted shadow-2xl/35" />
570 {children(frame)}
571 <div className="pointer-events-none absolute inset-0 z-[49] rounded-[inherit] ring-1 ring-inset ring-border/80" />
572 {RESIZE_HANDLES.map(({ direction, className }) => (
573 <div
574 key={direction}
575 role="button"
576 tabIndex={0}
577 aria-label={`Resize floating preview from ${direction}`}
578 data-preview-mini-player-resize={direction}
579 className={cn(
580 "pointer-events-auto absolute z-[49] touch-none outline-none hover:bg-primary/15 focus-visible:bg-primary/20",
581 className,
582 )}
583 onPointerDown={(event) => beginGesture(event, direction)}
584 onPointerMove={handlePointerMove}
585 onPointerUp={endGesture}
586 onPointerCancel={endGesture}
587 onLostPointerCapture={endGesture}
588 onKeyDown={(event) => {
589 if (!container) return;
590 const step = event.shiftKey ? 40 : 10;
591 const delta =
592 event.key === "ArrowLeft"
593 ? { x: -step, y: 0 }
594 : event.key === "ArrowRight"
595 ? { x: step, y: 0 }
596 : event.key === "ArrowUp"
597 ? { x: 0, y: -step }
598 : event.key === "ArrowDown"
599 ? { x: 0, y: step }
600 : null;
601 if (!delta) return;
602 event.preventDefault();
603 const next = resizePreviewMiniPlayer({
604 start: frame,
605 direction,
606 delta,
607 source: sourceSize,
608 container,
609 obstacles,
610 });
611 usePreviewMiniPlayerStore
612 .getState()
613 .resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y });
614 }}
615 />
616 ))}
617 </section>
618 ) : null}
619 </div>
620 );
621}