Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 105 additions & 24 deletions apps/web/src/components/chat/chatCanvasLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {

const preview: ChatCanvasPreview = {
key: "browser:one",
width: 320,
width: null,
position: null,
source: { width: 1600, height: 1000 },
};
Expand Down Expand Up @@ -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<typeof layout>) => {
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<typeof resolve> | 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!;
Expand All @@ -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);
});
Expand Down Expand Up @@ -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,
Expand All @@ -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", () => {
Expand Down Expand Up @@ -175,30 +254,33 @@ 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,
composerHeight: 180,
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,
Expand All @@ -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) => {
Expand All @@ -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);
Expand Down Expand Up @@ -276,7 +358,6 @@ describe("chat canvas layout", () => {
width: resized.width,
position: resized,
source,
lastInteraction: "resize",
},
});
};
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/chat/chatCanvasLayout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
clampPreviewMiniPlayerPosition,
PREVIEW_MINI_PLAYER_MIN_SIZE,
resolvePreviewMiniPlayerFrame,
type PreviewMiniPlayerFrame,
type PreviewMiniPlayerObstacles,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -127,7 +134,7 @@ export function resolveChatCanvasLayout({
}
if (nextChat) chat = nextChat;
else overlapsChat = true;
if (overlapsChat && preview.lastInteraction === "resize") {
if (overlapsChat && explicitlySized && canReflowChat) {
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
const width = Math.min(normalWidth, minChatWidth);
chat = {
left: padding,
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/preview/previewMiniPlayerLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/components/preview/previewMiniPlayerLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading