diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 783d86a8ee25e..24f1df64de376 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -43,7 +43,6 @@ export function ChatCanvas({ 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 && diff --git a/apps/web/src/components/chat/chatCanvasLayout.test.ts b/apps/web/src/components/chat/chatCanvasLayout.test.ts index 1a39fc04baa53..fd824b5541f1a 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.test.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.test.ts @@ -8,7 +8,7 @@ import { const preview: ChatCanvasPreview = { key: "browser:one", - width: 320, + width: null, position: null, source: { width: 1600, height: 1000 }, }; @@ -23,13 +23,94 @@ const expectClear = (result: ReturnType) => { }; describe("chat canvas layout", () => { + it.each([ + { canvasWidth: 744, previewWidth: 240 }, + { canvasWidth: 900, previewWidth: null }, + { canvasWidth: 900, previewWidth: 240 }, + { canvasWidth: 952, previewWidth: 400 }, + ])( + "keeps chat fixed with a $previewWidth preview in a compact $canvasWidth pixel canvas", + ({ canvasWidth, previewWidth }) => { + const original = resolve(canvasWidth, null).chat; + for (const x of [12, canvasWidth / 2, canvasWidth - 252]) { + const result = resolve(canvasWidth, { + ...preview, + width: previewWidth, + position: { x, y: 200 }, + source: { width: 390, height: 844 }, + }); + expect(result.chat).toEqual(original); + expect(result.overlapsChat).toBe(true); + } + }, + ); + + it("reflows chat once the canvas has room for the minimum side-by-side layout", () => { + const result = resolve(953, { + ...preview, + width: 240, + position: { x: 701, y: 200 }, + }); + expect(result.chat).toMatchObject({ left: 48, width: 641 }); + expectClear(result); + }); + + it.each([1012, 1040, 1100])( + "overlays a preview when details already constrain chat in a %spx canvas", + (width) => { + const layout = (preview: ChatCanvasPreview | null) => + resolveChatCanvasLayout({ + container: { width, height: 900 }, + maxChatWidth: 736, + composerHeight: 180, + detailsCard: { left: width - 292, right: width - 12, bottom: 400 }, + preview, + }); + const original = layout(null).chat; + for (const x of [12, width / 2, width - 412]) { + const result = layout({ + ...preview, + width: 400, + position: { x, y: 200 }, + source: { width: 768, height: 1024 }, + }); + expect(result.chat).toEqual(original); + expect(result.overlapsChat).toBe(true); + } + }, + ); + + it("folds details around a manually dragged preview and expands them after moving away", () => { + const container = { width: 1584, height: 988 }; + const detailsCard = { left: 1292, right: 1572, bottom: 400 }; + const layout = (position: ChatCanvasPreview["position"]) => + resolveChatCanvasLayout({ + container, + detailsCard, + preview: { ...preview, width: null, position }, + }); + const density = (result: ReturnType) => { + const card = resolveThreadDetailsCardLayout({ + container, + lane: { padding: 48, minChatWidth: 640 }, + frame: result.frame, + overlapsDetailsCard: result.overlapsDetailsCard, + })!; + return resolveThreadDetailsCardDensity(card.height, { full: 388, compact: 182 }); + }; + const dragged = layout({ x: 1252, y: 275 }); + expect(dragged.frame).toEqual({ x: 1252, y: 275, width: 320, height: 200 }); + expect(density(dragged)).toBe("compact"); + expect(density(layout({ x: 1252, y: 500 }))).toBe("full"); + expect(layout(null).overlapsDetailsCard).toBe(false); + }); + 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!; @@ -53,13 +134,11 @@ describe("chat canvas layout", () => { const moved = resolve(width, { ...preview, position: { x: start.x - 300, y: start.y }, - lastInteraction: "drag", }); expect(moved.frame).toEqual({ ...start, x: start.x - 300 }); const returned = resolve(width, { ...preview, position: { x: start.x, y: start.y }, - lastInteraction: "drag", }); expect(returned.frame).toEqual(start); }); @@ -87,7 +166,7 @@ describe("chat canvas layout", () => { expect(resolve(1800, player).chat.width).toBe(768); expect(player.width).toBe(480); }); - it("constrains a dragged preview below the full top-right card instead of folding it", () => { + it("folds the top-right card when a dragged preview crosses it", () => { const result = resolveChatCanvasLayout({ container: { width: 1584, height: 988 }, maxChatWidth: 736, @@ -100,13 +179,13 @@ describe("chat canvas layout", () => { position: { x: 1260, y: 240 }, }, }); - expect(result.frame).toEqual({ x: 1260, y: 351, width: 240, height: 365 }); - expect(result.overlapsDetailsCard).toBe(false); + expect(result.frame).toEqual({ x: 1260, y: 240, width: 240, height: 365 }); + expect(result.overlapsDetailsCard).toBe(true); expect(result.chat).toEqual({ left: 424, width: 736, insetStart: 0, insetEnd: 0 }); }); - it("keeps a dragged or resized preview below the open find bar", () => { + it("keeps default-sized and explicitly sized previews below the open find bar", () => { const findBar = { left: 1292, right: 1572, bottom: 48 }; - const layout = (lastInteraction: "drag" | "resize") => + const layout = (width: number | null) => resolveChatCanvasLayout({ container: { width: 1584, height: 988 }, maxChatWidth: 736, @@ -114,20 +193,23 @@ describe("chat canvas layout", () => { findBar, preview: { ...preview, - width: 240, + width, source: { width: 240, height: 365 }, position: { x: 1332, y: 12 }, - lastInteraction, }, }); - for (const lastInteraction of ["drag", "resize"] as const) { - const result = layout(lastInteraction); - expect(result.frame).toEqual({ x: 1332, y: 60, width: 240, height: 365 }); + for (const width of [null, 240]) { + const result = layout(width); + expect(result.frame).toEqual({ + x: 1332, + y: 60, + ...(width === null ? { width: 210, height: 320 } : { 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", () => { + it("opens beside the full card if a tall preview cannot fit below it", () => { const result = resolveChatCanvasLayout({ container: { width: 1584, height: 988 }, maxChatWidth: 736, @@ -137,14 +219,14 @@ describe("chat canvas layout", () => { ...preview, width: 240, source: { width: 240, height: 700 }, - position: { x: 1260, y: 100 }, + position: null, }, }); - expect(result.frame).toEqual({ x: 1008, y: 100, width: 240, height: 700 }); + expect(result.frame).toEqual({ x: 1008, y: 276, 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", () => { + it("preserves the chosen size while folding the card and keeping chat readable", () => { const container = { width: 1584, height: 988 }; const source = { width: 1000, height: 1523 }; const layout = (width: number) => { @@ -166,13 +248,13 @@ describe("chat canvas layout", () => { const shifted = layout(460); expect(shifted.frame!.width).toBe(460); expect(shifted.chat.left).toBeLessThan(424); - expect(shifted.overlapsDetailsCard).toBe(false); + expect(shifted.overlapsDetailsCard).toBe(true); expectClear(shifted); const narrowed = layout(532); expect(narrowed.frame!.width).toBe(532); - expect(narrowed.chat.width).toBe(656); - expect(narrowed.overlapsDetailsCard).toBe(false); + expect(narrowed.chat.width).toBe(736); + expect(narrowed.overlapsDetailsCard).toBe(true); expectClear(narrowed); const grown = layout(600); @@ -205,7 +287,6 @@ describe("chat canvas layout", () => { width: resized.width, position: resized, source, - lastInteraction: "resize", }, }); }; @@ -236,7 +317,7 @@ describe("chat canvas layout", () => { expect(density(layout(400))).toBe("full"); }, ); - it("requests card folding only when no full-card slot fits and keeps the composer clear", () => { + it("folds details around an oversized preview while keeping the composer clear", () => { const result = resolveChatCanvasLayout({ container: { width: 1584, height: 988 }, maxChatWidth: 736, @@ -246,7 +327,7 @@ describe("chat canvas layout", () => { ...preview, width: 1300, source: { width: 1300, height: 400 }, - position: { x: 12, y: 12 }, + position: { x: 12, y: 700 }, }, }); expect(result.overlapsDetailsCard).toBe(true); diff --git a/apps/web/src/components/chat/chatCanvasLayout.ts b/apps/web/src/components/chat/chatCanvasLayout.ts index 126319cb1334e..399d13205a218 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.ts @@ -1,21 +1,17 @@ import { clampPreviewMiniPlayerPosition, + PREVIEW_MINI_PLAYER_MIN_SIZE, resolvePreviewMiniPlayerFrame, type PreviewMiniPlayerFrame, type PreviewMiniPlayerObstacles, } from "../preview/previewMiniPlayerLayout"; -import type { - PreviewMiniPlayerPosition, - PreviewMiniPlayerSize, - PreviewMiniPlayerState, -} from "~/previewMiniPlayerStore"; +import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } 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; @@ -58,15 +54,21 @@ export function resolveChatCanvasLayout({ let frame: PreviewMiniPlayerFrame | null = null; let overlapsChat = false; if (preview && container.width > 0 && container.height > 0) { + const explicitlySized = preview.width !== null; + // Smaller portrait thumbnails must not lower the compact overlay breakpoint. + const canReflowChat = + normalWidth === centeredWidth && + container.width > padding + minChatWidth + PREVIEW_MINI_PLAYER_MIN_SIZE.width + GAP * 2; frame = resolvePreviewMiniPlayerFrame({ ...preview, container }); // Reserve a chat lane only if the preview still has room to drag. const minimumPreviewX = - preview.lastInteraction === "resize" || + explicitlySized || + !canReflowChat || container.width <= padding + minChatWidth + frame.width + GAP * 2 ? GAP : padding + minChatWidth + GAP; if (preview.position === null) frame = { ...frame, y: container.height - frame.height - GAP }; - if (preview.lastInteraction === "resize" && composerHeight > 0) { + if (explicitlySized && composerHeight > 0) { const laneWidth = Math.min(normalWidth, minChatWidth); const transitionWidth = Math.max(GAP, normalWidth - laneWidth); const lift = Math.min( @@ -98,7 +100,7 @@ export function resolveChatCanvasLayout({ bottom: Math.max(detailsCard.bottom, findBar.bottom), } : (detailsCard ?? findBar); - const cardObstacle = preview.lastInteraction === "resize" ? findBar : dragObstacle; + const cardObstacle = preview.position === null ? dragObstacle : findBar; frame = { ...frame, ...clampPreviewMiniPlayerPosition( @@ -112,6 +114,7 @@ export function resolveChatCanvasLayout({ const chatBeside = (player: PreviewMiniPlayerFrame) => { const normalRight = normalLeft + normalWidth; if (player.x >= normalRight + GAP) return chat; + if (!canReflowChat) return null; const width = Math.min(maxChatWidth, player.x - GAP - padding); if (width < minChatWidth) return null; const left = Math.min(normalLeft, player.x - GAP - width); @@ -129,7 +132,7 @@ export function resolveChatCanvasLayout({ } if (nextChat) chat = nextChat; else overlapsChat = true; - if (overlapsChat && preview.lastInteraction === "resize") { + if (overlapsChat && explicitlySized && canReflowChat) { const width = Math.min(normalWidth, minChatWidth); chat = { left: padding, diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index bf880bf253ac8..31f3a5b3973b3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -365,18 +365,9 @@ function MiniPlayerShell({ 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, - ]); + }, [reportPreview, sourceKey, miniPlayer.width, miniPlayer.position, sourceWidth, sourceHeight]); useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index 4d9eff507ed37..3dc7e633e2177 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -35,6 +35,65 @@ describe("resolvePreviewMiniPlayerSourceSize", () => { }); describe("resolvePreviewMiniPlayerFrame", () => { + it("fits a portrait phone's default preview inside the default box", () => { + expect( + resolvePreviewMiniPlayerFrame({ + width: null, + position: null, + source: { width: 390, height: 844 }, + container, + }), + ).toMatchObject({ width: 148, height: 320 }); + }); + + it.each([ + { width: 390, height: 844 }, + { width: 768, height: 1024 }, + { width: 1024, height: 768 }, + ])("lets a $width×$height device shrink below 240 pixels wide", (source) => { + const requestedWidth = source.width > source.height ? 210 : 140; + const expected = { + width: requestedWidth, + height: Math.round((requestedWidth * source.height) / source.width), + }; + expect( + resolvePreviewMiniPlayerFrame({ width: requestedWidth, position: null, source, container }), + ).toMatchObject(expected); + expect( + resizePreviewMiniPlayer({ + start: { x: 12, y: 12, width: 240, height: (240 * source.height) / source.width }, + direction: "east", + delta: { x: requestedWidth - 240, y: 0 }, + source, + container, + }), + ).toMatchObject(expected); + }); + + it("retains the 240×150 minimum for a 16:10 preview", () => { + expect( + resolvePreviewMiniPlayerFrame({ width: 1, position: null, source, container }), + ).toMatchObject({ width: 240, height: 150 }); + }); + + it.each([ + { source: { width: 1600, height: 900 }, width: 267 }, + { source: { width: 2100, height: 900 }, width: 350 }, + ])("keeps wide previews at least 150 pixels tall", ({ source, width }) => { + expect( + resolvePreviewMiniPlayerFrame({ width: 1, position: null, source, container }), + ).toMatchObject({ width, height: 150 }); + expect( + resizePreviewMiniPlayer({ + start: { x: 12, y: 12, width: 400, height: (400 * source.height) / source.width }, + direction: "east", + delta: { x: -399, y: 0 }, + source, + container, + }), + ).toMatchObject({ width, height: 150 }); + }); + it("shrinks to the space above the composer without losing the stored width", () => { const frame = resolvePreviewMiniPlayerFrame({ width: 800, diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index c21f2fe22d1cd..c411c2c3fedff 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -14,7 +14,7 @@ export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; // The mini-player shell straddles this webview at 47 and 49: above --z-sheet, under dialogs (50). export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const; -const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; +export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; export interface PreviewMiniPlayerFrame extends PreviewMiniPlayerPosition, PreviewMiniPlayerSize {} @@ -114,11 +114,7 @@ function fitPreviewMiniPlayerWidth( ): PreviewMiniPlayerSize { const aspectRatio = source.width / source.height; const width = Math.min( - Math.max( - desiredWidth, - PREVIEW_MINI_PLAYER_MIN_SIZE.width, - PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio, - ), + Math.max(desiredWidth, PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio), Math.max(1, max.width), Math.max(1, max.height * aspectRatio), ); diff --git a/apps/web/src/previewMiniPlayerStore.test.ts b/apps/web/src/previewMiniPlayerStore.test.ts index 538fdc11e6602..50b084e3d038f 100644 --- a/apps/web/src/previewMiniPlayerStore.test.ts +++ b/apps/web/src/previewMiniPlayerStore.test.ts @@ -7,6 +7,11 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "./previewMiniPlayerStore"; +import { resolveChatCanvasLayout } from "./components/chat/chatCanvasLayout"; +import { + resolveThreadDetailsCardDensity, + resolveThreadDetailsCardLayout, +} from "./components/chat/threadDetailsCardLayout"; const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); const tabA = browserMiniPlayerSource("tab-a"); @@ -17,6 +22,40 @@ beforeEach(() => { }); describe("previewMiniPlayerStore", () => { + it("keeps a resized preview and folded details card in place when dragging starts", () => { + const store = usePreviewMiniPlayerStore.getState(); + store.open(refA, tabA); + store.resize(refA, "browser:tab-a", 460, { x: 1112, y: 275 }); + const container = { width: 1584, height: 988 }; + const layout = () => { + const player = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + refA, + )!; + return resolveChatCanvasLayout({ + container, + maxChatWidth: 736, + composerHeight: 206, + detailsCard: { left: 1260, right: 1572, bottom: 339 }, + preview: { ...player, key: "browser:tab-a", source: { width: 1000, height: 1523 } }, + }); + }; + const resized = layout(); + expect(resized.frame).toEqual({ x: 1112, y: 275, width: 460, height: 701 }); + store.move(refA, "browser:tab-a", { x: 1106, y: 275 }); + const dragged = layout(); + expect(dragged.frame).toEqual({ ...resized.frame, x: 1106 }); + const card = resolveThreadDetailsCardLayout({ + container, + lane: { padding: 48, minChatWidth: 640 }, + frame: dragged.frame, + overlapsDetailsCard: dragged.overlapsDetailsCard, + })!; + expect(resolveThreadDetailsCardDensity(card.height, { full: 327, compact: 182 })).toBe( + "compact", + ); + }); + it("ignores stale drag updates after the floating tab changes", () => { usePreviewMiniPlayerStore.getState().open(refA, tabA); usePreviewMiniPlayerStore.getState().open(refA, tabB); @@ -28,7 +67,6 @@ describe("previewMiniPlayerStore", () => { source: tabB, position: null, width: null, - lastInteraction: "drag", }); }); }); diff --git a/apps/web/src/previewMiniPlayerStore.ts b/apps/web/src/previewMiniPlayerStore.ts index bdbb9b1ca876f..d28a03237b63a 100644 --- a/apps/web/src/previewMiniPlayerStore.ts +++ b/apps/web/src/previewMiniPlayerStore.ts @@ -26,7 +26,6 @@ export interface PreviewMiniPlayerState { readonly source: PreviewMiniPlayerSource; readonly position: PreviewMiniPlayerPosition | null; readonly width: number | null; - readonly lastInteraction: "drag" | "resize"; } interface PreviewMiniPlayerStoreState { @@ -78,7 +77,6 @@ export const usePreviewMiniPlayerStore = create()(( source, position: current?.position ?? null, width: current?.width ?? null, - lastInteraction: current?.lastInteraction ?? "drag", }, }, }; @@ -95,16 +93,11 @@ export const usePreviewMiniPlayerStore = create()(( const threadKey = scopedThreadKey(ref); const current = state.byThreadKey[threadKey]; if (!current || previewMiniPlayerSourceKey(current.source) !== sourceKey) return state; - if ( - current.position?.x === position.x && - current.position.y === position.y && - current.lastInteraction === "drag" - ) - return state; + if (current.position?.x === position.x && current.position.y === position.y) return state; return { byThreadKey: { ...state.byThreadKey, - [threadKey]: { ...current, position, lastInteraction: "drag" }, + [threadKey]: { ...current, position }, }, }; }), @@ -116,7 +109,6 @@ export const usePreviewMiniPlayerStore = create()(( !current || previewMiniPlayerSourceKey(current.source) !== sourceKey || (current.width === width && - current.lastInteraction === "resize" && (!position || (current.position?.x === position.x && current.position.y === position.y))) ) { return state; @@ -128,7 +120,6 @@ export const usePreviewMiniPlayerStore = create()(( ...current, width, position: position ?? current.position, - lastInteraction: "resize", }, }, };