diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx
new file mode 100644
index 000000000000..9bb9e746ea12
--- /dev/null
+++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx
@@ -0,0 +1,121 @@
+import "~/index.css";
+
+import { RegistryContext } from "@effect/atom-react";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { type DeviceServiceState, type EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { AsyncResult, Atom } from "effect/reactivity";
+import { useState } from "react";
+import { expect, it, vi } from "vite-plus/test";
+
+import { act, render } from "../../../test/render";
+import {
+ type PreviewMiniPlayerSource,
+ selectThreadPreviewMiniPlayer,
+ usePreviewMiniPlayerStore,
+} from "../../previewMiniPlayerStore";
+import { appAtomRegistry } from "../../rpc/atomRegistry";
+import { deviceEnvironment } from "../../state/device";
+import { ChatCanvas } from "../chat/ChatCanvas";
+import { createChatCanvasStore } from "../chat/canvasStore";
+import { deviceStreamScreenAtom } from "../device/deviceStreamSession";
+import { ThreadPreviewMiniPlayer, useThreadPreviewCanvasSource } from "./ThreadPreviewMiniPlayer";
+
+vi.mock(import("../device/DeviceStreamView"), () => ({
+ DeviceStreamView: (({ viewId }: { viewId: string }) => (
+
+ )) as never,
+}));
+
+const threadRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1"));
+const phone: PreviewMiniPlayerSource = {
+ kind: "device",
+ hostId: "local",
+ deviceId: "phone",
+ platform: "ios",
+ name: "Phone",
+};
+
+function Harness() {
+ const [store] = useState(createChatCanvasStore);
+ const miniPlayer = usePreviewMiniPlayerStore((state) =>
+ selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
+ );
+ const preview = useThreadPreviewCanvasSource(threadRef, miniPlayer);
+ return (
+
+ {miniPlayer ? (
+
+ ) : null}
+
+ );
+}
+
+it("holds its frame through a source change and stops when the gesture is interrupted", async () => {
+ using _state = vi
+ .spyOn(deviceEnvironment, "state")
+ .mockReturnValue(Atom.make>(AsyncResult.initial()));
+ using _store = {
+ [Symbol.dispose]: () => usePreviewMiniPlayerStore.getState().removeThread(threadRef),
+ };
+ usePreviewMiniPlayerStore.getState().open(threadRef, phone);
+ const container = document.createElement("div");
+ container.style.cssText = "display:flex;position:fixed;inset:0;width:1200px;height:800px";
+ await using screen = await render(
+
+
+ ,
+ { container },
+ );
+ const player = screen.container.querySelector("[data-preview-mini-player]")!;
+ const handle = (direction: string) =>
+ player.querySelector(`[data-preview-mini-player-resize="${direction}"]`)!;
+ const rect = () => {
+ const { left, top, right, bottom } = player.getBoundingClientRect();
+ return { left, top, right, bottom };
+ };
+ const point = (target: HTMLElement, type: string, x: number, y: number, buttons: number) =>
+ act(() =>
+ target.dispatchEvent(
+ new PointerEvent(type, {
+ pointerId: 1,
+ pointerType: "mouse",
+ isPrimary: true,
+ button: type === "pointermove" ? -1 : 0,
+ buttons,
+ clientX: x,
+ clientY: y,
+ bubbles: true,
+ cancelable: true,
+ }),
+ ),
+ );
+ const viewId = screen.getByTestId("stream").element().getAttribute("data-view-id")!;
+ const portrait = rect();
+
+ // Held without moving while the device rotates: the frame waits for the release.
+ await point(handle("northwest"), "pointerdown", portrait.left, portrait.top, 1);
+ await act(() =>
+ appAtomRegistry.set(deviceStreamScreenAtom(viewId), {
+ width: 1_000,
+ height: 2_000,
+ orientation: "landscape_left",
+ }),
+ );
+ expect(rect()).toEqual(portrait);
+
+ // The release never arrived, so the next buttonless move ends the gesture where it was held.
+ await point(handle("northwest"), "pointermove", portrait.left - 200, portrait.top - 200, 0);
+ const landscape = rect();
+ expect({ right: landscape.right, bottom: landscape.bottom }).toEqual({
+ right: portrait.right,
+ bottom: portrait.bottom,
+ });
+ expect(landscape.right - landscape.left).toBe(2 * (landscape.bottom - landscape.top));
+ await point(handle("northwest"), "pointermove", portrait.left - 300, portrait.top - 300, 1);
+ expect(rect()).toEqual(landscape);
+
+ await point(handle("west"), "pointerdown", landscape.left, landscape.top + 10, 1);
+ await act(() => window.dispatchEvent(new Event("blur")));
+ await point(handle("west"), "pointermove", landscape.left - 100, landscape.top + 10, 1);
+ expect(rect()).toEqual(landscape);
+});
diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
index 8e6c8fc42068..3e4dba8d879c 100644
--- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
+++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
@@ -6,6 +6,7 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
+ useEffectEvent,
useRef,
useState,
} from "react";
@@ -59,28 +60,19 @@ import { previewBridge } from "./previewBridge";
import { PreviewUnreachable } from "./PreviewUnreachable";
import { showPreviewPopup } from "./showPreviewPopup";
import {
- clampPreviewMiniPlayerPosition,
+ type PreviewMiniPlayerGesture,
+ settlePreviewMiniPlayerResize,
+ stepPreviewMiniPlayerGesture,
+} from "./previewMiniPlayerGesture";
+import {
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;
@@ -111,6 +103,13 @@ const useStreamedSourceSize = create<{
readonly viewport: PreviewStreamViewport;
} | null>(() => null);
+// The source size a resize in progress lays the frame out from, so a source that changes shape
+// under a held handle cannot resize the player by itself. Same single slot as above.
+const useHeldSourceSize = create<{
+ readonly key: string;
+ readonly source: PreviewMiniPlayerSize;
+} | null>(() => null);
+
const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe(
Atom.withLabel("device-stream-screen:none"),
);
@@ -178,6 +177,11 @@ export function useThreadPreviewCanvasSource(
? deviceStreamScreenAtom(miniPlayerDeviceViewId(threadRef, source))
: NO_DEVICE_SCREEN_ATOM,
);
+ const heldSource = useHeldSourceSize((state) =>
+ threadRef && sourceKey !== null && state?.key === streamedSourceKey(threadRef, sourceKey)
+ ? state.source
+ : null,
+ );
if (!miniPlayer || !source || sourceKey === null) return null;
if (source.kind === "browser" && !browser.snapshot) return null;
return {
@@ -186,9 +190,10 @@ export function useThreadPreviewCanvasSource(
position: miniPlayer.position,
lastInteraction: miniPlayer.lastInteraction,
source:
- source.kind === "browser"
+ heldSource ??
+ (source.kind === "browser"
? browser.sourceSize
- : resolveDeviceMiniPlayerSourceSize(source.platform, screen),
+ : resolveDeviceMiniPlayerSourceSize(source.platform, screen)),
};
}
@@ -446,7 +451,8 @@ function MiniPlayerShell({
readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode;
}) {
const canvas = useChatCanvas();
- const gestureRef = useRef(null);
+ const gestureRef = useRef(null);
+ const captureRef = 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);
@@ -463,6 +469,7 @@ function MiniPlayerShell({
}, [pillOpen]);
const container = canvas?.container ?? null;
const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
+ const heldSourceKey = streamedSourceKey(threadRef, sourceKey);
const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null;
const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS;
@@ -472,11 +479,54 @@ function MiniPlayerShell({
usePreviewMiniPlayerStore.getState().close(threadRef);
};
+ /** Every way a gesture stops comes through here; returns the gesture it ended. */
+ const finishGesture = () => {
+ const gesture = gestureRef.current;
+ if (!gesture) return null;
+ gestureRef.current = null;
+ handleTappedRef.current = false;
+ const handle = captureRef.current;
+ captureRef.current = null;
+ if (handle?.hasPointerCapture(gesture.pointerId)) {
+ handle.releasePointerCapture(gesture.pointerId);
+ }
+ if (gesture.direction === null) return gesture;
+ useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true);
+ const settled =
+ frame && container
+ ? settlePreviewMiniPlayerResize({
+ frame,
+ direction: gesture.direction,
+ held: gesture.source,
+ source: sourceSize,
+ container,
+ })
+ : null;
+ if (settled) {
+ usePreviewMiniPlayerStore
+ .getState()
+ .resize(threadRef, sourceKey, settled.width, { x: settled.x, y: settled.y });
+ }
+ return gesture;
+ };
+ const endInterruptedGesture = useEffectEvent(() => {
+ finishGesture();
+ });
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window blur ends a gesture whose release went to another window, and unmount ends one in progress
+ useEffect(() => {
+ window.addEventListener("blur", endInterruptedGesture);
+ return () => {
+ window.removeEventListener("blur", endInterruptedGesture);
+ useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true);
+ };
+ }, [heldSourceKey]);
+
const beginGesture = (
event: ReactPointerEvent,
direction: BrowserViewportResizeDirection | null,
) => {
if (event.button !== 0 || !frame) return;
+ finishGesture();
gestureRef.current = {
pointerId: event.pointerId,
pointerType: event.pointerType,
@@ -484,8 +534,13 @@ function MiniPlayerShell({
pointerY: event.clientY,
frame,
direction,
+ source: sourceSize,
moved: false,
};
+ captureRef.current = event.currentTarget;
+ if (direction !== null) {
+ useHeldSourceSize.setState({ key: heldSourceKey, source: sourceSize }, true);
+ }
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
@@ -493,44 +548,39 @@ function MiniPlayerShell({
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;
+ if (!gesture || !container) return;
+ const step = stepPreviewMiniPlayerGesture(gesture, event, container);
+ if (step.kind === "ignore") return;
+ if (step.kind === "end") {
+ finishGesture();
+ return;
+ }
+ if (!gesture.moved) gestureRef.current = { ...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,
- ),
- );
+ if (step.kind === "move") {
+ store.move(threadRef, sourceKey, step.position);
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 });
+ store.resize(threadRef, sourceKey, step.frame.width, { x: step.frame.x, y: step.frame.y });
};
const endGesture = (event: ReactPointerEvent) => {
- const gesture = gestureRef.current;
- if (gesture?.pointerId !== event.pointerId) return;
- gestureRef.current = null;
+ if (gestureRef.current?.pointerId !== event.pointerId) return;
+ const gesture = finishGesture();
handleTappedRef.current =
event.type === "pointerup" &&
- gesture.direction === null &&
+ gesture?.direction === null &&
gesture.pointerType === "touch" &&
!gesture.moved;
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
- event.currentTarget.releasePointerCapture(event.pointerId);
+ };
+
+ // Capture can go without a release or cancel: the handle is replaced, or the platform takes it.
+ const handleLostCapture = (event: ReactPointerEvent) => {
+ if (
+ gestureRef.current?.pointerId === event.pointerId &&
+ captureRef.current === event.currentTarget
+ ) {
+ finishGesture();
}
};
@@ -558,6 +608,7 @@ function MiniPlayerShell({
onPointerMove={handlePointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
+ onLostPointerCapture={handleLostCapture}
onClick={() => {
if (!handleTappedRef.current) return;
handleTappedRef.current = false;
@@ -634,6 +685,7 @@ function MiniPlayerShell({
onPointerMove={handlePointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
+ onLostPointerCapture={handleLostCapture}
/>
))}
diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts
new file mode 100644
index 000000000000..1d9f9035ebf2
--- /dev/null
+++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveChatCanvasLayout } from "../chat/chatCanvasLayout";
+import {
+ type PreviewMiniPlayerGesture,
+ settlePreviewMiniPlayerResize,
+ stepPreviewMiniPlayerGesture,
+} from "./previewMiniPlayerGesture";
+
+const container = { width: 1_440, height: 900 };
+const frame = { x: 996, y: 433, width: 268, height: 179 };
+const gesture: PreviewMiniPlayerGesture = {
+ pointerId: 1,
+ pointerType: "mouse",
+ pointerX: 996,
+ pointerY: 433,
+ frame,
+ direction: "northwest",
+ source: { width: 1_200, height: 800 },
+ moved: true,
+};
+const pointer = { pointerId: 1, buttons: 1, clientX: 936, clientY: 393 };
+
+describe("stepPreviewMiniPlayerGesture", () => {
+ it("ends on a move without the primary button instead of following the hover", () => {
+ expect(stepPreviewMiniPlayerGesture(gesture, pointer, container).kind).toBe("resize");
+ expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 0 }, container)).toEqual({
+ kind: "end",
+ });
+ expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 2 }, container)).toEqual({
+ kind: "end",
+ });
+ expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, pointerId: 2 }, container)).toEqual({
+ kind: "ignore",
+ });
+ });
+
+ it("resizes at the shape the gesture started with", () => {
+ const step = stepPreviewMiniPlayerGesture(gesture, pointer, container);
+ expect(step).toEqual({ kind: "resize", frame: { x: 936, y: 393, width: 328, height: 219 } });
+ });
+});
+
+describe("settlePreviewMiniPlayerResize", () => {
+ it("keeps the corner opposite the handle when the source changed shape mid-resize", () => {
+ const source = { width: 1_200, height: 1_000 };
+ const settled = settlePreviewMiniPlayerResize({
+ frame,
+ direction: "northwest",
+ held: gesture.source,
+ source,
+ container,
+ });
+ expect(settled).toEqual({ x: 1_018, y: 407, width: 246, height: 205 });
+ expect(
+ resolveChatCanvasLayout({
+ container,
+ preview: {
+ key: "browser:tab",
+ width: settled!.width,
+ position: settled,
+ source,
+ lastInteraction: "resize",
+ },
+ }).frame,
+ ).toEqual(settled);
+ expect(
+ settlePreviewMiniPlayerResize({
+ frame,
+ direction: "northwest",
+ held: gesture.source,
+ source: { width: 600, height: 400 },
+ container,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts
new file mode 100644
index 000000000000..6c5892eab73f
--- /dev/null
+++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts
@@ -0,0 +1,93 @@
+import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
+import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
+
+import {
+ clampPreviewMiniPlayerPosition,
+ type PreviewMiniPlayerFrame,
+ resizePreviewMiniPlayer,
+} from "./previewMiniPlayerLayout";
+
+const HANDLE_TAP_SLOP_PX = 6;
+
+export interface PreviewMiniPlayerGesture {
+ readonly pointerId: number;
+ readonly pointerType: string;
+ readonly pointerX: number;
+ readonly pointerY: number;
+ readonly frame: PreviewMiniPlayerFrame;
+ /** Null drags the player; a direction resizes it from that handle. */
+ readonly direction: BrowserViewportResizeDirection | null;
+ /** The source size a resize keeps the frame's shape to until it ends. */
+ readonly source: PreviewMiniPlayerSize;
+ readonly moved: boolean;
+}
+
+interface GesturePointer {
+ readonly pointerId: number;
+ readonly buttons: number;
+ readonly clientX: number;
+ readonly clientY: number;
+}
+
+export type PreviewMiniPlayerGestureStep =
+ | { readonly kind: "ignore" }
+ | { readonly kind: "end" }
+ | { readonly kind: "move"; readonly position: PreviewMiniPlayerPosition }
+ | { readonly kind: "resize"; readonly frame: PreviewMiniPlayerFrame };
+
+/** What a pointer move does to the gesture in progress. */
+export function stepPreviewMiniPlayerGesture(
+ gesture: PreviewMiniPlayerGesture,
+ pointer: GesturePointer,
+ container: PreviewMiniPlayerSize,
+): PreviewMiniPlayerGestureStep {
+ if (pointer.pointerId !== gesture.pointerId) return { kind: "ignore" };
+ // A release the handle never saw (over a native view, another window) leaves only this trace.
+ if ((pointer.buttons & 1) === 0) return { kind: "end" };
+ const delta = { x: pointer.clientX - gesture.pointerX, y: pointer.clientY - gesture.pointerY };
+ if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) {
+ return { kind: "ignore" };
+ }
+ if (gesture.direction === null) {
+ return {
+ kind: "move",
+ position: clampPreviewMiniPlayerPosition(
+ { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
+ container,
+ gesture.frame,
+ ),
+ };
+ }
+ return {
+ kind: "resize",
+ frame: resizePreviewMiniPlayer({
+ start: gesture.frame,
+ direction: gesture.direction,
+ delta,
+ source: gesture.source,
+ container,
+ }),
+ };
+}
+
+/**
+ * The frame a finished resize leaves when the source changed shape under it: the new shape, with
+ * the edges opposite the handle where the gesture left them. Null when the shape is unchanged.
+ */
+export function settlePreviewMiniPlayerResize(input: {
+ readonly frame: PreviewMiniPlayerFrame;
+ readonly direction: BrowserViewportResizeDirection;
+ readonly held: PreviewMiniPlayerSize;
+ readonly source: PreviewMiniPlayerSize;
+ readonly container: PreviewMiniPlayerSize;
+}): PreviewMiniPlayerFrame | null {
+ const { frame, direction, held, source, container } = input;
+ if (held.width * source.height === source.width * held.height) return null;
+ return resizePreviewMiniPlayer({
+ start: frame,
+ direction,
+ delta: { x: 0, y: 0 },
+ source,
+ container,
+ });
+}