diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index b875e4c221ef..6ba9994ba6ff 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -28,7 +28,9 @@ export function BrowserSurfaceSlot(props: { useLayoutEffect(() => { const element = elementRef.current; - if (!element) return; + // A closing panel can remain mounted for its animation, but must hand the + // browser to the floating player as soon as it stops being visible. + if (!element || !visible) return; let lease = acquireBrowserSurface(tabId, fitSourceContent); const update = () => { const rect = element.getBoundingClientRect(); @@ -77,7 +79,7 @@ export function BrowserSurfaceSlot(props: { if (updateRef.current === update) updateRef.current = null; lease.release(); }; - }, [fitSourceContent, tabId]); + }, [fitSourceContent, tabId, visible]); useLayoutEffect(() => { presentationRef.current = { visible, cornerRadius, zIndex }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3b08fa7e5597..ecc290f0c2ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2322,8 +2322,8 @@ export default function ChatView(props: ChatViewProps) { : undefined, [activeThreadRef, activePreviewServerEpoch], ); - const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => - selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), + const activePreviewMiniPlayerSource = usePreviewMiniPlayerStore( + (state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef)?.source ?? null, ); const panelTerminalIds = useMemo( () => @@ -2381,8 +2381,8 @@ export default function ChatView(props: ChatViewProps) { const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, + activePreviewMiniPlayerSource ?? null, + rightPanelOpen ? renderedRightPanelSurface : null, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = @@ -2416,7 +2416,7 @@ export default function ChatView(props: ChatViewProps) { }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]); useEffect(() => { - const source = activePreviewMiniPlayer?.source; + const source = activePreviewMiniPlayerSource; if ( activeThreadRef && previewSessionsReady && @@ -2425,7 +2425,12 @@ export default function ChatView(props: ChatViewProps) { ) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); + }, [ + activePreviewMiniPlayerSource, + activePreviewState.sessions, + activeThreadRef, + previewSessionsReady, + ]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -5534,7 +5539,7 @@ export default function ChatView(props: ChatViewProps) { // closes the device there is nothing left to stream. useEffect(() => { if (!activeThreadRef || !deviceStateLoaded) return; - const source = activePreviewMiniPlayer?.source; + const source = activePreviewMiniPlayerSource; if (source?.kind !== "device") return; const sessionStillExists = deviceState.sessions.some( (session) => @@ -5543,7 +5548,7 @@ export default function ChatView(props: ChatViewProps) { session.deviceId === source.deviceId, ); if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); - }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); + }, [activePreviewMiniPlayerSource, activeThreadRef, deviceState.sessions, deviceStateLoaded]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -11220,7 +11225,6 @@ export default function ChatView(props: ChatViewProps) {
{/* Chat column */} ) : null} {activeThreadRef && - activePreviewMiniPlayer && + activePreviewMiniPlayerSource && previewMiniPlayerVisible && - (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( + (activePreviewMiniPlayerSource.kind === "device" || canOperatePreview) ? ( ) : null} diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 504db1730489..07816199e7be 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -8,26 +8,21 @@ import { type CSSProperties, } from "react"; import { ChatCanvasContext } from "./ChatCanvasContext"; -import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; -import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; +import { resolveChatCanvasLayout, type ChatCanvasDetailsCard } from "./chatCanvasLayout"; /** * Owns the available conversation space. Cards only report where they sit; the * canvas decides when chat moves over to make room for them. */ export function ChatCanvas({ - composerOverlayElement, children, ...props -}: Omit, "className" | "style" | "ref"> & { - composerOverlayElement: HTMLElement | null; -}) { +}: Omit, "className" | "style" | "ref">) { const elementRef = useRef(null); const widthProbeRef = useRef(null); const [timelineElement, registerTimeline] = useState(null); - const [preview, setPreview] = useState(null); - const [detailsCard, setDetailsCard] = useState(null); - const reportDetailsCard = useCallback((next: PreviewMiniPlayerObstacles["detailsCard"]) => { + const [detailsCard, setDetailsCard] = useState(null); + const reportDetailsCard = useCallback((next: ChatCanvasDetailsCard | null) => { setDetailsCard((current) => current?.left === next?.left && current?.right === next?.right && @@ -42,26 +37,8 @@ export function ChatCanvas({ padding: 20, maxChatWidth: 768, minChatWidth: 640, - composerHeight: 0, timelineGutter: 0, }); - const reportPreview = useCallback((next: ChatCanvasPreview) => { - setPreview((current) => - current?.key === next.key && - current.width === next.width && - current.lastInteraction === next.lastInteraction && - current.position?.x === next.position?.x && - current.position?.y === next.position?.y && - current.source.width === next.source.width && - current.source.height === next.source.height - ? current - : next, - ); - }, []); - const clearPreview = useCallback( - (key: string) => setPreview((current) => (current?.key === key ? null : current)), - [], - ); useLayoutEffect(() => { const element = elementRef.current; const probe = widthProbeRef.current; @@ -74,7 +51,6 @@ export function ChatCanvas({ padding: Number.parseFloat(styles.paddingLeft), maxChatWidth: Number.parseFloat(styles.width), minChatWidth: Number.parseFloat(styles.minWidth), - composerHeight: composerOverlayElement?.getBoundingClientRect().height ?? 0, timelineGutter: timelineElement ? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2 : 0, @@ -91,23 +67,19 @@ export function ChatCanvas({ const observer = new ResizeObserver(measure); observer.observe(element); observer.observe(probe); - if (composerOverlayElement) observer.observe(composerOverlayElement); if (timelineElement) observer.observe(timelineElement); return () => observer.disconnect(); - }, [composerOverlayElement, timelineElement]); + }, [timelineElement]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; return { container, lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, - layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), - previewKey: preview?.key ?? null, - reportPreview, - clearPreview, + layout: resolveChatCanvasLayout({ ...measurements, container, detailsCard }), registerTimeline, reportDetailsCard, }; - }, [measurements, preview, detailsCard, reportPreview, clearPreview, reportDetailsCard]); + }, [measurements, detailsCard, reportDetailsCard]); const { layout } = context; return ( @@ -115,7 +87,6 @@ export function ChatCanvas({ {...props} ref={elementRef} data-chat-canvas - data-preview-overlaps-chat={layout.overlapsChat || undefined} className="relative flex min-h-0 min-w-0 flex-1 flex-col" style={ { diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index 44b91f82632a..d1c9231ab69d 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -1,16 +1,12 @@ import { createContext, useContext } from "react"; -import type { ChatCanvasPreview, resolveChatCanvasLayout } from "./chatCanvasLayout"; -import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; +import type { ChatCanvasDetailsCard, resolveChatCanvasLayout } from "./chatCanvasLayout"; export const ChatCanvasContext = createContext<{ container: { width: number; height: number }; lane: { padding: number; minChatWidth: number }; layout: ReturnType; - previewKey: string | null; - reportPreview: (preview: ChatCanvasPreview) => void; - clearPreview: (key: string) => void; registerTimeline: (element: HTMLElement | null) => void; - reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void; + reportDetailsCard: (card: ChatCanvasDetailsCard | null) => void; } | null>(null); export const useChatCanvas = () => useContext(ChatCanvasContext); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 74cbf60cc9ec..ee7404479c28 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -30,17 +30,9 @@ export function ThreadDetailsCard({ ? resolveThreadDetailsCardLayout({ container: canvas.container, lane: canvas.lane, - frame: null, - }) - : null; - const placement = canvas - ? resolveThreadDetailsCardLayout({ - container: canvas.container, - lane: canvas.lane, - frame: canvas.layout.frame, - overlapsDetailsCard: canvas.layout.overlapsDetailsCard, }) : null; + const placement = preferredPlacement; const mode = placement ? "inline" : "popover"; const inlineOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), diff --git a/apps/web/src/components/chat/chatCanvasLayout.test.ts b/apps/web/src/components/chat/chatCanvasLayout.test.ts index ea99128a73d0..6dcc0cc1925c 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.test.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.test.ts @@ -1,270 +1,29 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; -import { resizePreviewMiniPlayer } from "../preview/previewMiniPlayerLayout"; -import { - resolveThreadDetailsCardDensity, - resolveThreadDetailsCardLayout, -} from "./threadDetailsCardLayout"; - -const preview: ChatCanvasPreview = { - key: "browser:one", - width: 320, - position: null, - source: { width: 1600, height: 1000 }, -}; -const resolve = (width: number, player: ChatCanvasPreview | null = preview, height = 900) => - resolveChatCanvasLayout({ container: { width, height }, preview: player, composerHeight: 180 }); -const expectClear = (result: ReturnType) => { - const frame = result.frame!; - const chat = result.chat; - expect(frame.x + frame.width <= chat.left - 12 || frame.x >= chat.left + chat.width + 12).toBe( - true, - ); -}; +import { resolveChatCanvasLayout } from "./chatCanvasLayout"; describe("chat canvas layout", () => { - it("lifts a growing preview above the composer without snapping at the chat boundary", () => { - let previous: ReturnType | undefined; - for (let width = 480; width <= 1100; width++) { - const result = resolve(1344, { - ...preview, - width, - lastInteraction: "resize", - position: { x: 1332 - width, y: 888 - Math.round(width / 1.6) }, - }); - const frame = result.frame!; - if (frame.x < result.chat.left + result.chat.width + 12) { - expect(frame.y + frame.height).toBeLessThanOrEqual(708); - } - expect(result.chat.left).toBeLessThanOrEqual(288); - expect(result.chat.width).toBeGreaterThanOrEqual(640); - if (previous) { - expect(Math.abs(frame.y - previous.frame!.y)).toBeLessThanOrEqual(3); - expect(Math.abs(frame.width - previous.frame!.width)).toBeLessThanOrEqual(2); - expect(Math.abs(result.chat.width - previous.chat.width)).toBeLessThanOrEqual(1); - } - previous = result; - } - expect(previous!.overlapsChat).toBe(true); - expect(previous!.frame!.width).toBe(1100); - }); - - it("centers chat in the whole container without a preview", () => { - expect(resolve(1344, null).chat).toEqual({ left: 288, width: 768, insetStart: 0, insetEnd: 0 }); - expect(resolve(390, null).chat).toEqual({ left: 20, width: 350, insetStart: 0, insetEnd: 0 }); - }); - it("does not move chat when a bottom-right preview fits in its margin", () => { - const result = resolve(1600); - expect(result.chat.insetEnd).toBe(0); - expect(result.frame!.y + result.frame!.height).toBe(888); - expectClear(result); - }); - it("moves chat only as far as the preview requires", () => { - const result = resolve(1344); - expect(result.chat).toEqual({ left: 232, width: 768, insetStart: 0, insetEnd: 112 }); - expectClear(result); - }); - it("shrinks chat modestly after using the left margin", () => { - const result = resolve(1200, { ...preview, width: 480 }); - expect(result.chat.width).toBe(676); - expect(result.chat.left).toBe(20); - expectClear(result); - }); - it("stops leftward dragging before the player can push chat to the right", () => { - const centered = resolve(1344, null).chat; - for (const x of [1100, 800, 672, 600, 400, 12, -100]) { - const result = resolve(1344, { ...preview, position: { x, y: 500 } }); - expect(result.frame!.x).toBeGreaterThanOrEqual(672); - expect(result.chat.left).toBeLessThanOrEqual(centered.left); - expect(result.chat.insetStart).toBe(0); - expect(result.chat.width).toBeGreaterThanOrEqual(640); - expectClear(result); - } - expect(resolve(1344, { ...preview, position: { x: 12, y: 500 } }).frame!.x).toBe(672); - }); - it("keeps a dragged player above the composer when no readable lane fits beside it", () => { - const result = resolve(1000, { ...preview, position: { x: 12, y: 700 } }); - expect(result.chat).toEqual(resolve(1000, null).chat); - expect(result.frame!.x).toBe(668); - expect(result.frame!.y + result.frame!.height).toBeLessThanOrEqual(708); - }); - it("allows message overlap on narrow screens while excluding the composer", () => { - const result = resolve(1000); - expect(result.overlapsChat).toBe(true); - expect(result.chat.insetStart).toBe(0); - expect(result.chat.insetEnd).toBe(0); - expect(result.frame!.y + result.frame!.height).toBeLessThanOrEqual(900 - 180 - 12); - expect(result.frame!.width / result.frame!.height).toBeCloseTo(1.6); - }); - it("restores the preferred width when the container grows again", () => { - const player = { ...preview, width: 480 }; - expect(resolve(1200, player).chat.width).toBeLessThan(768); - expect(resolve(1800, player).chat.width).toBe(768); - expect(player.width).toBe(480); - }); - it("uses the same stable side-by-side frame even as the composer grows", () => { - const result = resolve(1344); - expect( - resolveChatCanvasLayout({ - container: { width: 1344, height: 900 }, - preview, - composerHeight: 400, - }), - ).toEqual(result); - }); - it("constrains a dragged preview below the full top-right card instead of folding it", () => { - const result = resolveChatCanvasLayout({ - container: { width: 1584, height: 988 }, - maxChatWidth: 736, - composerHeight: 180, - detailsCard: { left: 1260, right: 1572, bottom: 339 }, - preview: { - ...preview, - width: 240, - source: { width: 240, height: 365 }, - position: { x: 1260, y: 240 }, - }, - }); - expect(result.frame).toEqual({ x: 1260, y: 351, width: 240, height: 365 }); - expect(result.overlapsDetailsCard).toBe(false); - expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 }); - }); - it("uses space beside the full card if a tall preview cannot fit below it", () => { - const result = resolveChatCanvasLayout({ - container: { width: 1584, height: 988 }, - maxChatWidth: 736, - composerHeight: 180, - detailsCard: { left: 1260, right: 1572, bottom: 339 }, - preview: { - ...preview, - width: 240, - source: { width: 240, height: 700 }, - position: { x: 1260, y: 100 }, - }, + it("centers chat in the whole container", () => { + expect(resolveChatCanvasLayout({ container: { width: 1344, height: 900 } }).chat).toEqual({ + left: 288, + width: 768, + insetStart: 0, + insetEnd: 0, }); - expect(result.frame).toEqual({ x: 1008, y: 100, width: 240, height: 700 }); - expect(result.overlapsDetailsCard).toBe(false); - expectClear(result); - }); - it("keeps a large player when clearing the full card would leave no readable chat", () => { - const container = { width: 1584, height: 988 }; - const source = { width: 1000, height: 1523 }; - const layout = (width: number) => { - const resized = resizePreviewMiniPlayer({ - start: { x: 1332, y: 610, width: 240, height: 366 }, - direction: "northwest", - delta: { x: 240 - width, y: ((240 - width) * source.height) / source.width }, - container, - source, - }); - return resolveChatCanvasLayout({ - container, - maxChatWidth: 736, - composerHeight: 206, - detailsCard: { left: 1260, right: 1572, bottom: 339 }, - preview: { ...preview, width: resized.width, position: resized, source }, - }); - }; - const shifted = layout(460); - expect(shifted.frame!.width).toBe(460); - expect(shifted.chat.left).toBeLessThan(424); - expect(shifted.overlapsDetailsCard).toBe(false); - expectClear(shifted); - - const narrowed = layout(560); - expect(narrowed.frame!.width).toBe(560); - expect(narrowed.chat.width).toBe(656); - expect(narrowed.overlapsDetailsCard).toBe(false); - expectClear(narrowed); - - const grown = layout(600); - expect(grown.frame!.width).toBe(600); - expect(grown.overlapsDetailsCard).toBe(true); - expect(grown.overlapsChat).toBe(false); - expectClear(grown); - expect(layout(560)).toEqual(narrowed); - }); - it.each(["west", "north"] as const)( - "folds the card before moving a %s resize beside it", - (direction) => { - const container = { width: 1584, height: 988 }; - const source = { width: 1000, height: 1523 }; - const layout = (width: number) => { - const resized = resizePreviewMiniPlayer({ - start: { x: 1332, y: 611, width: 240, height: 365 }, - direction, - delta: { x: 240 - width, y: ((240 - width) * source.height) / source.width }, - container, - source, - }); - return resolveChatCanvasLayout({ - container, - maxChatWidth: 736, - composerHeight: 206, - detailsCard: { left: 1260, right: 1572, bottom: 339 }, - preview: { - ...preview, - width: resized.width, - position: resized, - source, - lastInteraction: "resize", - }, - }); - }; - const density = (result: ReturnType) => { - const card = resolveThreadDetailsCardLayout({ - container, - lane: { padding: 20, minChatWidth: 640 }, - frame: result.frame, - overlapsDetailsCard: result.overlapsDetailsCard, - }); - return card - ? resolveThreadDetailsCardDensity(card.height, { full: 327, compact: 182 }) - : "hidden"; - }; - expect(density(layout(400))).toBe("full"); - const compact = layout(460); - expect(compact.frame!.x + compact.frame!.width).toBe(1572); - expect(compact.frame!.width).toBe(460); - expect(compact.chat.left).toBe(364); - expect(compact.overlapsDetailsCard).toBe(true); - expect(density(compact)).toBe("compact"); - expectClear(compact); - expect(density(layout(500))).toBe("compact"); - expect(density(layout(520))).toBe("essential"); - expect(density(layout(540))).toBe("hidden"); - expect(layout(600).frame!.width).toBe(600); - expect(layout(460)).toEqual(compact); - expect(density(layout(400))).toBe("full"); - }, - ); - it("requests card folding only when no full-card slot fits and keeps the composer clear", () => { - const result = resolveChatCanvasLayout({ - container: { width: 1584, height: 988 }, - maxChatWidth: 736, - composerHeight: 180, - detailsCard: { left: 1260, right: 1572, bottom: 600 }, - preview: { - ...preview, - width: 1300, - source: { width: 1300, height: 400 }, - position: { x: 12, y: 12 }, - }, + expect(resolveChatCanvasLayout({ container: { width: 390, height: 900 } }).chat).toEqual({ + left: 20, + width: 350, + insetStart: 0, + insetEnd: 0, }); - expect(result.overlapsDetailsCard).toBe(true); - expect(result.overlapsChat).toBe(true); - expect(result.frame).toEqual({ x: 272, y: 396, width: 1300, height: 400 }); }); }); describe("workspace card beside chat", () => { - const withCard = (width: number, player: ChatCanvasPreview | null = null, maxChatWidth = 736) => + const withCard = (width: number, maxChatWidth = 736) => resolveChatCanvasLayout({ container: { width, height: 900 }, maxChatWidth, - composerHeight: 180, detailsCard: { left: width - 292, right: width - 12, bottom: 400 }, - preview: player, }); it("keeps chat centered while the card fits beside it", () => { expect(withCard(1384).chat).toEqual({ left: 324, width: 736, insetStart: 0, insetEnd: 0 }); @@ -279,19 +38,11 @@ describe("workspace card beside chat", () => { expect(withCard(984).chat).toMatchObject({ left: 20, width: 640 }); }); it("keeps a full-width chat clear of the card", () => { - expect(withCard(1147, null, 10_000).chat).toEqual({ + expect(withCard(1147, 10_000).chat).toEqual({ left: 20, width: 803, insetStart: 0, insetEnd: 304, }); }); - it("keeps centered chat in place when a new preview opens below the card", () => { - expect(withCard(1440, preview)).toEqual({ - chat: { left: 352, width: 736, insetStart: 0, insetEnd: 0 }, - frame: { x: 1108, y: 688, width: 320, height: 200 }, - overlapsChat: false, - overlapsDetailsCard: false, - }); - }); }); diff --git a/apps/web/src/components/chat/chatCanvasLayout.ts b/apps/web/src/components/chat/chatCanvasLayout.ts index 714b9e286402..0a7d8c990124 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.ts @@ -1,45 +1,24 @@ -import { - clampPreviewMiniPlayerPosition, - resolvePreviewMiniPlayerFrame, - type PreviewMiniPlayerFrame, - type PreviewMiniPlayerObstacles, -} from "../preview/previewMiniPlayerLayout"; -import type { - PreviewMiniPlayerPosition, - PreviewMiniPlayerSize, - PreviewMiniPlayerState, -} from "~/previewMiniPlayerStore"; - -export interface ChatCanvasPreview { - readonly key: string; - readonly width: number | null; - readonly position: PreviewMiniPlayerPosition | null; - readonly source: PreviewMiniPlayerSize; - readonly lastInteraction?: PreviewMiniPlayerState["lastInteraction"]; -} - -const GAP = 12; // Minimum space between chat and the workspace card. Chat stays centered while // the card fits beside it with this much room. export const DETAILS_CARD_CLEARANCE = 32; -/** Pure geometry shared by the conversation, composer, workspace card, and floating preview. */ +export interface ChatCanvasDetailsCard { + readonly left: number; + readonly right: number; + readonly bottom: number; +} + +/** Conversation and composer placement around the inline workspace card. */ export function resolveChatCanvasLayout({ container, - preview, padding = 20, maxChatWidth = 768, - minChatWidth = 640, - composerHeight = 0, detailsCard = null, }: { - container: PreviewMiniPlayerSize; - preview: ChatCanvasPreview | null; + container: { width: number; height: number }; padding?: number; maxChatWidth?: number; - minChatWidth?: number; - composerHeight?: number; - detailsCard?: PreviewMiniPlayerObstacles["detailsCard"]; + detailsCard?: ChatCanvasDetailsCard | null; }) { const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2)); // A workspace card that does not fit beside the centered chat first moves @@ -53,132 +32,11 @@ export function resolveChatCanvasLayout({ padding, Math.min((container.width - normalWidth) / 2, laneRight - normalWidth), ); - let chat = { + const chat = { left: normalLeft, width: normalWidth, insetStart: 0, insetEnd: Math.max(0, container.width - normalLeft * 2 - normalWidth), }; - let frame: PreviewMiniPlayerFrame | null = null; - let overlapsChat = false; - if (preview && container.width > 0 && container.height > 0) { - frame = resolvePreviewMiniPlayerFrame({ ...preview, container }); - // Dragging stops at a readable chat lane on the left. Resizing can still - // consume that space when the container requires message overlap. - const minimumPreviewX = - preview.lastInteraction === "resize" ? GAP : padding + minChatWidth + GAP; - // New players start beside the composer, with the workspace card above them. - if (preview.position === null) frame = { ...frame, y: container.height - frame.height - GAP }; - if (preview.lastInteraction === "resize" && composerHeight > 0) { - // Lift the preview while chat uses its remaining shrink room, reaching - // the composer's top before the preview enters the readable chat lane. - const laneWidth = Math.min(normalWidth, minChatWidth); - const transitionWidth = Math.max(GAP, normalWidth - laneWidth); - const lift = Math.min( - 1, - Math.max(0, (padding + normalWidth + GAP - frame.x) / transitionWidth), - ); - if (lift > 0) { - frame = resolvePreviewMiniPlayerFrame({ - width: frame.width, - position: frame, - source: preview.source, - container: { - ...container, - height: Math.max(GAP * 2 + 1, container.height - composerHeight * lift), - }, - }); - } - } - const preferredFrame = { - ...frame, - ...clampPreviewMiniPlayerPosition(frame, container, frame, undefined, minimumPreviewX), - }; - // A resize keeps its anchored edge and consumes card height first. A drag - // clears the full card whenever it can, so moving alone never folds it. - const cardObstacle = preview.lastInteraction === "resize" ? null : detailsCard; - frame = { - ...frame, - ...clampPreviewMiniPlayerPosition( - frame, - container, - frame, - { detailsCard: cardObstacle, composer: null }, - minimumPreviewX, - ), - }; - const chatBeside = (player: PreviewMiniPlayerFrame) => { - const normalRight = normalLeft + normalWidth; - if (player.x >= normalRight + GAP) return chat; - const width = Math.min(maxChatWidth, player.x - GAP - padding); - if (width < minChatWidth) return null; - const left = Math.min(normalLeft, player.x - GAP - width); - return { - left, - width, - insetStart: 0, - insetEnd: Math.max(0, container.width - left * 2 - width), - }; - }; - let nextChat = chatBeside(frame); - // Clearing the full card must also leave a readable chat. If only the - // preferred position does, keep the resized player and let the card fold. - if (!nextChat && detailsCard) { - nextChat = chatBeside(preferredFrame); - if (nextChat) frame = preferredFrame; - } - if (nextChat) chat = nextChat; - else overlapsChat = true; - if (overlapsChat && preview.lastInteraction === "resize") { - const width = Math.min(normalWidth, minChatWidth); - chat = { - left: padding, - width, - insetStart: 0, - insetEnd: Math.max(0, container.width - padding * 2 - width), - }; - } else if (overlapsChat) { - const obstacles = { - detailsCard: cardObstacle, - composer: { left: chat.left, right: chat.left + chat.width, height: composerHeight }, - }; - frame = resolvePreviewMiniPlayerFrame({ ...preview, container, obstacles }); - frame = { - ...frame, - ...clampPreviewMiniPlayerPosition( - preview.position ?? { x: frame.x, y: container.height - frame.height - GAP }, - container, - frame, - obstacles, - minimumPreviewX, - ), - }; - // If the full card and composer leave no slot, keep typing usable while - // the card folds to the remaining height above this fallback frame. - if ( - frame.x < obstacles.composer.right && - frame.x + frame.width > obstacles.composer.left && - frame.y + frame.height > container.height - composerHeight - ) { - frame = { - ...frame, - ...clampPreviewMiniPlayerPosition( - frame, - container, - frame, - { composer: obstacles.composer, detailsCard: null }, - minimumPreviewX, - ), - }; - } - } - } - const overlapsDetailsCard = Boolean( - frame && - detailsCard && - frame.x < detailsCard.right && - frame.x + frame.width > detailsCard.left && - frame.y < detailsCard.bottom, - ); - return { chat, frame, overlapsChat, overlapsDetailsCard }; + return { chat }; } diff --git a/apps/web/src/components/chat/threadDetailsCardLayout.test.ts b/apps/web/src/components/chat/threadDetailsCardLayout.test.ts index 165985245cd1..5503843ed481 100644 --- a/apps/web/src/components/chat/threadDetailsCardLayout.test.ts +++ b/apps/web/src/components/chat/threadDetailsCardLayout.test.ts @@ -5,11 +5,10 @@ import { } from "./threadDetailsCardLayout"; const lane = { padding: 20, minChatWidth: 640 }; -const resolve = (width: number, height: number, previewY: number | null = null) => +const resolve = (width: number, height: number) => resolveThreadDetailsCardLayout({ container: { width, height }, lane, - frame: previewY === null ? null : { x: width - 332, y: previewY, width: 320, height: 240 }, }); describe("workspace card", () => { @@ -26,56 +25,13 @@ describe("workspace card", () => { expect(resolve(984, 900)).toMatchObject({ x: 692 }); expect(resolve(983, 900)).toBeNull(); }); - it("keeps the card at the top right while the preview is freely dragged vertically", () => { - for (const y of [12, 170, 250, 400, 648]) { - expect(resolve(1600, 900, y)).toEqual({ x: 1308, y: 12, width: 280, height: 876 }); - } - }); - it("keeps full height while a preview stays clear of the card", () => { - expect(resolve(1344, 900, 600)).toMatchObject({ width: 280, height: 876 }); - expect( - resolveThreadDetailsCardLayout({ - container: { width: 1600, height: 900 }, - lane, - frame: { x: 12, y: 100, width: 320, height: 240 }, - })?.height, - ).toBe(876); + it("hides when the window cannot hold readable controls", () => { + expect(resolve(1600, 184)).toMatchObject({ height: 160 }); + expect(resolve(1600, 183)).toBeNull(); }); }); describe("card content fitting", () => { - const place = (previewY: number, previewHeight = 365, overlapsDetailsCard = false) => - resolveThreadDetailsCardLayout({ - container: { width: 1584, height: 988 }, - lane, - frame: { x: 1260, y: previewY, width: 240, height: previewHeight }, - overlapsDetailsCard, - }); - it("does not fold in response to a drag while the full card can be kept clear", () => { - for (const y of [12, 170, 225, 240, 340, 380, 611]) { - const placement = place(y)!; - expect(placement).toMatchObject({ x: 1292, y: 12, height: 964 }); - expect(resolveThreadDetailsCardDensity(placement.height, { full: 327, compact: 182 })).toBe( - "full", - ); - } - }); - it("folds only after the preview cannot fit around the full card", () => { - const content = { full: 327, compact: 182 }; - expect(resolveThreadDetailsCardDensity(place(351, 625, true)!.height, content)).toBe("full"); - expect(resolveThreadDetailsCardDensity(place(350, 626, true)!.height, content)).toBe("compact"); - expect(resolveThreadDetailsCardDensity(place(12)!.height, content)).toBe("full"); - }); - it("hides only when the available height cannot hold readable controls", () => { - expect(place(184, 792, true)).toMatchObject({ y: 12, height: 160 }); - expect(place(183, 793, true)).toBeNull(); - }); - it("keeps all content as a freely moved preview approaches without colliding", () => { - const content = { full: 162, compact: 126 }; - for (const y of [650, 450, 350, 250]) { - expect(resolveThreadDetailsCardDensity(resolve(1600, 900, y)!.height, content)).toBe("full"); - } - }); it("folds only detail that cannot fit and restores it when space returns", () => { const content = { full: 570, compact: 180 }; expect(resolveThreadDetailsCardDensity(600, content)).toBe("full"); diff --git a/apps/web/src/components/chat/threadDetailsCardLayout.ts b/apps/web/src/components/chat/threadDetailsCardLayout.ts index 655aaa487064..9e5f8f674366 100644 --- a/apps/web/src/components/chat/threadDetailsCardLayout.ts +++ b/apps/web/src/components/chat/threadDetailsCardLayout.ts @@ -1,4 +1,3 @@ -import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout"; import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout"; export function resolveThreadDetailsCardDensity( @@ -17,25 +16,16 @@ export function resolveThreadDetailsCardDensity( export function resolveThreadDetailsCardLayout({ container, lane, - frame, - overlapsDetailsCard = false, }: { container: { width: number; height: number }; lane: { padding: number; minChatWidth: number }; - frame: PreviewMiniPlayerFrame | null; - overlapsDetailsCard?: boolean; }) { const gap = 12; // Keep in sync with --thread-details-panel-width, which sizes the popover. const width = 280; const x = container.width - width - gap; if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null; - // Resizing consumes the height above the player. Dragging first tries to - // clear the full card and folds it only when there is no readable placement. - const height = - overlapsDetailsCard && frame && frame.x + frame.width > x - gap && frame.x < x + width + gap - ? Math.min(container.height - gap * 2, frame.y - gap * 2) - : container.height - gap * 2; + const height = container.height - gap * 2; if (height < 160) return null; return { x, diff --git a/apps/web/src/components/preview/PreviewMiniPlayerShell.test.tsx b/apps/web/src/components/preview/PreviewMiniPlayerShell.test.tsx new file mode 100644 index 000000000000..68bdd2a98e14 --- /dev/null +++ b/apps/web/src/components/preview/PreviewMiniPlayerShell.test.tsx @@ -0,0 +1,223 @@ +// @vitest-environment jsdom +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; +import { PreviewMiniPlayerShell } from "./PreviewMiniPlayerShell"; + +const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; +const source = browserMiniPlayerSource("tab"); +const callbacks = new Set<() => void>(); +let viewport: { width: number; height: number }; +let root: Root; +let parentRenders: number; + +function Preview() { + const activeSource = usePreviewMiniPlayerStore( + (state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef)?.source, + ); + parentRenders++; + return activeSource ? ( + undefined} + > + {() => } + + ) : null; +} + +const player = () => document.querySelector("[data-preview-mini-player]")!; +const moveButton = () => + document.querySelector('[aria-label="Move floating preview"]')!; +const resizeHandle = () => + document.querySelector('[data-preview-mini-player-resize="northwest"]')!; +const floatingState = () => + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, threadRef)!; +const frame = () => ({ + x: Number.parseFloat(player().style.left), + y: Number.parseFloat(player().style.top), + width: Number.parseFloat(player().style.width), + height: Number.parseFloat(player().style.height), +}); + +async function pointer(target: HTMLElement, type: string, x: number, y: number, buttons = 1) { + const event = new MouseEvent(type, { bubbles: true, clientX: x, clientY: y, button: 0, buttons }); + Object.defineProperty(event, "pointerId", { value: 1 }); + await act(() => target.dispatchEvent(event)); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + viewport = { width: 1400, height: 800 }; + parentRenders = 0; + callbacks.clear(); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(readonly callback: () => void) { + callbacks.add(callback); + } + observe() {} + disconnect() { + callbacks.delete(this.callback); + } + }, + ); + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => viewport.width); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => viewport.height); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + () => new DOMRect(0, 0, viewport.width, viewport.height), + ); + let captured = false; + vi.stubGlobal("PointerEvent", MouseEvent); + HTMLElement.prototype.setPointerCapture = () => { + captured = true; + }; + HTMLElement.prototype.hasPointerCapture = () => captured; + HTMLElement.prototype.releasePointerCapture = () => { + captured = false; + }; + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + useBrowserSurfaceStore.setState({ byTabId: {} }); + usePreviewMiniPlayerStore.getState().open(threadRef, source); + usePreviewMiniPlayerStore.getState().resize(threadRef, "browser:tab", 480, { x: 800, y: 300 }); + document.body.innerHTML = '
'; + root = createRoot(document.getElementById("root")!); + await act(() => root.render()); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; +}); + +describe("floating preview gestures", () => { + it("moves freely over the app without changing size or rerendering its parent", async () => { + const initialRenders = parentRenders; + const handle = moveButton(); + await pointer(handle, "pointerdown", 1000, 350); + await pointer(handle, "pointermove", 300, 130); + await pointer(handle, "pointerup", 300, 130, 0); + + expect(frame()).toEqual({ x: 100, y: 80, width: 480, height: 300 }); + expect(floatingState().width).toBe(480); + expect(parentRenders).toBe(initialRenders); + expect(document.getElementById("root")!.contains(player())).toBe(false); + }); + + it("keeps an anchored resize unchanged when switching to a move", async () => { + const edge = resizeHandle(); + await pointer(edge, "pointerdown", 800, 300); + await pointer(edge, "pointermove", 640, 200); + await pointer(edge, "pointerup", 640, 200, 0); + expect(frame()).toEqual({ x: 640, y: 200, width: 640, height: 400 }); + + const handle = moveButton(); + await pointer(handle, "pointerdown", 800, 240); + await pointer(handle, "pointermove", 200, 80); + await pointer(handle, "pointerup", 200, 80, 0); + expect(frame()).toEqual({ x: 40, y: 40, width: 640, height: 400 }); + }); + + it.each(["pointerup", "pointercancel", "lostpointercapture", "blur", "missing-release"])( + "stops resizing after %s so hovering cannot move or resize the preview", + async (ending) => { + const edge = resizeHandle(); + await pointer(edge, "pointerdown", 800, 300); + await pointer(edge, "pointermove", 640, 200); + const resized = frame(); + if (ending === "blur") await act(() => window.dispatchEvent(new Event("blur"))); + else if (ending === "missing-release") await pointer(edge, "pointermove", 400, 100, 0); + else await pointer(edge, ending, 640, 200, 0); + await pointer(edge, "pointermove", 100, 100, 0); + expect(frame()).toEqual(resized); + + const handle = moveButton(); + await pointer(handle, "pointerdown", 800, 240); + await pointer(handle, "pointermove", 200, 80); + await pointer(handle, "pointerup", 200, 80, 0); + expect(frame()).toEqual({ x: 40, y: 40, width: 640, height: 400 }); + }, + ); + + it("ignores plain hover events on every resize edge", async () => { + const initial = frame(); + for (const edge of document.querySelectorAll( + "[data-preview-mini-player-resize]", + )) { + await pointer(edge, "pointermove", 100, 100, 0); + } + await pointer(moveButton(), "pointermove", 100, 100, 0); + expect(frame()).toEqual(initial); + }); + + it("restores the chosen frame after a temporary window shrink", async () => { + const initial = frame(); + viewport = { width: 400, height: 400 }; + await act(() => { + for (const callback of callbacks) callback(); + }); + expect(frame().width).toBe(376); + expect(floatingState()).toMatchObject({ width: 480, position: { x: 800, y: 300 } }); + viewport = { width: 1400, height: 800 }; + await act(() => { + for (const callback of callbacks) callback(); + }); + expect(frame()).toEqual(initial); + }); +}); + +describe("browser panel handoff", () => { + it("keeps the floating lease while an invisible panel remains mounted or mounts again", async () => { + const fittedOwner = useBrowserSurfaceStore.getState().byTabId.tab!.owner; + await act(() => + root.render( + <> + + + , + ), + ); + await act(() => { + for (const callback of callbacks) callback(); + }); + expect(useBrowserSurfaceStore.getState().byTabId.tab).toMatchObject({ + owner: fittedOwner, + fitSourceContent: true, + visible: true, + }); + + await act(() => root.render()); + expect(useBrowserSurfaceStore.getState().byTabId.tab).toMatchObject({ + fitSourceContent: false, + visible: true, + }); + + await act(() => + root.render( + <> + + + , + ), + ); + expect(useBrowserSurfaceStore.getState().byTabId.tab).toMatchObject({ + fitSourceContent: true, + visible: true, + }); + }); +}); diff --git a/apps/web/src/components/preview/PreviewMiniPlayerShell.tsx b/apps/web/src/components/preview/PreviewMiniPlayerShell.tsx new file mode 100644 index 000000000000..0feac65c8022 --- /dev/null +++ b/apps/web/src/components/preview/PreviewMiniPlayerShell.tsx @@ -0,0 +1,400 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { GripIcon, PanelRightIcon, XIcon } from "lucide-react"; +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; +import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; +import { + type PreviewMiniPlayerSize, + type PreviewMiniPlayerSource, + previewMiniPlayerSourceKey, + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; +import { + clampPreviewMiniPlayerPosition, + PREVIEW_MINI_PLAYER_CORNER_RADIUS, + type PreviewMiniPlayerFrame, + resizePreviewMiniPlayer, + resolvePreviewMiniPlayerFrame, +} from "./previewMiniPlayerLayout"; + +interface PointerGesture { + readonly pointerId: number; + readonly pointerX: number; + readonly pointerY: number; + readonly frame: PreviewMiniPlayerFrame; + readonly direction: BrowserViewportResizeDirection | null; + moved: boolean; +} + +// Ignore small pointer movement when pressing a control. +const GESTURE_SLOP_PX = 6; + +const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; + +const RESIZE_CURSOR_CLASSES = { + north: "cursor-ns-resize", + south: "cursor-ns-resize", + west: "cursor-ew-resize", + east: "cursor-ew-resize", + northwest: "cursor-nwse-resize", + northeast: "cursor-nesw-resize", + southwest: "cursor-nesw-resize", + southeast: "cursor-nwse-resize", +} satisfies Record; +const MOVE_DELTAS: Record = { + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, +}; + +// Invisible grab zones straddling each edge; the cursor is the only affordance. +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" }, +]; + +/** + * The frame, drag/resize gestures, and controls shared by every floating + * source. Native clipping and the DOM frame use the same radius so their + * separately composited edges stay aligned. + */ +export function PreviewMiniPlayerShell({ + threadRef, + source, + sourceSize, + label, + onOpenInPanel, + pillActions, + recording = false, + cornerRadius = frameCornerRadius, + children, +}: { + readonly threadRef: ScopedThreadRef; + readonly source: PreviewMiniPlayerSource; + readonly sourceSize: PreviewMiniPlayerSize; + readonly label: string; + readonly onOpenInPanel: () => void; + readonly pillActions?: ReactNode; + readonly recording?: boolean; + /** The clip radius for a given frame; the pill stays inside the curve. */ + readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number; + readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; +}) { + const miniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + ); + const overlayRef = useRef(null); + const controlsRef = useRef(null); + const controlsPointerTypeRef = useRef(null); + const [pillOpen, setPillOpen] = useState(false); + const [container, setContainer] = useState({ width: 0, height: 0 }); + const [activeGesture, setActiveGesture] = useState< + BrowserViewportResizeDirection | "move" | null + >(null); + useLayoutEffect(() => { + const overlay = overlayRef.current; + if (!overlay) return; + const measure = () => { + const next = { width: overlay.clientWidth, height: overlay.clientHeight }; + setContainer((current) => + current.width === next.width && current.height === next.height ? current : next, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(overlay); + return () => observer.disconnect(); + }, []); + const gestureRef = useRef(null); + const sourceKey = previewMiniPlayerSourceKey(source); + const frame = + miniPlayer && container.width > 0 && container.height > 0 + ? resolvePreviewMiniPlayerFrame({ ...miniPlayer, source: sourceSize, container }) + : null; + + useEffect(() => { + const cancel = () => { + gestureRef.current = null; + setActiveGesture(null); + }; + window.addEventListener("blur", cancel); + return () => window.removeEventListener("blur", cancel); + }, []); + + // Touch has no hover. Tapping the corner dot reveals the same controls, + // and tapping outside dismisses them. + useEffect(() => { + if (!pillOpen) return; + const dismiss = (event: PointerEvent) => { + if (event.target instanceof Node && controlsRef.current?.contains(event.target)) return; + setPillOpen(false); + }; + document.addEventListener("pointerdown", dismiss, true); + return () => document.removeEventListener("pointerdown", dismiss, true); + }, [pillOpen]); + + 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); + }; + + const beginGesture = ( + event: ReactPointerEvent, + direction: BrowserViewportResizeDirection | null, + ) => { + if (event.button !== 0 || !frame || gestureRef.current) return; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + return; + } + setActiveGesture(direction ?? "move"); + gestureRef.current = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + frame, + direction, + moved: false, + }; + event.preventDefault(); + event.stopPropagation(); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + // A lost mouse release must never turn later hover events into a drag. + if ((event.buttons & 1) === 0) { + endGesture(event); + return; + } + const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; + if (!gesture.moved && Math.hypot(delta.x, delta.y) < GESTURE_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; + setActiveGesture(null); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + // Keep the portal root out of a stacking context: the hosted Electron guest + // sits between the frame (47) and controls (49) in the document at layer 48. + return createPortal( +
+ {activeGesture ? ( + // Cover guests during a gesture so an Electron webview or iframe cannot steal it. +
+ ) : null} + {frame ? ( +
+
+ +
+ + beginGesture(event, null)} + onPointerMove={handlePointerMove} + onPointerUp={endGesture} + onPointerCancel={endGesture} + onLostPointerCapture={endGesture} + onKeyDown={(event) => { + if (!frame) return; + const delta = MOVE_DELTAS[event.key]; + if (!delta) return; + event.preventDefault(); + const step = event.shiftKey ? 40 : 10; + usePreviewMiniPlayerStore + .getState() + .move( + threadRef, + sourceKey, + clampPreviewMiniPlayerPosition( + { x: frame.x + delta.x * step, y: frame.y + delta.y * step }, + container, + frame, + ), + ); + }} + /> + } + > + + + Hold and drag to move preview + + {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} + onLostPointerCapture={endGesture} + /> + ))} +
+ ) : null} +
, + document.body, + ); +} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 522cda3d42c5..5873d4988d20 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -518,7 +518,7 @@ describe("PreviewView navigation", () => { ); }); - it("opens and closes a thread-scoped floating preview for the active tab", async () => { + it("floats a docked preview even when its source already has a saved floating frame", async () => { const props = { threadRef: { environmentId: EnvironmentId.make("environment-1"), @@ -539,9 +539,11 @@ describe("PreviewView navigation", () => { mocks.miniPlayerTabId = "tab-1"; renderToStaticMarkup(); - expect(mocks.pictureInPicturePressed).toBe(true); + expect(mocks.pictureInPicturePressed).toBe(false); mocks.togglePictureInPicture?.(); - expect(mocks.closeMiniPlayer).toHaveBeenCalledWith(props.threadRef); + expect(mocks.closeMiniPlayer).not.toHaveBeenCalled(); + expect(mocks.openMiniPlayer).toHaveBeenCalledTimes(2); + expect(mocks.closeRightPanel).toHaveBeenCalledTimes(2); }); it("keeps the native preview window as a secondary action", async () => { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index af5253448655..b53ad2979f64 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -412,13 +412,13 @@ export function PreviewView({ const handlePictureInPicture = useCallback(() => { if (!tabId) return; - if (miniPlayerTabId === tabId) { + if (!visible && miniPlayerTabId === tabId) { usePreviewMiniPlayerStore.getState().close(threadRef); return; } usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId)); useRightPanelStore.getState().close(threadRef); - }, [miniPlayerTabId, tabId, threadRef]); + }, [miniPlayerTabId, tabId, threadRef, visible]); /** * The menu's actions. A server tab, streamed or rendered natively here, runs @@ -908,7 +908,7 @@ export function PreviewView({ onPictureInPicture={ tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined } - pictureInPicture={miniPlayerTabId === tabId} + pictureInPicture={!visible && miniPlayerTabId === tabId} pictureInPictureDisabled={ isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents) } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 91f0453c29a8..104111fdbb8b 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -1,25 +1,15 @@ "use client"; import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; -import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { - type PointerEvent as ReactPointerEvent, - type ReactNode, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { PictureInPicture2 } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; -import { useChatCanvas } from "../chat/ChatCanvasContext"; 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 { useRendersServerTabNatively } from "~/browser/previewRuntime"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; @@ -33,14 +23,11 @@ import { 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, previewMiniPlayerSourceKey, - usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { useDeviceState } from "~/state/device"; @@ -49,74 +36,39 @@ import { DeviceStreamView } from "../device/DeviceStreamView"; import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; import { previewBridge } from "./previewBridge"; +import { PreviewMiniPlayerShell } from "./PreviewMiniPlayerShell"; import { - clampPreviewMiniPlayerPosition, - NO_PREVIEW_MINI_PLAYER_OBSTACLES, 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; -} - -// A touch that travels less than this is a tap on the handle, not a drag. -const HANDLE_TAP_SLOP_PX = 6; - interface Props { readonly threadRef: ScopedThreadRef; - readonly miniPlayer: PreviewMiniPlayerState; + readonly source: PreviewMiniPlayerSource; } -const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; - -// Invisible grab zones straddling each edge; the cursor is the only affordance. -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" }, -]; - /** Floats the thread's browser tab or device stream over chat. */ -export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { - const { source } = miniPlayer; +export function ThreadPreviewMiniPlayer({ threadRef, source }: Props) { return source.kind === "browser" ? ( ) : ( ); } -function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { +function BrowserMiniPlayer({ threadRef, tabId, source }: Props & { readonly tabId: string }) { const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); @@ -144,7 +96,6 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly ); const openInPanel = () => { - usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openBrowser(threadRef, tabId); }; @@ -178,9 +129,9 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly const canPopOut = serverTab ? supportsServerPictureInPicture() : true; return ( - ) } - + ); } function DeviceMiniPlayer({ threadRef, source, - miniPlayer, }: Props & { readonly source: Extract }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); const [screen, setScreen] = useState(null); @@ -271,7 +221,6 @@ function DeviceMiniPlayer({ ); const openInPanel = () => { - usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openDevice(threadRef, { hostId: source.hostId, deviceId: source.deviceId, @@ -281,9 +230,9 @@ function DeviceMiniPlayer({ }; return ( -
)} - - ); -} - -/** - * 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, - 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; - /** The clip radius for a given frame; the pill stays inside the curve. */ - readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number; - readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; -}) { - const canvas = useChatCanvas(); - const gestureRef = useRef(null); - // Touch has no hover, so tapping the handle toggles the pill instead. The - // toggle waits for the tap's click, which would otherwise land on the pill. - const [pillOpen, setPillOpen] = useState(false); - const handleTappedRef = useRef(false); - const handleRef = useRef(null); - // The pill covers the handle, so a tap anywhere else dismisses it. - 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 obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; - const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); - const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; - const { width: sourceWidth, height: sourceHeight } = sourceSize; - const reportPreview = canvas?.reportPreview; - const clearPreview = canvas?.clearPreview; - useLayoutEffect(() => { - reportPreview?.({ - key: sourceKey, - width: miniPlayer.width, - position: miniPlayer.position, - lastInteraction: miniPlayer.lastInteraction, - source: { width: sourceWidth, height: sourceHeight }, - }); - }, [ - reportPreview, - sourceKey, - miniPlayer.width, - miniPlayer.position, - miniPlayer.lastInteraction, - sourceWidth, - sourceHeight, - ]); - useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); - - 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); - }; - - 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, - obstacles, - ), - ); - return; - } - const next = resizePreviewMiniPlayer({ - start: gesture.frame, - direction: gesture.direction, - delta, - source: sourceSize, - container, - obstacles, - }); - 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} -
+ ); } diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index 56658461ff5e..1944b9e64984 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -89,10 +89,10 @@ describe("resolveDeviceMiniPlayerCornerRadius", () => { }); describe("resolvePreviewMiniPlayerFrame", () => { - it("opens at the source aspect ratio in the top-right corner", () => { + it("opens at the source aspect ratio in the bottom-right corner", () => { expect( resolvePreviewMiniPlayerFrame({ width: null, position: null, source, container }), - ).toEqual({ x: 668, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 320, height: 200 }); + ).toEqual({ x: 668, y: 488, width: 320, height: 200 }); }); it("keeps a tall source at the minimum width instead of the default box", () => { @@ -103,7 +103,7 @@ describe("resolvePreviewMiniPlayerFrame", () => { source: { width: 390, height: 844 }, container, }), - ).toEqual({ x: 748, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 240, height: 519 }); + ).toEqual({ x: 748, y: 169, width: 240, height: 519 }); }); it("derives height from the stored width", () => { diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 2d0f8bbaec7b..7574b2b6600e 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -94,7 +94,7 @@ export interface PreviewMiniPlayerObstacles { readonly detailsCard: (HorizontalSpan & { readonly bottom: number }) | null; } -export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { +const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null, detailsCard: null, }; @@ -274,7 +274,7 @@ export function clampPreviewMiniPlayerPosition( * Resolves the on-screen frame from the stored width and position. Clamping * happens here on every layout pass instead of being written back to the * store, so a temporarily narrow container never destroys the user's chosen - * width. A player without a position sits in the top-right corner, or tucks + * width. A player without a position sits in the bottom-right corner, or tucks * under the details card with right edges aligned when the card owns it. */ export function resolvePreviewMiniPlayerFrame(input: { @@ -310,7 +310,7 @@ export function resolvePreviewMiniPlayerFrame(input: { ? { x: detailsCard.right - size.width, y: detailsCard.bottom + PREVIEW_MINI_PLAYER_EDGE_GAP } : { x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, - y: PREVIEW_MINI_PLAYER_EDGE_GAP, + y: container.height - size.height - PREVIEW_MINI_PLAYER_EDGE_GAP, }); return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size }; }