From 0447af8a04f09c48500ccb2a70db82c6aed38673 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:27:43 -0700 Subject: [PATCH 1/2] fix(web): thread details popover closes when the card moves inline Problem: the thread details card reported its presentation to ChatView from a layout effect (onPresentationChange), and the card and the preview mini player each reported their boxes into ChatCanvas state from layout effects, so every layout change took a second render pass. When the card switched from popover to inline while the popover was open (window widened, preview moved away), base-ui kept the unmounted popup on screen because its exit animation never finished. Fix: a per-chat-view canvas store holds the canvas measurements and the card's measured height (tagged with its thread). ChatCanvas and ChatView derive the card placement and presentation from that store, the inline flag and the mini player's source, which ChatView now reads through useThreadPreviewCanvasSource. The popover popup stays mounted in both modes and only renders content in popover mode, so it closes cleanly. Effects: ThreadDetailsCard 4 -> 2 (kept: closing a stale popover flag, which derives from three stores; content measurement driven by density), ThreadPreviewMiniPlayer 3 -> 1 (kept: pill outside-press listener), ChatCanvas 1 -> 1 (resize observation), ChatView 18 -> 18. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 68 +++++--- apps/web/src/components/chat/ChatCanvas.tsx | 124 +++++--------- .../src/components/chat/ChatCanvasContext.ts | 20 ++- .../chat/ThreadDetailsCard.browser.test.tsx | 138 +++++++++++++++ .../src/components/chat/ThreadDetailsCard.tsx | 160 ++++++++---------- .../components/chat/ThreadDetailsPanel.tsx | 3 +- .../src/components/chat/canvasStore.test.ts | 86 ++++++++++ apps/web/src/components/chat/canvasStore.ts | 160 ++++++++++++++++++ .../preview/ThreadPreviewMiniPlayer.tsx | 159 ++++++++++++----- 9 files changed, 674 insertions(+), 244 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx create mode 100644 apps/web/src/components/chat/canvasStore.test.ts create mode 100644 apps/web/src/components/chat/canvasStore.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51abfdc64d203..462728f093c24 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -121,6 +121,7 @@ import { BackgroundThreadContext } from "./chat/backgroundThread"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, @@ -150,10 +151,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { - RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, - type ThreadPanelPresentation, -} from "../rightPanelLayout"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { PopoverCreateHandle } from "./ui/popover"; import { selectActiveRightPanel, @@ -164,7 +162,11 @@ import { } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; -import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; +import { + ThreadPreviewMiniPlayer, + useThreadPreviewCanvasSource, +} from "./preview/ThreadPreviewMiniPlayer"; +import { createChatCanvasStore, resolveChatCanvasModel } from "./chat/canvasStore"; import { usePreviewSession } from "./preview/usePreviewSession"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -1004,16 +1006,8 @@ export default function ChatView(props: ChatViewProps) { const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; - const [threadPanelPresentation, setThreadPanelPresentation] = - useState("inline"); const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); - const threadPanelOpen = useRightPanelStore((state) => - selectThreadPanelOpen( - state.threadPanelVisibilityByThreadKey, - activeThreadRef, - threadPanelPresentation, - ), - ); + const [chatCanvasStore] = useState(createChatCanvasStore); const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activeThreadEnvironmentId = activeThread?.environmentId; @@ -1856,6 +1850,39 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); + const shownPreviewMiniPlayer = + activeThreadRef && + activePreviewMiniPlayer && + previewMiniPlayerVisible && + (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) + ? activePreviewMiniPlayer + : null; + const canvasPreview = useThreadPreviewCanvasSource(activeThreadRef, shownPreviewMiniPlayer); + const threadPanelInlineOpen = useRightPanelStore((state) => + selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, activeThreadRef, "inline"), + ); + const centerChatBesideDetails = useClientSettings((s) => s.redesignEnabled); + // Selects only the mode, so canvas resizes rerender the chat view only when the + // card switches presentation. + const threadPanelPresentation = useStore( + chatCanvasStore, + (state) => + resolveChatCanvasModel({ + measurements: state.measurements, + detailsCardMeasurement: state.detailsCardMeasurement, + threadRef: activeThreadRef, + preview: canvasPreview, + detailsCardInlineOpen: threadPanelInlineOpen, + centerBesideDetails: centerChatBesideDetails, + }).detailsCardMode, + ); + const threadPanelOpen = useRightPanelStore((state) => + selectThreadPanelOpen( + state.threadPanelVisibilityByThreadKey, + activeThreadRef, + threadPanelPresentation, + ), + ); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -3184,6 +3211,9 @@ export default function ChatView(props: ChatViewProps) { thread: activeThreadRef, enabled: threadFindEnabled, }} + store={chatCanvasStore} + threadRef={activeThreadRef} + preview={canvasPreview} detailsCardTopInset={isThreadFindActive ? THREAD_FIND_BAR_RESERVED_HEIGHT : 0} composerOverlayElement={isDraftHeroState ? null : composerOverlayElement} data-chat-workspace-drop-target="true" @@ -3690,14 +3720,11 @@ export default function ChatView(props: ChatViewProps) { {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( ) : null} - {activeThreadRef && - activePreviewMiniPlayer && - previewMiniPlayerVisible && - (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( + {activeThreadRef && shownPreviewMiniPlayer ? ( ) : null} @@ -3711,7 +3738,6 @@ export default function ChatView(props: ChatViewProps) { , "className" | "style" | "ref"> & { + store: ChatCanvasStore; + /** The thread whose details card sits on the canvas; other threads' measurements are stale. */ + threadRef: ScopedThreadRef | null; + /** The thread's mini player while it renders on this canvas. */ + preview: ChatCanvasPreview | null; composerOverlayElement: HTMLElement | null; detailsCardTopInset?: number; }) { const elementRef = useRef(null); const widthProbeRef = useRef(null); const [timelineElement, registerTimeline] = useState(null); - const [preview, setPreview] = useState(null); - const [detailsCard, setDetailsCard] = useState(null); - const reportDetailsCard = (next: PreviewMiniPlayerObstacles["detailsCard"]) => { - setDetailsCard((current) => - current?.left === next?.left && - current?.right === next?.right && - current?.bottom === next?.bottom - ? current - : next, - ); - }; - const [measurements, setMeasurements] = useState({ - width: 0, - height: 0, - padding: 48, - maxChatWidth: 768, - minChatWidth: 640, - composerHeight: 0, - timelineGutter: 0, - findBarLeft: 0, - findBarRight: 0, - findBarBottom: 0, - }); - const reportPreview = (next: ChatCanvasPreview) => { - setPreview((current) => - current?.key === next.key && - current.width === next.width && - current.lastInteraction === next.lastInteraction && - current.position?.x === next.position?.x && - current.position?.y === next.position?.y && - current.source.width === next.source.width && - current.source.height === next.source.height - ? current - : next, - ); - }; - const clearPreview = (key: string) => - setPreview((current) => (current?.key === key ? null : current)); + const measurements = useStore(store, (state) => state.measurements); + const detailsCardMeasurement = useStore(store, (state) => state.detailsCardMeasurement); useLayoutEffect(() => { const element = elementRef.current; const probe = widthProbeRef.current; @@ -65,10 +40,15 @@ export function ChatCanvas({ const canvasBounds = element.getBoundingClientRect(); const findBarBounds = findBar?.getBoundingClientRect(); const styles = getComputedStyle(probe); - const next = { - findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0, - findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0, - findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0, + store.getState().setMeasurements({ + findBar: + detailsCardTopInset > 0 + ? { + left: findBarBounds ? findBarBounds.left - canvasBounds.left : 0, + right: findBarBounds ? findBarBounds.right - canvasBounds.left : 0, + bottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0, + } + : null, width: element.clientWidth, height: element.clientHeight, padding: Number.parseFloat(styles.paddingLeft), @@ -78,14 +58,7 @@ export function ChatCanvas({ timelineGutter: timelineElement ? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2 : 0, - }; - setMeasurements((current) => - Object.keys(next).every( - (key) => current[key as keyof typeof current] === next[key as keyof typeof next], - ) - ? current - : next, - ); + }); }; measure(); const observed: Element[] = [element, probe]; @@ -93,34 +66,31 @@ export function ChatCanvas({ if (composerOverlayElement) observed.push(composerOverlayElement); if (timelineElement) observed.push(timelineElement); return observeResize(observed, measure); - }, [composerOverlayElement, timelineElement, detailsCardTopInset]); - const redesign = useClientSettings((s) => s.redesignEnabled); - const container = { width: measurements.width, height: measurements.height }; - const findBar = - detailsCardTopInset > 0 - ? { - left: measurements.findBarLeft, - right: measurements.findBarRight, - bottom: measurements.findBarBottom, - } - : null; + }, [store, composerOverlayElement, timelineElement, detailsCardTopInset]); + const detailsCardInlineOpen = useRightPanelStore((state) => + threadRef === null + ? false + : selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), + ); + const centerBesideDetails = useClientSettings((s) => s.redesignEnabled); + const model = resolveChatCanvasModel({ + measurements, + detailsCardMeasurement, + threadRef, + preview, + detailsCardInlineOpen, + centerBesideDetails, + }); const context = { - container, - lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, - layout: resolveChatCanvasLayout({ - ...measurements, - container, - preview, - detailsCard, - findBar, - centerBesideDetails: redesign, - }), + container: model.container, + lane: model.lane, + layout: model.layout, previewKey: preview?.key ?? null, - reportPreview, - clearPreview, registerTimeline, - reportDetailsCard, - detailsCardTopInset: findBar?.bottom ?? 0, + measureDetailsCard: store.getState().measureDetailsCard, + detailsCardTopInset: model.detailsCardTopInset, + preferredDetailsCard: model.preferredDetailsCard, + detailsCardPlacement: model.detailsCardPlacement, }; const { layout } = context; return ( diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index 8842173ba878b..4db2229f4d411 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -1,17 +1,27 @@ import { createContext, useContext } from "react"; -import type { ChatCanvasPreview, resolveChatCanvasLayout } from "./chatCanvasLayout"; -import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; +import type { resolveChatCanvasLayout } from "./chatCanvasLayout"; +import type { resolveThreadDetailsCardLayout } from "./threadDetailsCardLayout"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +/** The inline details card's measured full-density height at a card width. */ +export interface DetailsCardMeasurement { + readonly threadRef: ScopedThreadRef; + readonly width: number; + readonly fullContentHeight: number; +} export const ChatCanvasContext = createContext<{ container: { width: number; height: number }; lane: { padding: number; minChatWidth: number }; layout: ReturnType; previewKey: string | null; - reportPreview: (preview: ChatCanvasPreview) => void; - clearPreview: (key: string) => void; registerTimeline: (element: HTMLElement | null) => void; - reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void; + measureDetailsCard: (measurement: DetailsCardMeasurement) => void; detailsCardTopInset: number; + /** Where an inline card sits when nothing overlaps it; null when the canvas is too narrow. */ + preferredDetailsCard: ReturnType; + /** Where the inline card sits now; null when it shows as a popover. */ + detailsCardPlacement: ReturnType; } | null>(null); export const useChatCanvas = () => useContext(ChatCanvasContext); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx new file mode 100644 index 0000000000000..37110875ed0ea --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -0,0 +1,138 @@ +import { StrictMode } from "react"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { act, render } from "../../../test/render"; +import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore"; +import { PopoverCreateHandle } from "../ui/popover"; +import { ChatCanvasContext } from "./ChatCanvasContext"; +import { resolveChatCanvasLayout } from "./chatCanvasLayout"; +import { ThreadDetailsCard } from "./ThreadDetailsCard"; + +const threadA: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread-a"), +}; +const threadB: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread-b"), +}; +const container = { width: 1200, height: 800 }; +const placement = { x: 908, y: 0, right: 12, width: 280, height: 400 } as const; + +function setup() { + useRightPanelStore.setState({ threadPanelVisibilityByThreadKey: {} }); + const anchor = document.createElement("div"); + document.body.append(anchor); + return { + anchor: { current: anchor }, + handle: PopoverCreateHandle(), + [Symbol.dispose]: () => anchor.remove(), + }; +} + +const popoverOpen = (threadRef: ScopedThreadRef) => + selectThreadPanelOpen( + useRightPanelStore.getState().threadPanelVisibilityByThreadKey, + threadRef, + "popover", + ); + +function Card({ + threadRef, + inline, + fixture, +}: { + threadRef: ScopedThreadRef; + inline: boolean; + fixture: ReturnType; +}) { + return ( + // The web root renders in StrictMode, which re-runs ref callbacks on every mount. + + {}, + measureDetailsCard: () => {}, + detailsCardTopInset: 0, + preferredDetailsCard: placement, + detailsCardPlacement: inline ? placement : null, + }} + > + + {() =>

