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..42170a1147668 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,31 @@ 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 } }; }); + // 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 }, + 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..a5c3525c3c930 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,96 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; +// 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"), +); + +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 = useStreamedSourceSize((state) => + threadRef && + tabId !== null && + state?.key === + streamedSourceKey(threadRef, previewMiniPlayerSourceKey(browserMiniPlayerSource(tabId))) + ? state.viewport + : 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 +213,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 +317,17 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly visible followSize={false} controlPosition="bottom" - onViewport={setStreamViewport} + onViewport={(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" /> @@ -268,7 +361,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 +453,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));