Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10199,6 +10199,7 @@ export default function ChatView(props: ChatViewProps) {
threadRef={activeThreadRef}
miniPlayer={activePreviewMiniPlayer}
composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
detailsPanelOpen={inlineThreadPanelOpen}
/>
) : null}

Expand Down
75 changes: 61 additions & 14 deletions apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,34 +64,56 @@ interface Props {
readonly miniPlayer: PreviewMiniPlayerState;
/** The docked composer overlay; null while the composer floats mid-screen. */
readonly composerOverlayElement: HTMLElement | null;
/** Whether the inline thread-details card is open in the chat column. */
readonly detailsPanelOpen: boolean;
}

interface Layout {
readonly container: PreviewMiniPlayerSize;
readonly obstacles: PreviewMiniPlayerObstacles;
}

const sameSpan = <T extends { readonly left: number; readonly right: number }>(
a: T | null,
b: T | null,
extent: keyof T,
) =>
a === b ||
(a !== null && b !== null && a.left === b.left && a.right === b.right && a[extent] === b[extent]);

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));
sameSpan(a.obstacles.composer, b.obstacles.composer, "height") &&
sameSpan(a.obstacles.detailsCard, b.obstacles.detailsCard, "bottom");

/**
* The inline thread-details card lives in the aside beside the chat column,
* so it is found from the column's nearest ancestor that reserves room for it.
*/
function findDetailsCard(container: HTMLElement): HTMLElement | null {
const card = container
.closest('[data-thread-details-inline-reserved="true"]')
?.querySelector('[data-thread-details-panel="inline"] [data-thread-details-card]');
return card instanceof HTMLElement ? card : null;
}