Details for {threadRef.threadId}

} +
+
+
+ ); +} + +it("stays open once opened", async () => { + using fixture = setup(); + await using _screen = await render(); + await act(() => useRightPanelStore.getState().setThreadPanelOpen(threadB, "popover", true)); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); + expect(popoverOpen(threadB)).toBe(true); +}); + +it("closes a popup left open when the card switches inline, and reopens from the flag", async () => { + using fixture = setup(); + await using screen = await render(); + const setOpen = (open: boolean) => + act(() => useRightPanelStore.getState().setThreadPanelOpen(threadB, "popover", open)); + await setOpen(true); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); + + useRightPanelStore.getState().setThreadPanelOpen(threadB, "inline", false); + await screen.rerender(); + await expect.element(page.getByText("Details for thread-b")).not.toBeInTheDocument(); + await setOpen(false); + + await screen.rerender(); + await expect.element(page.getByText("Details for thread-b")).not.toBeInTheDocument(); + await setOpen(true); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); +}); + +it("does not reopen the popover when the card returns from inline mode", async () => { + using fixture = setup(); + await using screen = await render(); + await act(() => useRightPanelStore.getState().setThreadPanelOpen(threadB, "popover", true)); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); + + useRightPanelStore.getState().setThreadPanelOpen(threadB, "inline", false); + await screen.rerender(); + await expect.element(page.getByText("Details for thread-b")).not.toBeInTheDocument(); + + await screen.rerender(); + await expect.element(page.getByText("Details for thread-b")).not.toBeInTheDocument(); + expect(popoverOpen(threadB)).toBe(false); +}); + +it("removes the leaving thread's popup on a thread switch", async () => { + using fixture = setup(); + await using screen = await render(); + await act(() => useRightPanelStore.getState().setThreadPanelOpen(threadB, "popover", true)); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); + + await screen.rerender(); + await expect.element(page.getByText("Details for thread-b")).not.toBeInTheDocument(); + await expect.element(page.getByText("Details for thread-a")).not.toBeInTheDocument(); +}); + +it("reopens after a normal close", async () => { + using fixture = setup(); + await using _screen = await render(); + const setOpen = (open: boolean) => + act(() => useRightPanelStore.getState().setThreadPanelOpen(threadB, "popover", open)); + await setOpen(true); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); + await setOpen(false); + await expect.element(page.getByText("Details for thread-b")).not.toBeInTheDocument(); + await setOpen(true); + await expect.element(page.getByText("Details for thread-b")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 3216c6c376c1b..82dcf8f0c6f6a 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -4,12 +4,10 @@ import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../../lib/utils"; import { Popover, PopoverPopup, PopoverCreateHandle } from "../ui/popover"; import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore"; -import type { ThreadPanelPresentation } from "../../rightPanelLayout"; import { useChatCanvas } from "./ChatCanvasContext"; import { THREAD_DETAILS_CARD_GAP, resolveThreadDetailsCardDensity, - resolveThreadDetailsCardLayout, } from "./threadDetailsCardLayout"; import { observeResize } from "../../lib/observeResize"; @@ -17,33 +15,16 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, - onPresentationChange, children, }: { threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; - onPresentationChange: (presentation: ThreadPanelPresentation) => void; children: (density: "full" | "compact" | "essential") => ReactNode; }) { const canvas = useChatCanvas(); - const preferredPlacement = canvas - ? resolveThreadDetailsCardLayout({ - container: canvas.container, - lane: canvas.lane, - frame: null, - topInset: canvas.detailsCardTopInset, - }) - : null; - const placement = canvas - ? resolveThreadDetailsCardLayout({ - container: canvas.container, - lane: canvas.lane, - frame: canvas.layout.frame, - overlapsDetailsCard: canvas.layout.overlapsDetailsCard, - topInset: canvas.detailsCardTopInset, - }) - : null; + const preferredPlacement = canvas?.preferredDetailsCard ?? null; + const placement = canvas?.detailsCardPlacement ?? null; const mode = placement ? "inline" : "popover"; const topInset = canvas?.detailsCardTopInset ?? 0; const inlineOpen = useRightPanelStore((state) => @@ -52,39 +33,29 @@ export function ThreadDetailsCard({ const popoverOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"), ); - const [contentElement, setContentElement] = useState(null); const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`; const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, - fullContentHeight: 0, }); const contentHeights = measurements.key === measurementKey ? measurements.heights : { full: 0, compact: 0 }; const height = placement?.height ?? Math.max(0, (canvas?.container.height ?? 0) - 52); const density = resolveThreadDetailsCardDensity(height, contentHeights); - const reportDetailsCard = canvas?.reportDetailsCard; - const cardLeft = preferredPlacement?.x; - const cardRight = preferredPlacement - ? preferredPlacement.x + preferredPlacement.width - : undefined; - const cardBottom = - preferredPlacement && measurements.key === measurementKey && measurements.fullContentHeight > 0 - ? preferredPlacement.y + Math.min(measurements.fullContentHeight, preferredPlacement.height) - : undefined; - useLayoutEffect(() => { - reportDetailsCard?.( - inlineOpen && cardLeft !== undefined && cardRight !== undefined && cardBottom !== undefined - ? { left: cardLeft, right: cardRight, bottom: cardBottom } - : null, - ); - }, [reportDetailsCard, inlineOpen, cardLeft, cardRight, cardBottom]); - useLayoutEffect(() => () => reportDetailsCard?.(null), [reportDetailsCard]); + const measureDetailsCard = canvas?.measureDetailsCard; + const cardWidth = preferredPlacement?.width; + // The mode derives from canvas measurements, the preview frame and the + // inline flag, which live in three stores, so no single write site sees the + // card leave popover mode. A flag left set would reopen the popover unasked + // when the card returns to popover mode. useLayoutEffect(() => { - onPresentationChange(mode); if (mode === "inline" && popoverOpen) useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", false); - }, [mode, onPresentationChange, threadRef, popoverOpen]); + }, [mode, popoverOpen, threadRef]); + const [contentElement, setContentElement] = useState(null); + const { environmentId, threadId } = threadRef; + // Re-measures when the density or measurement key changes, so each density's + // height is measured once it renders. useLayoutEffect(() => { const element = contentElement; if (!element || density === "essential") return; @@ -100,26 +71,30 @@ export function ThreadDetailsCard({ next - Array.from(lists).reduce((sum, list) => sum + list.offsetHeight, 0); setMeasurements((current) => { const heights = current.key === measurementKey ? current.heights : { full: 0, compact: 0 }; - const fullContentHeight = - density === "full" - ? next - : current.key === measurementKey - ? current.fullContentHeight - : 0; - return current.key === measurementKey && - heights[density] === fittingHeight && - current.fullContentHeight === fullContentHeight + return current.key === measurementKey && heights[density] === fittingHeight ? current - : { - key: measurementKey, - heights: { ...heights, [density]: fittingHeight }, - fullContentHeight, - }; + : { key: measurementKey, heights: { ...heights, [density]: fittingHeight } }; }); + // The canvas keeps preview frames clear of the full card. + if (density === "full" && cardWidth !== undefined) { + measureDetailsCard?.({ + threadRef: { environmentId, threadId }, + width: cardWidth, + fullContentHeight: next, + }); + } }; measure(); return observeResize(element, measure); - }, [contentElement, density, measurementKey]); + }, [ + contentElement, + density, + measurementKey, + cardWidth, + measureDetailsCard, + environmentId, + threadId, + ]); const card = (
- {placement ? ( - inlineOpen ? ( - - ) : null - ) : ( - 0 && "transition-[top] duration-150 ease-out motion-reduce:transition-none", + )} + style={{ + right: placement.right, + top: placement.y, + width: placement.width, + maxHeight: height, + }} + data-density={density} + data-thread-details-panel="inline" > + {card} + + ) : null} + {/* Rendered in both modes: base-ui keeps a popup that unmounts mid-open + mounted, because its exit animation never finishes. Staying in the tree + lets it close cleanly when the card switches inline. */} + + {mode === "popover" ? (
{card}
-
- )} + ) : null} +
); } diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index cea257e464127..3e62dd8cd5a44 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -27,7 +27,7 @@ const GitActionsControl = memo(BaseGitActionsControl); export interface ThreadDetailsPanelProps extends Pick< ComponentProps, - "anchor" | "handle" | "onPresentationChange" + "anchor" | "handle" > { forceNewWorktree?: boolean; environmentId: EnvironmentId; @@ -105,7 +105,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} - onPresentationChange={props.onPresentationChange} > {(density) => ( <> diff --git a/apps/web/src/components/chat/canvasStore.test.ts b/apps/web/src/components/chat/canvasStore.test.ts new file mode 100644 index 0000000000000..07222bc70a3cf --- /dev/null +++ b/apps/web/src/components/chat/canvasStore.test.ts @@ -0,0 +1,86 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + createChatCanvasStore, + resolveChatCanvasModel, + type ChatCanvasMeasurements, +} from "./canvasStore"; +import type { ChatCanvasPreview } from "./chatCanvasLayout"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +}; +const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") }; +const measurements = (width: number, height = 900): ChatCanvasMeasurements => ({ + width, + height, + padding: 48, + maxChatWidth: 768, + minChatWidth: 640, + composerHeight: 180, + timelineGutter: 0, + findBar: null, +}); +const preview: ChatCanvasPreview = { + key: "browser:one", + width: 320, + position: null, + source: { width: 1600, height: 1000 }, +}; + +describe("chat canvas model", () => { + it("shows the details card inline only where the canvas fits it beside the chat", () => { + const resolve = (width: number) => + resolveChatCanvasModel({ + measurements: measurements(width), + detailsCardMeasurement: null, + threadRef, + preview: null, + detailsCardInlineOpen: true, + centerBesideDetails: false, + }).detailsCardMode; + expect(resolve(1400)).toBe("inline"); + expect(resolve(900)).toBe("popover"); + }); + + it("moves the card into a popover when a preview below it leaves too little height", () => { + const resolve = (fullContentHeight: number) => + resolveChatCanvasModel({ + measurements: measurements(1100, 300), + detailsCardMeasurement: { threadRef, width: 280, fullContentHeight }, + threadRef, + preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" }, + detailsCardInlineOpen: true, + centerBesideDetails: false, + }); + const model = resolve(400); + expect(model.layout.overlapsDetailsCard).toBe(true); + expect(model.detailsCardMode).toBe("popover"); + }); + + it("ignores a details card measured for another thread", () => { + const model = resolveChatCanvasModel({ + measurements: measurements(1100, 300), + detailsCardMeasurement: { threadRef: otherThreadRef, width: 280, fullContentHeight: 400 }, + threadRef, + preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" }, + detailsCardInlineOpen: true, + centerBesideDetails: false, + }); + expect(model.layout.overlapsDetailsCard).toBe(false); + expect(model.detailsCardMode).toBe("inline"); + }); + + it("keeps state identity when a measurement repeats", () => { + const store = createChatCanvasStore(); + store.getState().setMeasurements(measurements(1400)); + const before = store.getState(); + store.getState().setMeasurements(measurements(1400)); + store.getState().measureDetailsCard({ threadRef, width: 280, fullContentHeight: 300 }); + const measured = store.getState(); + store.getState().measureDetailsCard({ threadRef, width: 280, fullContentHeight: 300 }); + expect(measured.measurements).toBe(before.measurements); + expect(store.getState()).toBe(measured); + }); +}); diff --git a/apps/web/src/components/chat/canvasStore.ts b/apps/web/src/components/chat/canvasStore.ts new file mode 100644 index 0000000000000..763b992f3d18f --- /dev/null +++ b/apps/web/src/components/chat/canvasStore.ts @@ -0,0 +1,160 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { createStore } from "zustand"; +import type { ThreadPanelPresentation } from "../../rightPanelLayout"; +import type { DetailsCardMeasurement } from "./ChatCanvasContext"; +import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; +import { resolveThreadDetailsCardLayout } from "./threadDetailsCardLayout"; + +interface CanvasEdges { + readonly left: number; + readonly right: number; + readonly bottom: number; +} + +export interface ChatCanvasMeasurements { + readonly width: number; + readonly height: number; + readonly padding: number; + readonly maxChatWidth: number; + readonly minChatWidth: number; + readonly composerHeight: number; + readonly timelineGutter: number; + /** The find bar's box in canvas coordinates while it is shown. */ + readonly findBar: CanvasEdges | null; +} + +interface ChatCanvasStoreState { + readonly measurements: ChatCanvasMeasurements; + readonly detailsCardMeasurement: DetailsCardMeasurement | null; + readonly setMeasurements: (next: ChatCanvasMeasurements) => void; + readonly measureDetailsCard: (next: DetailsCardMeasurement) => void; +} + +const INITIAL_MEASUREMENTS: ChatCanvasMeasurements = { + width: 0, + height: 0, + padding: 48, + maxChatWidth: 768, + minChatWidth: 640, + composerHeight: 0, + timelineGutter: 0, + findBar: null, +}; + +const sameEdges = (a: CanvasEdges | null, b: CanvasEdges | null) => + a === b || + (a !== null && b !== null && a.left === b.left && a.right === b.right && a.bottom === b.bottom); + +const sameMeasurements = (a: ChatCanvasMeasurements, b: ChatCanvasMeasurements) => + a.width === b.width && + a.height === b.height && + a.padding === b.padding && + a.maxChatWidth === b.maxChatWidth && + a.minChatWidth === b.minChatWidth && + a.composerHeight === b.composerHeight && + a.timelineGutter === b.timelineGutter && + sameEdges(a.findBar, b.findBar); + +/** + * One store per chat view: the canvas measures into it, and the chat view's + * header controls derive the details card presentation from the same inputs. + */ +export const createChatCanvasStore = () => + createStore()((set) => ({ + measurements: INITIAL_MEASUREMENTS, + detailsCardMeasurement: null, + setMeasurements: (next) => + set((state) => (sameMeasurements(state.measurements, next) ? state : { measurements: next })), + measureDetailsCard: (next) => + set((state) => { + const current = state.detailsCardMeasurement; + return current?.threadRef.environmentId === next.threadRef.environmentId && + current.threadRef.threadId === next.threadRef.threadId && + current.width === next.width && + current.fullContentHeight === next.fullContentHeight + ? state + : { detailsCardMeasurement: next }; + }), + })); + +export type ChatCanvasStore = ReturnType; + +export function resolveChatCanvasModel({ + measurements, + detailsCardMeasurement, + threadRef, + preview, + detailsCardInlineOpen, + centerBesideDetails, +}: { + measurements: ChatCanvasMeasurements; + detailsCardMeasurement: DetailsCardMeasurement | null; + /** The thread whose details card sits on the canvas; other threads' measurements are stale. */ + threadRef: ScopedThreadRef | null; + preview: ChatCanvasPreview | null; + detailsCardInlineOpen: boolean; + centerBesideDetails: boolean; +}) { + const container = { width: measurements.width, height: measurements.height }; + const { findBar } = measurements; + const detailsCardTopInset = findBar?.bottom ?? 0; + const lane = { padding: measurements.padding, minChatWidth: measurements.minChatWidth }; + // The preview avoids where an open inline card would sit, whether or not a + // preview overlap has pushed the card itself into popover mode. + const preferredDetailsCard = resolveThreadDetailsCardLayout({ + container, + lane, + frame: null, + topInset: detailsCardTopInset, + }); + // The canvas outlives thread switches, so a measurement only counts for the + // thread whose card is shown now. + const detailsCardMeasured = + threadRef !== null && + detailsCardMeasurement !== null && + detailsCardMeasurement.threadRef.environmentId === threadRef.environmentId && + detailsCardMeasurement.threadRef.threadId === threadRef.threadId + ? detailsCardMeasurement + : null; + const detailsCard = + detailsCardInlineOpen && + preferredDetailsCard && + detailsCardMeasured?.width === preferredDetailsCard.width && + detailsCardMeasured.fullContentHeight > 0 + ? { + left: preferredDetailsCard.x, + right: preferredDetailsCard.x + preferredDetailsCard.width, + bottom: + preferredDetailsCard.y + + Math.min(detailsCardMeasured.fullContentHeight, preferredDetailsCard.height), + } + : null; + const layout = resolveChatCanvasLayout({ + container, + preview, + padding: measurements.padding, + maxChatWidth: measurements.maxChatWidth, + minChatWidth: measurements.minChatWidth, + composerHeight: measurements.composerHeight, + detailsCard, + findBar, + centerBesideDetails, + }); + const detailsCardPlacement = resolveThreadDetailsCardLayout({ + container, + lane, + frame: layout.frame, + overlapsDetailsCard: layout.overlapsDetailsCard, + topInset: detailsCardTopInset, + }); + const detailsCardMode: ThreadPanelPresentation = detailsCardPlacement ? "inline" : "popover"; + return { + container, + lane, + layout, + detailsCardTopInset, + preferredDetailsCard, + detailsCardPlacement, + detailsCardMode, + }; +} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 614543093cac9..3227fcb0bc1b1 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -1,19 +1,20 @@ import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; import { previewPdfAssetUrl } from "@t3tools/shared/preview"; -import { useAtomValue } from "@effect/atom-react"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, - useId, - useLayoutEffect, useRef, useState, } from "react"; +import { create } from "zustand"; +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useChatCanvas } from "../chat/ChatCanvasContext"; +import type { ChatCanvasPreview } from "../chat/chatCanvasLayout"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { findActiveBrowserRecordingRuntimeTabId, @@ -22,7 +23,8 @@ import { import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { useRendersServerTabNatively } from "~/browser/previewRuntime"; +import { rendersServerTabNatively } from "~/browser/previewRuntime"; +import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { closeServerPictureInPicture, @@ -41,6 +43,7 @@ import { type PreviewMiniPlayerSize, type PreviewMiniPlayerSource, type PreviewMiniPlayerState, + browserMiniPlayerSource, previewMiniPlayerSourceKey, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; @@ -48,7 +51,9 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { useDeviceState } from "~/state/device"; import { DeviceStreamView } from "../device/DeviceStreamView"; -import { deviceStreamScreenAtom } from "../device/deviceStreamSession"; +import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; +import { Atom } from "effect/reactivity"; +import { deviceScreenKey, deviceStreamScreenAtom } from "../device/deviceStreamSession"; import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; import { previewBridge } from "./previewBridge"; import { showPreviewPopup } from "./showPreviewPopup"; @@ -97,6 +102,92 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; +// What the streams report about their source. It lives outside the player so +// the chat canvas lays out the frame from the same source size the player uses. +const useStreamedSourceSizes = create<{ + readonly viewports: Record; +}>(() => ({ viewports: {} })); + +const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe( + Atom.withLabel("device-stream-screen:none"), +); + +const streamedSourceKey = (threadRef: ScopedThreadRef, sourceKey: string) => + `${scopedThreadKey(threadRef)}\u0000${sourceKey}`; + +// The mini player's device stream view, named so the canvas can read the +// screen size that view reports. +const miniPlayerDeviceViewId = ( + threadRef: ScopedThreadRef, + source: Extract, +) => + `preview-mini-player\u0000${scopedThreadKey(threadRef)}\u0000${deviceScreenKey(threadRef.environmentId, source.hostId, source.deviceId)}`; + +function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: string | null) { + const previewState = useThreadPreviewState(threadRef); + const snapshot = tabId === null ? null : (previewState.sessions[tabId] ?? null); + const runtimeTabId = + threadRef && tabId !== null + ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) + : null; + const desktopOverlay = tabId === null ? null : (previewState.desktopByTabId[tabId] ?? null); + const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); + const fittedSourceContent = useBrowserSurfaceStore((state) => + runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), + ); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const serverTab = + threadRef !== null && + snapshot?.runtime === "server" && + !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); + const streamViewport = useStreamedSourceSizes((state) => + threadRef && tabId !== null + ? (state.viewports[ + streamedSourceKey(threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId))) + ] ?? null) + : null, + ); + const sourceSize: PreviewMiniPlayerSize = + serverTab && streamViewport + ? streamViewport + : resolvePreviewMiniPlayerSourceSize( + snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, + fittedSourceContent, + layoutZoomFactor, + ); + return { serverEpoch: previewState.serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize }; +} + +/** The mini player as the chat canvas lays it out; null when no player renders. */ +export function useThreadPreviewCanvasSource( + threadRef: ScopedThreadRef | null, + miniPlayer: PreviewMiniPlayerState | null, +): ChatCanvasPreview | null { + const source = miniPlayer?.source ?? null; + const browser = useBrowserMiniPlayerSource( + threadRef, + source?.kind === "browser" ? source.tabId : null, + ); + const sourceKey = source ? previewMiniPlayerSourceKey(source) : null; + const screen = useAtomValue( + threadRef && source?.kind === "device" + ? deviceStreamScreenAtom(miniPlayerDeviceViewId(threadRef, source)) + : NO_DEVICE_SCREEN_ATOM, + ); + if (!miniPlayer || !source || sourceKey === null) return null; + if (source.kind === "browser" && !browser.snapshot) return null; + return { + key: sourceKey, + width: miniPlayer.width, + position: miniPlayer.position, + lastInteraction: miniPlayer.lastInteraction, + source: + source.kind === "browser" + ? browser.sourceSize + : resolveDeviceMiniPlayerSourceSize(source.platform, screen), + }; +} + export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { const { source } = miniPlayer; return source.kind === "browser" ? ( @@ -118,32 +209,20 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { const httpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); - const previewState = useThreadPreviewState(threadRef); - const snapshot = previewState.sessions[tabId] ?? null; - const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); + const { serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize } = + useBrowserMiniPlayerSource(threadRef, tabId); + const runtimeTabId = previewRuntimeTabId(threadRef, serverEpoch, tabId); const recordingTabIds = useActiveBrowserRecordingTabIds(); const recording = recordingTabIds.has(runtimeTabId) || findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; - const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; - const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); - const fittedSourceContent = useBrowserSurfaceStore( - (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, - ); - const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot); - const serverTab = snapshot?.runtime === "server" && !nativeServerTab; - const [streamViewport, setStreamViewport] = useState(null); const serverSurfaceRef = useRef(null); const serverPictureInPicture = useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId); - const sourceSize = - serverTab && streamViewport - ? streamViewport - : resolvePreviewMiniPlayerSourceSize( - snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, - fittedSourceContent, - layoutZoomFactor, - ); + const viewportKey = streamedSourceKey( + threadRef, + previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId)), + ); const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); @@ -234,7 +313,14 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly visible followSize={false} controlPosition="bottom" - onViewport={setStreamViewport} + onViewport={(viewport) => + useStreamedSourceSizes.setState((state) => { + const current = state.viewports[viewportKey]; + return current?.width === viewport.width && current.height === viewport.height + ? state + : { viewports: { ...state.viewports, [viewportKey]: viewport } }; + }) + } onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} className="size-full" /> @@ -268,7 +354,7 @@ function DeviceMiniPlayer({ miniPlayer, }: Props & { readonly source: Extract }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); - const viewId = useId(); + const viewId = miniPlayerDeviceViewId(threadRef, source); const screen = useAtomValue(deviceStreamScreenAtom(viewId)); const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); const device = deviceState.devices.find( @@ -360,27 +446,6 @@ function MiniPlayerShell({ const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; - const { width: sourceWidth, height: sourceHeight } = sourceSize; - const reportPreview = canvas?.reportPreview; - const clearPreview = canvas?.clearPreview; - useLayoutEffect(() => { - reportPreview?.({ - key: sourceKey, - width: miniPlayer.width, - position: miniPlayer.position, - lastInteraction: miniPlayer.lastInteraction, - source: { width: sourceWidth, height: sourceHeight }, - }); - }, [ - reportPreview, - sourceKey, - miniPlayer.width, - miniPlayer.position, - miniPlayer.lastInteraction, - sourceWidth, - sourceHeight, - ]); - useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; const pillInset = Math.max(8, Math.round(radius * 0.55)); From 05f911d64e2058cdb23076b9d92ae59ffc93912e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:02:43 -0700 Subject: [PATCH 2/2] fix(web): mini player keeps one streamed viewport size, not one per tab Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/ThreadDetailsCard.tsx | 3 +- .../preview/ThreadPreviewMiniPlayer.tsx | 39 +++++++++++-------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 82dcf8f0c6f6a..42170a1147668 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -75,7 +75,8 @@ export function ThreadDetailsCard({ ? current : { key: measurementKey, heights: { ...heights, [density]: fittingHeight } }; }); - // The canvas keeps preview frames clear of the full card. + // Only the laid-out DOM knows the full card height, and the canvas keeps preview + // frames clear of it, so it is reported from this measure effect. if (density === "full" && cardWidth !== undefined) { measureDetailsCard?.({ threadRef: { environmentId, threadId }, diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3227fcb0bc1b1..a5c3525c3c930 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -102,11 +102,14 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; -// What the streams report about their source. It lives outside the player so -// the chat canvas lays out the frame from the same source size the player uses. -const useStreamedSourceSizes = create<{ - readonly viewports: Record; -}>(() => ({ viewports: {} })); +// What the stream reports about its source. It lives outside the player so the +// chat canvas lays out the frame from the same source size the player uses. One +// slot is enough, since only the shown thread renders a player, and it keeps the +// store from collecting an entry for every tab a session ever streamed. +const useStreamedSourceSize = create<{ + readonly key: string; + readonly viewport: PreviewStreamViewport; +} | null>(() => null); const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe( Atom.withLabel("device-stream-screen:none"), @@ -140,11 +143,12 @@ function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: st threadRef !== null && snapshot?.runtime === "server" && !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); - const streamViewport = useStreamedSourceSizes((state) => - threadRef && tabId !== null - ? (state.viewports[ - streamedSourceKey(threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId))) - ] ?? null) + const streamViewport = useStreamedSourceSize((state) => + threadRef && + tabId !== null && + state?.key === + streamedSourceKey(threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId))) + ? state.viewport : null, ); const sourceSize: PreviewMiniPlayerSize = @@ -314,12 +318,15 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly followSize={false} controlPosition="bottom" onViewport={(viewport) => - useStreamedSourceSizes.setState((state) => { - const current = state.viewports[viewportKey]; - return current?.width === viewport.width && current.height === viewport.height - ? state - : { viewports: { ...state.viewports, [viewportKey]: viewport } }; - }) + useStreamedSourceSize.setState( + (state) => + state?.key === viewportKey && + state.viewport.width === viewport.width && + state.viewport.height === viewport.height + ? state + : { key: viewportKey, viewport }, + true, + ) } onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} className="size-full"