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
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/canvasStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe("chat canvas model", () => {
measurements: measurements(1100, 300),
detailsCardMeasurement: { threadRef, width: 280, fullContentHeight },
threadRef,
preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" },
preview: { ...preview, position: { x: 760, y: 150 } },
detailsCardInlineOpen: true,
});
const model = resolve(400);
Expand All @@ -62,7 +62,7 @@ describe("chat canvas model", () => {
measurements: measurements(1100, 300),
detailsCardMeasurement: { threadRef: otherThreadRef, width: 280, fullContentHeight: 400 },
threadRef,
preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" },
preview: { ...preview, position: { x: 760, y: 150 } },
detailsCardInlineOpen: true,
});
expect(model.layout.overlapsDetailsCard).toBe(false);
Expand Down
131 changes: 105 additions & 26 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 @@ -43,7 +43,6 @@ describe("chat canvas layout", () => {
...preview,
width: resized.width,
position: resized,
lastInteraction: "resize",
},
height,
).frame!;
Expand All @@ -58,19 +57,99 @@ describe("chat canvas layout", () => {
...preview,
width: resized.width,
position: resized,
lastInteraction: "resize",
}).frame!.width,
).toBe(resized.width);
}
}
});
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 +173,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 +205,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 +218,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 +252,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 +288,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 +317,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 +356,6 @@ describe("chat canvas layout", () => {
width: resized.width,
position: resized,
source,
lastInteraction: "resize",
},
});
};
Expand Down Expand Up @@ -307,7 +386,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 +396,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
22 changes: 12 additions & 10 deletions apps/web/src/components/chat/chatCanvasLayout.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -56,15 +52,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 +97,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 +111,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 +129,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
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ export function useThreadPreviewCanvasSource(
key: sourceKey,
width: miniPlayer.width,
position: miniPlayer.position,
lastInteraction: miniPlayer.lastInteraction,
source:
heldSource ??
(source.kind === "browser"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ describe("settlePreviewMiniPlayerResize", () => {
width: settled!.width,
position: settled,
source,
lastInteraction: "resize",
},
}).frame,
).toEqual(settled);
Expand Down
Loading
Loading