/**
* 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.
* Measures the chat column, the composer, and the details card 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 {
function measureLayout(
container: HTMLElement,
composerOverlay: HTMLElement | null,
detailsCard: HTMLElement | null,
): Layout {
const containerRect = container.getBoundingClientRect();
const stackRect = composerOverlay
?.querySelector('[data-chat-composer-stack="true"]')
?.getBoundingClientRect();
const overlayRect = composerOverlay?.getBoundingClientRect();
const cardRect = detailsCard?.getBoundingClientRect();
return {
container: { width: container.clientWidth, height: container.clientHeight },
obstacles: {
Expand All @@ -103,6 +125,14 @@ function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | nu
height: Math.ceil(overlayRect.height),
}
: null,
detailsCard:
cardRect && cardRect.height > 0
? {
left: Math.floor(cardRect.left - containerRect.left),
right: Math.ceil(cardRect.right - containerRect.left),
bottom: Math.ceil(cardRect.bottom - containerRect.top),
}
: null,
},
};
}
Expand All @@ -125,7 +155,12 @@ const RESIZE_HANDLES: ReadonlyArray<{
];

/** Floats the thread's browser tab or device stream over chat. */
export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) {
export function ThreadPreviewMiniPlayer({
threadRef,
miniPlayer,
composerOverlayElement,
detailsPanelOpen,
}: Props) {
const { source } = miniPlayer;
return source.kind === "browser" ? (
<BrowserMiniPlayer
Expand All @@ -134,6 +169,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlay
tabId={source.tabId}
miniPlayer={miniPlayer}
composerOverlayElement={composerOverlayElement}
detailsPanelOpen={detailsPanelOpen}
/>
) : (
<DeviceMiniPlayer
Expand All @@ -142,6 +178,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlay
source={source}
miniPlayer={miniPlayer}
composerOverlayElement={composerOverlayElement}
detailsPanelOpen={detailsPanelOpen}
/>
);
}
Expand All @@ -151,6 +188,7 @@ function BrowserMiniPlayer({
tabId,
miniPlayer,
composerOverlayElement,
detailsPanelOpen,
}: Props & { readonly tabId: string }) {
const previewState = useThreadPreviewState(threadRef);
const snapshot = previewState.sessions[tabId] ?? null;
Expand Down Expand Up @@ -196,6 +234,7 @@ function BrowserMiniPlayer({
miniPlayer={miniPlayer}
sourceSize={sourceSize}
composerOverlayElement={composerOverlayElement}
detailsPanelOpen={detailsPanelOpen}
label="Floating browser preview"
recording={recording}
onOpenInPanel={openInPanel}
Expand Down Expand Up @@ -254,6 +293,7 @@ function DeviceMiniPlayer({
source,
miniPlayer,
composerOverlayElement,
detailsPanelOpen,
}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {
const { state: deviceState } = useDeviceState(threadRef.environmentId);
const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
Expand Down Expand Up @@ -284,6 +324,7 @@ function DeviceMiniPlayer({
miniPlayer={miniPlayer}
sourceSize={sourceSize}
composerOverlayElement={composerOverlayElement}
detailsPanelOpen={detailsPanelOpen}
label="Floating device preview"
onOpenInPanel={openInPanel}
cornerRadius={cornerRadius}
Expand Down Expand Up @@ -320,6 +361,7 @@ function MiniPlayerShell({
miniPlayer,
sourceSize,
composerOverlayElement,
detailsPanelOpen,
label,
onOpenInPanel,
pillActions,
Expand All @@ -331,6 +373,7 @@ function MiniPlayerShell({
readonly miniPlayer: PreviewMiniPlayerState;
readonly sourceSize: PreviewMiniPlayerSize;
readonly composerOverlayElement: HTMLElement | null;
readonly detailsPanelOpen: boolean;
readonly label: string;
readonly onOpenInPanel: () => void;
readonly pillActions?: ReactNode;
Expand Down Expand Up @@ -363,21 +406,25 @@ function MiniPlayerShell({
usePreviewMiniPlayerStore.getState().close(threadRef);
};

// The composer grows on its own (drafts, banners), so it is observed alongside the column.
// The composer and the details card grow on their own (drafts, banners,
// workspace rows), so both are observed alongside the column. The card is
// looked up when the panel opens; the flag re-runs this on toggle.
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
const detailsCard = detailsPanelOpen ? findDetailsCard(element) : null;
const measure = () => {
const next = measureLayout(element, composerOverlayElement);
const next = measureLayout(element, composerOverlayElement, detailsCard);
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);
if (detailsCard) observer.observe(detailsCard);
return () => observer.disconnect();
}, [composerOverlayElement]);
}, [composerOverlayElement, detailsPanelOpen]);

const beginGesture = (
event: ReactPointerEvent<HTMLElement>,
Expand Down
121 changes: 99 additions & 22 deletions apps/web/src/components/preview/previewMiniPlayerLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test";

import {
clampPreviewMiniPlayerPosition,
defaultPreviewMiniPlayerPosition,
PREVIEW_MINI_PLAYER_EDGE_GAP,
type PreviewMiniPlayerObstacles,
resizePreviewMiniPlayer,
Expand All @@ -18,8 +17,14 @@ 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 } };
const obstacles: PreviewMiniPlayerObstacles = { composer, detailsCard: null };
const tallComposer: PreviewMiniPlayerObstacles = {
composer: { ...composer, height: 300 },
detailsCard: null,
};
// The inline details card docked to the top-right corner.
const detailsCard = { left: 680, right: 988, bottom: 260 };
const withCard: PreviewMiniPlayerObstacles = { composer, detailsCard };

describe("resolvePreviewMiniPlayerSourceSize", () => {
it("uses the device viewport scaled by zoom", () => {
Expand Down Expand Up @@ -122,7 +127,7 @@ describe("resolvePreviewMiniPlayerFrame", () => {
// 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 beside = { composer: { left: 300, right: 900, height: 300 }, detailsCard: null };
const resized = resizePreviewMiniPlayer({
start: { x: 12, y: 100, width: 240, height: 519 },
direction: "east",
Expand Down Expand Up @@ -234,7 +239,7 @@ describe("resizePreviewMiniPlayer", () => {
// 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 } };
const beside = { composer: { left: 300, right: 900, height: 300 }, detailsCard: null };
expect(
resizePreviewMiniPlayer({
start,
Expand Down Expand Up @@ -284,29 +289,49 @@ describe("resizePreviewMiniPlayer", () => {
});
});

describe("defaultPreviewMiniPlayerPosition", () => {
it("keeps the top-right fallback when the inline details panel is closed", () => {
describe("resolvePreviewMiniPlayerFrame with the details card", () => {
it("opens under the inline details card with right edges aligned", () => {
expect(
defaultPreviewMiniPlayerPosition({
fallback: { x: 664, y: 16 },
parentRect: { left: 300, top: 60 },
playerWidth: 320,
detailsCardRect: null,
resolvePreviewMiniPlayerFrame({
width: null,
position: null,
source,
container,
obstacles: withCard,
}),
).toEqual({ x: 664, y: 16 });
).toEqual({ x: 988 - 320, y: 260 + gap, width: 320, height: 200 });
});

it("slides under the inline details card with right edges aligned", () => {
it("opens left of the card when it cannot fit beneath it", () => {
// A tall card leaves too few rows above the composer for a full player.
const tallCard = { ...withCard, detailsCard: { ...detailsCard, bottom: 480 } };
expect(
defaultPreviewMiniPlayerPosition({
fallback: { x: 664, y: 16 },
parentRect: { left: 300, top: 60 },
playerWidth: 320,
detailsCardRect: { right: 1280, bottom: 320 },
resolvePreviewMiniPlayerFrame({
width: null,
position: null,
source,
container,
obstacles: tallCard,
}),
).toEqual({
x: 1280 - 300 - 320,
y: 320 - 60 + PREVIEW_MINI_PLAYER_EDGE_GAP,
).toEqual({ x: 680 - gap - 320, y: 700 - 150 - gap - 200, width: 320, height: 200 });
});

it("keeps a stored player's size when the card covers its columns", () => {
// The card leaves ~290 rows beneath it; fitting into them would collapse
// a 480-wide player to ~1px tall. The frame keeps its size and the clamp
// relocates it to the open columns left of the card.
const frame = resolvePreviewMiniPlayerFrame({
width: 480,
position: { x: 700, y: 280 },
source,
container,
obstacles: withCard,
});
expect(frame).toEqual({
x: 680 - gap - 480,
y: 700 - 150 - gap - 300,
width: 480,
height: 300,
});
});
});
Expand All @@ -325,6 +350,7 @@ describe("clampPreviewMiniPlayerPosition", () => {
expect(
clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, {
composer: { ...composer, height: 160 },
detailsCard: null,
}),
).toEqual({ x: 500, y: 288 });
});
Expand All @@ -351,6 +377,57 @@ describe("clampPreviewMiniPlayerPosition", () => {
).toEqual({ x: composer.right + gap, y: 500 });
});

it("keeps the player out from under the details card", () => {
// Dragged up into the card's columns: pushed down to its bottom edge.
expect(clampPreviewMiniPlayerPosition({ x: 700, y: 40 }, container, player, withCard)).toEqual({
x: 628,
y: 260 + gap,
});
});

it("slides sideways out from under the card when that is the shorter move", () => {
expect(
clampPreviewMiniPlayerPosition(
{ x: 660, y: 40 },
container,
{ width: 60, height: 150 },
withCard,
),
).toEqual({ x: 680 - gap - 60, y: 40 });
});

it("clears the card and the composer together in a tight column", () => {
// Its own columns have too few rows between the card and the composer,
// so it slides left of the card, where the rows above the composer suffice.
const tight = { composer: { left: 0, right: 1_000, height: 300 }, detailsCard };
expect(clampPreviewMiniPlayerPosition({ x: 700, y: 600 }, container, player, tight)).toEqual({
x: 680 - gap - 360,
y: 700 - 300 - gap - 240,
});
// Too tall for any column: the card wins and the player sits below it.
expect(
clampPreviewMiniPlayerPosition(
{ x: 700, y: 600 },
container,
{ width: 360, height: 400 },
tight,
),
).toEqual({ x: 628, y: 260 + gap });
});

it("stays inside the container when a full-width card leaves no open rows", () => {
// A card reaching past the player's last row would push the fallback
// below the container; the clamp keeps the bottom edge visible instead.
const wall = {
composer,
detailsCard: { left: 0, right: 1_000, bottom: 690 },
};
expect(clampPreviewMiniPlayerPosition({ x: 300, y: 500 }, container, player, wall)).toEqual({
x: 300,
y: 700 - gap - player.height,
});
});

it("sits above the composer when it is too wide for either margin", () => {
expect(
clampPreviewMiniPlayerPosition(
Expand Down
Loading
Loading