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
14 changes: 5 additions & 9 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1694,8 +1694,6 @@ export default function ChatView(props: ChatViewProps) {
[],
);
const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
const composerOverlayHeightRef = useRef(0);
// Space the timeline keeps clear above its end. Tracks the overlay while the
// composer is expanded and holds that height while it rests, so the resting
// composer never exposes rows that its expansion will cover.
Expand Down Expand Up @@ -5493,11 +5491,6 @@ export default function ChatView(props: ChatViewProps) {
(height: number) => {
const nextHeight = Math.ceil(height);
if (nextHeight <= 0) return;
const previousHeight = composerOverlayHeightRef.current;
if (previousHeight !== nextHeight) {
composerOverlayHeightRef.current = nextHeight;
setComposerOverlayHeight(nextHeight);
}
const nextInset = resolveComposerTimelineInset({
currentInset: composerTimelineInsetRef.current,
overlayHeight: nextHeight,
Expand Down Expand Up @@ -8658,7 +8651,10 @@ export default function ChatView(props: ChatViewProps) {
ref={attachDraftHeroTransitionGroupRef}
className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]"
>
<div className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl">
<div
data-chat-composer-stack="true"
className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl"
>
{isDraftHeroState ? (
<div className="absolute inset-x-0 bottom-full z-0">
<div
Expand Down Expand Up @@ -8873,7 +8869,7 @@ export default function ChatView(props: ChatViewProps) {
key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
threadRef={activeThreadRef}
miniPlayer={activePreviewMiniPlayer}
bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}
composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
) : null}

Expand Down
85 changes: 65 additions & 20 deletions apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ import type { DeviceScreenSize } from "../device/deviceStream";
import { previewBridge } from "./previewBridge";
import {
clampPreviewMiniPlayerPosition,
NO_PREVIEW_MINI_PLAYER_OBSTACLES,
PREVIEW_MINI_PLAYER_CORNER_RADIUS,
PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
type PreviewMiniPlayerFrame,
type PreviewMiniPlayerObstacles,
resizePreviewMiniPlayer,
resolveDeviceMiniPlayerCornerRadius,
resolveDeviceMiniPlayerSourceSize,
Expand All @@ -56,7 +58,49 @@ interface PointerGesture {
interface Props {
readonly threadRef: ScopedThreadRef;
readonly miniPlayer: PreviewMiniPlayerState;
readonly bottomInset: number;
/** The docked composer overlay; null while the composer floats mid-screen. */
readonly composerOverlayElement: HTMLElement | null;
}

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

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));

/**
* 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.
*/
function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | null): Layout {
const containerRect = container.getBoundingClientRect();
const stackRect = composerOverlay
?.querySelector('[data-chat-composer-stack="true"]')
?.getBoundingClientRect();
const overlayRect = composerOverlay?.getBoundingClientRect();
return {
container: { width: container.clientWidth, height: container.clientHeight },
obstacles: {
composer:
overlayRect && stackRect && overlayRect.height > 0
? {
left: Math.floor(stackRect.left - containerRect.left),
right: Math.ceil(stackRect.right - containerRect.left),
height: Math.ceil(overlayRect.height),
}
: null,
},
};
}

const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
Expand All @@ -77,23 +121,23 @@ const RESIZE_HANDLES: ReadonlyArray<{
];

/** Floats the thread's browser tab or device stream over chat. */
export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) {
export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) {
const { source } = miniPlayer;
return source.kind === "browser" ? (
<BrowserMiniPlayer
key={source.tabId}
threadRef={threadRef}
tabId={source.tabId}
miniPlayer={miniPlayer}
bottomInset={bottomInset}
composerOverlayElement={composerOverlayElement}
/>
) : (
<DeviceMiniPlayer
key={previewMiniPlayerSourceKey(source)}
threadRef={threadRef}
source={source}
miniPlayer={miniPlayer}
bottomInset={bottomInset}
composerOverlayElement={composerOverlayElement}
/>
);
}
Expand All @@ -102,7 +146,7 @@ function BrowserMiniPlayer({
threadRef,
tabId,
miniPlayer,
bottomInset,
composerOverlayElement,
}: Props & { readonly tabId: string }) {
const previewState = useThreadPreviewState(threadRef);
const snapshot = previewState.sessions[tabId] ?? null;
Expand Down Expand Up @@ -143,7 +187,7 @@ function BrowserMiniPlayer({
threadRef={threadRef}
miniPlayer={miniPlayer}
sourceSize={sourceSize}
bottomInset={bottomInset}
composerOverlayElement={composerOverlayElement}
label="Floating browser preview"
onOpenInPanel={openInPanel}
pillActions={
Expand Down Expand Up @@ -200,7 +244,7 @@ function DeviceMiniPlayer({
threadRef,
source,
miniPlayer,
bottomInset,
composerOverlayElement,
}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {
const { state: deviceState } = useDeviceState(threadRef.environmentId);
const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
Expand Down Expand Up @@ -230,7 +274,7 @@ function DeviceMiniPlayer({
threadRef={threadRef}
miniPlayer={miniPlayer}
sourceSize={sourceSize}
bottomInset={bottomInset}
composerOverlayElement={composerOverlayElement}
label="Floating device preview"
onOpenInPanel={openInPanel}
cornerRadius={cornerRadius}
Expand Down Expand Up @@ -266,7 +310,7 @@ function MiniPlayerShell({
threadRef,
miniPlayer,
sourceSize,
bottomInset,
composerOverlayElement,
label,
onOpenInPanel,
pillActions,
Expand All @@ -276,7 +320,7 @@ function MiniPlayerShell({
readonly threadRef: ScopedThreadRef;
readonly miniPlayer: PreviewMiniPlayerState;
readonly sourceSize: PreviewMiniPlayerSize;
readonly bottomInset: number;
readonly composerOverlayElement: HTMLElement | null;
readonly label: string;
readonly onOpenInPanel: () => void;
readonly pillActions?: ReactNode;
Expand All @@ -286,15 +330,17 @@ function MiniPlayerShell({
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const gestureRef = useRef<PointerGesture | null>(null);
const [container, setContainer] = useState<PreviewMiniPlayerSize | null>(null);
const [layout, setLayout] = useState<Layout | null>(null);
const container = layout?.container ?? null;
const obstacles = layout?.obstacles ?? NO_PREVIEW_MINI_PLAYER_OBSTACLES;
const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
const frame = container
? resolvePreviewMiniPlayerFrame({
width: miniPlayer.width,
position: miniPlayer.position,
source: sourceSize,
container,
bottomInset,
obstacles,
})
: null;

Expand All @@ -306,22 +352,21 @@ function MiniPlayerShell({
usePreviewMiniPlayerStore.getState().close(threadRef);
};

// The composer grows on its own (drafts, banners), so it is observed alongside the column.
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
const measure = () => {
setContainer((current) =>
current?.width === element.clientWidth && current.height === element.clientHeight
? current
: { width: element.clientWidth, height: element.clientHeight },
);
const next = measureLayout(element, composerOverlayElement);
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);
return () => observer.disconnect();
}, []);
}, [composerOverlayElement]);

const beginGesture = (
event: ReactPointerEvent<HTMLElement>,
Expand Down Expand Up @@ -353,7 +398,7 @@ function MiniPlayerShell({
{ x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
container,
gesture.frame,
bottomInset,
obstacles,
),
);
return;
Expand All @@ -364,7 +409,7 @@ function MiniPlayerShell({
delta,
source: sourceSize,
container,
bottomInset,
obstacles,
});
store.resize(threadRef, sourceKey, next.width);
store.move(threadRef, sourceKey, { x: next.x, y: next.y });
Expand Down
Loading
Loading