diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0e786ae54af7..212c23ecdb3d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1694,8 +1694,6 @@ export default function ChatView(props: ChatViewProps) { [], ); const [composerOverlayElement, setComposerOverlayElement] = useState(null); - const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); - const composerOverlayHeightRef = useRef(0); // Space the timeline keeps clear above its end. Tracks the overlay while the // composer is expanded and holds that height while it rests, so the resting // composer never exposes rows that its expansion will cover. @@ -5493,11 +5491,6 @@ export default function ChatView(props: ChatViewProps) { (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; - const previousHeight = composerOverlayHeightRef.current; - if (previousHeight !== nextHeight) { - composerOverlayHeightRef.current = nextHeight; - setComposerOverlayHeight(nextHeight); - } const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, overlayHeight: nextHeight, @@ -8658,7 +8651,10 @@ export default function ChatView(props: ChatViewProps) { ref={attachDraftHeroTransitionGroupRef} className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]" > -
+
{isDraftHeroState ? (
) : null} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 432e41cb4ab3..76dba69eceee 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -35,9 +35,11 @@ import type { DeviceScreenSize } from "../device/deviceStream"; import { previewBridge } from "./previewBridge"; import { clampPreviewMiniPlayerPosition, + NO_PREVIEW_MINI_PLAYER_OBSTACLES, PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, + type PreviewMiniPlayerObstacles, resizePreviewMiniPlayer, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, @@ -56,7 +58,49 @@ interface PointerGesture { interface Props { readonly threadRef: ScopedThreadRef; readonly miniPlayer: PreviewMiniPlayerState; - readonly bottomInset: number; + /** The docked composer overlay; null while the composer floats mid-screen. */ + readonly composerOverlayElement: HTMLElement | null; +} + +interface Layout { + readonly container: PreviewMiniPlayerSize; + readonly obstacles: PreviewMiniPlayerObstacles; +} + +const sameLayout = (a: Layout, b: Layout) => + a.container.width === b.container.width && + a.container.height === b.container.height && + (a.obstacles.composer === b.obstacles.composer || + (a.obstacles.composer !== null && + b.obstacles.composer !== null && + a.obstacles.composer.left === b.obstacles.composer.left && + a.obstacles.composer.right === b.obstacles.composer.right && + a.obstacles.composer.height === b.obstacles.composer.height)); + +/** + * Measures the chat column and the composer in the column's coordinates. The + * composer's columns come from its centered stack, not the full-width overlay, + * so the margins beside it stay open to the player. + */ +function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | null): Layout { + const containerRect = container.getBoundingClientRect(); + const stackRect = composerOverlay + ?.querySelector('[data-chat-composer-stack="true"]') + ?.getBoundingClientRect(); + const overlayRect = composerOverlay?.getBoundingClientRect(); + return { + container: { width: container.clientWidth, height: container.clientHeight }, + obstacles: { + composer: + overlayRect && stackRect && overlayRect.height > 0 + ? { + left: Math.floor(stackRect.left - containerRect.left), + right: Math.ceil(stackRect.right - containerRect.left), + height: Math.ceil(overlayRect.height), + } + : null, + }, + }; } const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; @@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{ ]; /** Floats the thread's browser tab or device stream over chat. */ -export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) { +export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) { const { source } = miniPlayer; return source.kind === "browser" ? ( ) : ( ); } @@ -102,7 +146,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer, - bottomInset, + composerOverlayElement, }: Props & { readonly tabId: string }) { const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; @@ -143,7 +187,7 @@ function BrowserMiniPlayer({ threadRef={threadRef} miniPlayer={miniPlayer} sourceSize={sourceSize} - bottomInset={bottomInset} + composerOverlayElement={composerOverlayElement} label="Floating browser preview" onOpenInPanel={openInPanel} pillActions={ @@ -200,7 +244,7 @@ function DeviceMiniPlayer({ threadRef, source, miniPlayer, - bottomInset, + composerOverlayElement, }: Props & { readonly source: Extract }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); const [screen, setScreen] = useState(null); @@ -230,7 +274,7 @@ function DeviceMiniPlayer({ threadRef={threadRef} miniPlayer={miniPlayer} sourceSize={sourceSize} - bottomInset={bottomInset} + composerOverlayElement={composerOverlayElement} label="Floating device preview" onOpenInPanel={openInPanel} cornerRadius={cornerRadius} @@ -266,7 +310,7 @@ function MiniPlayerShell({ threadRef, miniPlayer, sourceSize, - bottomInset, + composerOverlayElement, label, onOpenInPanel, pillActions, @@ -276,7 +320,7 @@ function MiniPlayerShell({ readonly threadRef: ScopedThreadRef; readonly miniPlayer: PreviewMiniPlayerState; readonly sourceSize: PreviewMiniPlayerSize; - readonly bottomInset: number; + readonly composerOverlayElement: HTMLElement | null; readonly label: string; readonly onOpenInPanel: () => void; readonly pillActions?: ReactNode; @@ -286,7 +330,9 @@ function MiniPlayerShell({ }) { const containerRef = useRef(null); const gestureRef = useRef(null); - const [container, setContainer] = useState(null); + const [layout, setLayout] = useState(null); + const container = layout?.container ?? null; + const obstacles = layout?.obstacles ?? NO_PREVIEW_MINI_PLAYER_OBSTACLES; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); const frame = container ? resolvePreviewMiniPlayerFrame({ @@ -294,7 +340,7 @@ function MiniPlayerShell({ position: miniPlayer.position, source: sourceSize, container, - bottomInset, + obstacles, }) : null; @@ -306,22 +352,21 @@ function MiniPlayerShell({ usePreviewMiniPlayerStore.getState().close(threadRef); }; + // The composer grows on its own (drafts, banners), so it is observed alongside the column. useLayoutEffect(() => { const element = containerRef.current; if (!element) return; const measure = () => { - setContainer((current) => - current?.width === element.clientWidth && current.height === element.clientHeight - ? current - : { width: element.clientWidth, height: element.clientHeight }, - ); + const next = measureLayout(element, composerOverlayElement); + setLayout((current) => (current && sameLayout(current, next) ? current : next)); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(element); + if (composerOverlayElement) observer.observe(composerOverlayElement); return () => observer.disconnect(); - }, []); + }, [composerOverlayElement]); const beginGesture = ( event: ReactPointerEvent, @@ -353,7 +398,7 @@ function MiniPlayerShell({ { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, container, gesture.frame, - bottomInset, + obstacles, ), ); return; @@ -364,7 +409,7 @@ function MiniPlayerShell({ delta, source: sourceSize, container, - bottomInset, + obstacles, }); store.resize(threadRef, sourceKey, next.width); store.move(threadRef, sourceKey, { x: next.x, y: next.y }); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index ad330ddcd223..175de44de2e4 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampPreviewMiniPlayerPosition, PREVIEW_MINI_PLAYER_EDGE_GAP, + type PreviewMiniPlayerObstacles, resizePreviewMiniPlayer, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, @@ -13,6 +14,11 @@ import { const container = { width: 1_000, height: 700 }; const source = { width: 1_600, height: 1_000 }; +const gap = PREVIEW_MINI_PLAYER_EDGE_GAP; +// A centered composer stack with margins on each side. +const composer = { left: 100, right: 900, height: 150 }; +const obstacles: PreviewMiniPlayerObstacles = { composer }; +const tallComposer: PreviewMiniPlayerObstacles = { composer: { ...composer, height: 300 } }; describe("resolvePreviewMiniPlayerSourceSize", () => { it("uses the device viewport scaled by zoom", () => { @@ -106,11 +112,35 @@ describe("resolvePreviewMiniPlayerFrame", () => { position: { x: 100, y: 80 }, source, container, - bottomInset: 300, + obstacles: tallComposer, }); expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 }); }); + it("keeps a tall frame parked beside the composer across layout passes", () => { + // The frame an edge resize produced in the left margin, resolved again from + // the stored width and position on the next render. + const phone = { width: 390, height: 844 }; + const beside = { composer: { left: 300, right: 900, height: 300 } }; + const resized = resizePreviewMiniPlayer({ + start: { x: 12, y: 100, width: 240, height: 519 }, + direction: "east", + delta: { x: 10, y: 0 }, + source: phone, + container, + obstacles: beside, + }); + expect( + resolvePreviewMiniPlayerFrame({ + width: resized.width, + position: { x: resized.x, y: resized.y }, + source: phone, + container, + obstacles: beside, + }), + ).toEqual(resized); + }); + it("never grows past the source's own rendered size", () => { expect( resolvePreviewMiniPlayerFrame({ @@ -194,11 +224,52 @@ describe("resizePreviewMiniPlayer", () => { delta: { x: 300, y: 0 }, source, container, - bottomInset: 0, }), ).toEqual({ x: 12, y: 300, width: 620, height: 388 }); }); + it("lets a player beside a tall composer keep its height on an edge drag", () => { + // A portrait player parked in the margin left of the composer, already + // taller than the rows above the composer, nudged from its right edge. + const phone = { width: 390, height: 844 }; + const start = { x: 12, y: 100, width: 240, height: 519 }; + const beside = { composer: { left: 300, right: 900, height: 300 } }; + expect( + resizePreviewMiniPlayer({ + start, + direction: "east", + delta: { x: 10, y: 0 }, + source: phone, + container, + obstacles: beside, + }), + ).toEqual({ x: 12, y: 100, width: 250, height: 541 }); + // The same drag with the composer under the player is still held above it. + expect( + resizePreviewMiniPlayer({ + start: { ...start, x: 400 }, + direction: "east", + delta: { x: 10, y: 0 }, + source: phone, + container, + obstacles: beside, + }), + ).toMatchObject({ height: 376 }); + }); + + it("stops growing downward at the composer beneath the player's columns", () => { + expect( + resizePreviewMiniPlayer({ + start: { x: 300, y: 100, width: 320, height: 200 }, + direction: "south", + delta: { x: 0, y: 400 }, + source, + container, + obstacles: tallComposer, + }), + ).toEqual({ x: 300, y: 100, width: 461, height: 288 }); + }); + it("respects the minimum size", () => { expect( resizePreviewMiniPlayer({ @@ -213,20 +284,53 @@ describe("resizePreviewMiniPlayer", () => { }); describe("clampPreviewMiniPlayerPosition", () => { + const player = { width: 360, height: 240 }; + it("keeps a dragged player within the chat viewport", () => { + expect(clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, player)).toEqual({ + x: 628, + y: gap, + }); + }); + + it("keeps the player above a growing composer", () => { expect( - clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, { width: 360, height: 240 }), - ).toEqual({ x: 628, y: PREVIEW_MINI_PLAYER_EDGE_GAP }); + clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, { + composer: { ...composer, height: 160 }, + }), + ).toEqual({ x: 500, y: 288 }); }); - it("keeps the player above a growing composer inset", () => { + it("lets the player drop into the margin beside the composer", () => { expect( clampPreviewMiniPlayerPosition( - { x: 500, y: 448 }, + { x: 20, y: 500 }, container, - { width: 360, height: 240 }, - 160, + { width: 60, height: 150 }, + obstacles, ), - ).toEqual({ x: 500, y: 288 }); + ).toEqual({ x: 20, y: 500 }); + }); + + it("slides sideways past the composer when that is the shorter move", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 850, y: 500 }, + container, + { width: 60, height: 150 }, + obstacles, + ), + ).toEqual({ x: composer.right + gap, y: 500 }); + }); + + it("sits above the composer when it is too wide for either margin", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 100, y: 100 }, + container, + { width: 976, height: 500 }, + obstacles, + ), + ).toEqual({ x: gap, y: 700 - 150 - gap - 500 }); }); }); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 6f9dc41725d0..13ba26f06fca 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -77,12 +77,55 @@ export function resolveDeviceMiniPlayerCornerRadius( ); } +interface HorizontalSpan { + readonly left: number; + readonly right: number; +} + +/** + * The composer stack docked to the bottom edge, in container coordinates. It + * only reserves the columns it covers, so the margins beside it stay open all + * the way down. + */ +export interface PreviewMiniPlayerObstacles { + readonly composer: (HorizontalSpan & { readonly height: number }) | null; +} + +export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null }; + +const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width }); + +const spansOverlap = (a: HorizontalSpan, b: HorizontalSpan) => a.left < b.right && a.right > b.left; + +/** The lowest row (before the edge gap) open to a player covering these columns. */ +function floorFor( + span: HorizontalSpan, + container: PreviewMiniPlayerSize, + obstacles: PreviewMiniPlayerObstacles, +): number { + const { composer } = obstacles; + return composer && spansOverlap(span, composer) + ? container.height - Math.max(0, composer.height) + : container.height; +} + +/** + * The box a stored size is fitted into. A player with a position keeps the + * rows its own columns have, so a tall frame parked beside the composer + * survives the next layout pass; without one it takes the rows above the + * composer, which every column has. + */ const availableArea = ( container: PreviewMiniPlayerSize, - bottomInset: number, + obstacles: PreviewMiniPlayerObstacles, + span: HorizontalSpan | null, ): PreviewMiniPlayerSize => ({ width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, - height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, + height: + (span + ? floorFor(span, container, obstacles) + : container.height - Math.max(0, obstacles.composer?.height ?? 0)) - + PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }); /** @@ -117,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { ); } +const clampToContainer = ( + position: PreviewMiniPlayerPosition, + container: PreviewMiniPlayerSize, + player: PreviewMiniPlayerSize, + bottom = container.height, +): PreviewMiniPlayerPosition => ({ + x: Math.min( + Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), + Math.max( + PREVIEW_MINI_PLAYER_EDGE_GAP, + container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, + ), + ), + y: Math.min( + Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), + Math.max(PREVIEW_MINI_PLAYER_EDGE_GAP, bottom - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP), + ), +}); + +const overlapsObstacle = ( + position: PreviewMiniPlayerPosition, + player: PreviewMiniPlayerSize, + container: PreviewMiniPlayerSize, + obstacles: PreviewMiniPlayerObstacles, +): boolean => + position.y + player.height > floorFor(spanOf(position.x, player.width), container, obstacles); + +/** + * Keeps the player inside the container and off the composer. An overlapping + * player is pushed out along whichever side needs the smaller move, so a drag + * slides along the composer into the margin beside it instead of stopping at + * its top edge; when no side leaves it fully clear it sits above the composer. + */ export function clampPreviewMiniPlayerPosition( position: PreviewMiniPlayerPosition, container: PreviewMiniPlayerSize, player: PreviewMiniPlayerSize, - bottomInset = 0, + obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, ): PreviewMiniPlayerPosition { - const reservedBottomSpace = Math.max(0, bottomInset); - const maxX = Math.max( - PREVIEW_MINI_PLAYER_EDGE_GAP, - container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, - ); - const maxY = Math.max( - PREVIEW_MINI_PLAYER_EDGE_GAP, - container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP, - ); - return { - x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX), - y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY), - }; + const inside = clampToContainer(position, container, player); + const { composer } = obstacles; + if (!composer || !overlapsObstacle(inside, player, container, obstacles)) return inside; + const gap = PREVIEW_MINI_PLAYER_EDGE_GAP; + const above = { x: inside.x, y: container.height - composer.height - gap - player.height }; + const beside = [ + { x: composer.left - gap - player.width, y: inside.y }, + { x: composer.right + gap, y: inside.y }, + ]; + let best = clampToContainer(above, container, player); + let bestDistance = Math.abs(best.y - inside.y); + for (const candidate of beside) { + const clamped = clampToContainer(candidate, container, player); + if (clamped.x !== candidate.x || overlapsObstacle(candidate, player, container, obstacles)) { + continue; + } + const distance = Math.abs(candidate.x - inside.x); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return best; } /** @@ -149,19 +235,25 @@ export function resolvePreviewMiniPlayerFrame(input: { readonly position: PreviewMiniPlayerPosition | null; readonly source: PreviewMiniPlayerSize; readonly container: PreviewMiniPlayerSize; - readonly bottomInset?: number; + readonly obstacles?: PreviewMiniPlayerObstacles; }): PreviewMiniPlayerFrame { - const { width, position, source, container, bottomInset = 0 } = input; + const { + width, + position, + source, + container, + obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, + } = input; const size = fitPreviewMiniPlayerWidth( width ?? defaultPreviewMiniPlayerWidth(source), source, - availableArea(container, bottomInset), + availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null), ); const anchored = position ?? { x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, y: PREVIEW_MINI_PLAYER_EDGE_GAP, }; - return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size }; + return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size }; } /** @@ -177,27 +269,37 @@ export function resizePreviewMiniPlayer(input: { readonly delta: PreviewMiniPlayerPosition; readonly source: PreviewMiniPlayerSize; readonly container: PreviewMiniPlayerSize; - readonly bottomInset?: number; + readonly obstacles?: PreviewMiniPlayerObstacles; }): PreviewMiniPlayerFrame { - const { start, direction, delta, source, container, bottomInset = 0 } = input; + const { + start, + direction, + delta, + source, + container, + obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, + } = input; const east = direction.includes("east"); const west = direction.includes("west"); const north = direction.includes("north"); const south = direction.includes("south"); - const available = availableArea(container, bottomInset); const right = start.x + start.width; const bottom = start.y + start.height; + // Growth stops where the player's current columns meet the composer, and a + // plain edge drag lets the free axis use everything those columns have. A + // wider player may reach new columns; the clamp below slides it clear. + const floor = floorFor(spanOf(start.x, start.width), container, obstacles); const max = { width: west ? right - PREVIEW_MINI_PLAYER_EDGE_GAP : east ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x - : available.width, + : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, height: north ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP : south - ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y - : available.height, + ? floor - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y + : floor - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }; const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0); const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0); @@ -219,7 +321,7 @@ export function resizePreviewMiniPlayer(input: { { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y }, container, size, - bottomInset, + obstacles, ); return { ...position, ...size }; }