diff --git a/apps/web/src/components/chat/chatCanvasLayout.test.ts b/apps/web/src/components/chat/chatCanvasLayout.test.ts index 748d8abb5d1ac..c016861225e08 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 }, }; @@ -64,13 +64,94 @@ 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!; @@ -94,13 +175,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); }); @@ -128,7 +207,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, @@ -141,8 +220,8 @@ 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("moves the chat left only as far as a details card it would crowd requires", () => { @@ -175,9 +254,9 @@ describe("chat canvas layout", () => { expect(Math.abs(leftAt(width + 1) - leftAt(width))).toBeLessThanOrEqual(1); } }); - 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, @@ -185,20 +264,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, @@ -208,14 +290,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) => { @@ -237,13 +319,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); @@ -276,7 +358,6 @@ describe("chat canvas layout", () => { width: resized.width, position: resized, source, - lastInteraction: "resize", }, }); }; @@ -307,7 +388,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, @@ -317,7 +398,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 77e276ee817f4..1be526a256145 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.ts @@ -1,5 +1,6 @@ import { clampPreviewMiniPlayerPosition, + PREVIEW_MINI_PLAYER_MIN_SIZE, resolvePreviewMiniPlayerFrame, type PreviewMiniPlayerFrame, type PreviewMiniPlayerObstacles, @@ -56,15 +57,20 @@ export function resolveChatCanvasLayout({ let frame: PreviewMiniPlayerFrame | null = null; let overlapsChat = false; if (preview && container.width > 0 && container.height > 0) { + const explicitlySized = preview.width !== null; + 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( @@ -96,7 +102,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( @@ -110,6 +116,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); @@ -127,7 +134,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/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index 4d9eff507ed37..5181d3d9d92b5 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -35,6 +35,57 @@ 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.each([ + { source, width: 240 }, + { 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( + 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 f66183c9fcb12..3675d568802f2 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 {} @@ -108,10 +108,7 @@ const availableArea = ( }); const minimumPreviewMiniPlayerWidth = (source: PreviewMiniPlayerSize) => - Math.max( - PREVIEW_MINI_PLAYER_MIN_SIZE.width, - (PREVIEW_MINI_PLAYER_MIN_SIZE.height * source.width) / source.height, - ); + (PREVIEW_MINI_PLAYER_MIN_SIZE.height * source.width) / source.height; function fitPreviewMiniPlayerWidth( desiredWidth: number, diff --git a/apps/web/src/previewMiniPlayerStore.test.ts b/apps/web/src/previewMiniPlayerStore.test.ts index 538fdc11e6602..66e891315b5b2 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);