From 56ff9bd8eb1907b2526f9b07f6ac47d420782db6 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 9 Oct 2026 06:12:50 +0000 Subject: [PATCH 1/2] fix(web): panel toggles stay smooth and header icons stop flickering --- apps/web/src/components/ChatView.tsx | 974 +++++++++--------- apps/web/src/components/ChatWorkspace.tsx | 167 +++ apps/web/src/components/RightPanelTabs.tsx | 8 + .../components/chat/PanelLayoutControls.tsx | 90 +- .../chat/threadPanelPresentation.ts | 35 + .../components/preview/PreviewPanelShell.tsx | 12 +- .../src/hooks/useOpenPanelPullRequestUrl.ts | 8 +- apps/web/src/index.css | 4 + apps/web/src/routes/_chat.pull-requests.tsx | 5 - 9 files changed, 765 insertions(+), 538 deletions(-) create mode 100644 apps/web/src/components/ChatWorkspace.tsx create mode 100644 apps/web/src/components/chat/threadPanelPresentation.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 87d1303e05e21..680f3da69ff28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -242,17 +242,11 @@ import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; -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 { pullRequestSurface, - selectActiveRightPanel, selectActiveRightPanelSurface, - selectThreadPanelOpen, selectThreadRightPanelState, type RightPanelSurface, useRightPanelStore, @@ -286,6 +280,13 @@ import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; +import { + ChatWorkspace, + ChatWorkspaceColumn, + type RightPanelView, + RightPanelSlot, + RightPanelViewSlot, +} from "./ChatWorkspace"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; @@ -459,7 +460,9 @@ import { PanelLayoutControls, type PanelLayoutControlsProps, RightPanelMaximizeControl, + ThreadPanelToggle, } from "./chat/PanelLayoutControls"; +import { createThreadPanelPresentationStore } from "./chat/threadPanelPresentation"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -627,6 +630,7 @@ const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; // watermark per interval is plenty. const VISIT_DISPATCH_THROTTLE_MS = 10_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; +const EMPTY_ENVIRONMENTS: readonly never[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition( isDraftHeroState: boolean, @@ -1779,18 +1783,17 @@ export default function ChatView(props: ChatViewProps) { ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - const citationLocation = useLocation({ - select: (location) => ({ - href: location.href, - key: location.state.assistantCitationActivation ?? location.state.__TSR_key, - }), + // Router selections compare by identity, so each one stays a primitive. + const citationHref = useLocation({ select: (location) => location.href }); + const citationKey = useLocation({ + select: (location) => location.state.assistantCitationActivation ?? location.state.__TSR_key, }); const citationRequest = useMemo(() => { - const citation = assistantCitationFromLocation(citationLocation.href); + const citation = assistantCitationFromLocation(citationHref); return citation && citation.environmentId === environmentId && citation.threadId === threadId - ? { citation, key: citationLocation.key ?? citationLocation.href } + ? { citation, key: citationKey ?? citationHref } : null; - }, [citationLocation.href, citationLocation.key, environmentId, threadId]); + }, [citationHref, citationKey, environmentId, threadId]); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1936,7 +1939,6 @@ export default function ChatView(props: ChatViewProps) { useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); - const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null); const threadPanelPopoverAnchorRef = useRef(null); // Tracks whether the user explicitly dismissed the sidebar for the active turn. // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -2252,10 +2254,6 @@ export default function ChatView(props: ChatViewProps) { ); // Electron hosts its own browser tabs; other clients need the environment to host them. const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; - const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { - container: workspaceLayoutElement, - widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, - }); const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const timelineThreadError = serverRuntime?.status === "failed" && @@ -2295,25 +2293,13 @@ export default function ChatView(props: ChatViewProps) { if (!anchorRunSettled) return; setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); }, [anchorRunSettled, activeThreadKey]); - const activeRightPanelKind = useRightPanelStore((state) => - selectActiveRightPanel(state.byThreadKey, activeThreadRef), - ); - const diffOpen = activeRightPanelKind === "diff"; const explicitDiffOpenRef = useRef(null); - useLayoutEffect(() => { - const explicitThreadRef = explicitDiffOpenRef.current; - explicitDiffOpenRef.current = null; - // Generic openings always show Changes, including tab fallbacks and thread changes. - // A timeline click instead opens the specific turn/file the user requested. - if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - } - }, [activeThreadRef, diffOpen]); - const rightPanelState = useRightPanelStore((state) => - selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + const rightPanelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef).surfaces, ); - const activeRightPanelSurface = useRightPanelStore((state) => - selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), + const readActiveRightPanelSurface = useCallback( + () => selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, activeThreadRef), + [activeThreadRef], ); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; @@ -2331,11 +2317,11 @@ export default function ChatView(props: ChatViewProps) { const panelTerminalIds = useMemo( () => new Set( - rightPanelState.surfaces.flatMap((surface) => + rightPanelSurfaces.flatMap((surface) => surface.kind === "terminal" ? surface.terminalIds : [], ), ), - [rightPanelState.surfaces], + [rightPanelSurfaces], ); const allocatableActiveTerminalIds = useMemo( () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], @@ -2352,8 +2338,6 @@ export default function ChatView(props: ChatViewProps) { ), [allocatableActiveTerminalIds, canReuseTerminal, environmentId], ); - const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable; - const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const activeTerminalDrawerPresence = usePanelPresence( @@ -2363,43 +2347,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, panelAnimationDurationMs, ); - const rightPanelPresenceValue = useMemo( - () => ({ - activeSurface: activeRightPanelSurface, - surfaces: rightPanelState.surfaces, - }), - [activeRightPanelSurface, rightPanelState.surfaces], - ); - const rightPanelPresence = usePanelPresence( - rightPanelOpen && activeThreadRef !== null, - rightPanelPresenceValue, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); - const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = - shouldUsePlanSidebarSheet && rightPanelPresent && rightPanelOpen; - const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet; - const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; - const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ); - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; - const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; - const [threadPanelPresentation, setThreadPanelPresentation] = - useState("inline"); + const [threadPanelPresentation] = useState(createThreadPanelPresentationStore); const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); - const threadPanelOpen = useRightPanelStore((state) => - selectThreadPanelOpen( - state.threadPanelVisibilityByThreadKey, - activeThreadRef, - threadPanelPresentation, - ), - ); useEffect(() => { if (!activeThreadRef || !previewSessionsReady) return; @@ -3047,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) { const environment = environmentById.get(environmentId); return environment ? [environment] : []; }) - : [], + : EMPTY_ENVIRONMENTS, [automaticEnvironment, logicalProjectEnvironments, environmentById], ); const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); @@ -4338,13 +4287,13 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } - if (!diffOpen) { + if (readActiveRightPanelSurface()?.kind !== "diff") { onDiffPanelOpen?.(); } if (activeThreadRef) { useRightPanelStore.getState().toggle(activeThreadRef, "diff"); } - }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); + }, [activeThreadRef, isServerThread, onDiffPanelOpen, readActiveRightPanelSurface]); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = useMemo( @@ -4370,7 +4319,7 @@ export default function ChatView(props: ChatViewProps) { ); }) .map((candidate) => candidate.environmentId) - : [], + : EMPTY_ENVIRONMENTS, [ needsLoadBalancing, logicalProjectEnvironments, @@ -5553,7 +5502,7 @@ export default function ChatView(props: ChatViewProps) { : !previous.has(session.tabId); if (!fresh || session.reveal !== true) continue; if (!autoShowFloatingPreview && requested?.force !== true) continue; - const surface = rightPanelState.surfaces.find( + const surface = rightPanelSurfaces.find( (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, ); if (surface && requested?.force === true) { @@ -5570,7 +5519,7 @@ export default function ChatView(props: ChatViewProps) { activePreviewState.sessions, activeThreadRef, autoShowFloatingPreview, - rightPanelState.surfaces, + rightPanelSurfaces, ]); // A floating device follows its session: once the agent or another client // closes the device there is nothing left to stream. @@ -5861,6 +5810,7 @@ export default function ChatView(props: ChatViewProps) { ]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { + const activeRightPanelSurface = readActiveRightPanelSurface(); // Closing the panel on a live browser or device floats it instead of dropping it. if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { usePreviewMiniPlayerStore @@ -5874,10 +5824,10 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().setMaximized(activeThreadRef, false); useRightPanelStore.getState().close(activeThreadRef); } - }, [activeRightPanelSurface, activeThreadRef]); + }, [activeThreadRef, readActiveRightPanelSurface]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !browserAvailable) return; - if (previewPanelOpen) { + if (readActiveRightPanelSurface()?.kind === "preview") { closePreviewPanel(); return; } @@ -5895,7 +5845,7 @@ export default function ChatView(props: ChatViewProps) { canOperatePreview, closePreviewPanel, createBrowserSurface, - previewPanelOpen, + readActiveRightPanelSurface, ]); const addTerminalSurface = useCallback(() => { if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return; @@ -5928,6 +5878,7 @@ export default function ChatView(props: ChatViewProps) { ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { + const activeRightPanelSurface = readActiveRightPanelSurface(); if ( !hasTerminalWriteAccess() || !activeThreadRef || @@ -5960,7 +5911,6 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, - activeRightPanelSurface, activeThreadId, activeThreadRef, activeThreadWorktreePath, @@ -5968,6 +5918,7 @@ export default function ChatView(props: ChatViewProps) { gitCwd, openTerminal, hasTerminalWriteAccess, + readActiveRightPanelSurface, ], ); const splitPanelTerminalVertical = useCallback(() => { @@ -5975,16 +5926,18 @@ export default function ChatView(props: ChatViewProps) { }, [splitPanelTerminal]); const activatePanelTerminal = useCallback( (terminalId: string) => { + const activeRightPanelSurface = readActiveRightPanelSurface(); if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; useRightPanelStore .getState() .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef], + [activeThreadRef, readActiveRightPanelSurface], ); const closePanelTerminal = useCallback( (terminalId: string) => { + const activeRightPanelSurface = readActiveRightPanelSurface(); if ( !hasTerminalWriteAccess() || !activeThreadRef || @@ -6003,9 +5956,9 @@ export default function ChatView(props: ChatViewProps) { }, [ hasTerminalWriteAccess, - activeRightPanelSurface, activeThreadRef, closeTerminalMutation, + readActiveRightPanelSurface, storeCloseTerminal, ], ); @@ -6036,6 +5989,7 @@ export default function ChatView(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; + const diffOpen = readActiveRightPanelSurface()?.kind === "diff"; useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -6047,31 +6001,31 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, onDiffPanelOpen], + [activeThreadRef, onDiffPanelOpen, readActiveRightPanelSurface], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; - if (rightPanelOpen) { + if ( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, activeThreadRef).isOpen + ) { closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); + }, [activeThreadRef, closePreviewPanel]); const toggleThreadPanel = useCallback(() => { if (!activeThreadRef) return; - useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); + useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation.get()); }, [activeThreadRef, threadPanelPresentation]); - // A thread started for a link the OS opened shows its browser maximized, like a browser window. - useEffect(() => { - if (!canMaximizeRightPanel) return; - if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { - useRightPanelStore.getState().setMaximized(routeThreadRef, true); - } - }, [canMaximizeRightPanel, routeThreadRef]); const toggleRightPanelMaximized = useCallback(() => { - if (!canMaximizeRightPanel || !activeThreadRef) return; - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]); + if (!activeThreadRef || shouldUsePlanSidebarSheet) return; + const panel = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (!panel.isOpen) return; + useRightPanelStore.getState().setMaximized(activeThreadRef, panel.maximized !== true); + }, [activeThreadRef, shouldUsePlanSidebarSheet]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; @@ -6199,7 +6153,7 @@ export default function ChatView(props: ChatViewProps) { const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); + const surfaces = rightPanelSurfaces.filter((entry) => entry.id !== surface.id); const finishClose = () => finishRightPanelSurfaceClose(surfaces); closeAfterAgentBrowserConfirmation(surfaces, finishClose); }, @@ -6207,15 +6161,15 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef, closeAfterAgentBrowserConfirmation, finishRightPanelSurfaceClose, - rightPanelState.surfaces, + rightPanelSurfaces, ], ); const closeRightPanelSurfacesToRight = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); + const surfaceIndex = rightPanelSurfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; - const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); + const surfaces = rightPanelSurfaces.slice(surfaceIndex + 1); const finishClose = () => finishRightPanelSurfaceClose(surfaces); closeAfterAgentBrowserConfirmation(surfaces, finishClose); }, @@ -6223,18 +6177,18 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef, closeAfterAgentBrowserConfirmation, finishRightPanelSurfaceClose, - rightPanelState.surfaces, + rightPanelSurfaces, ], ); const closeAllRightPanelSurfaces = useCallback(() => { if (!activeThreadRef) return; - const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); - closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); + const finishClose = () => finishRightPanelSurfaceClose(rightPanelSurfaces); + closeAfterAgentBrowserConfirmation(rightPanelSurfaces, finishClose); }, [ activeThreadRef, closeAfterAgentBrowserConfirmation, finishRightPanelSurfaceClose, - rightPanelState.surfaces, + rightPanelSurfaces, ]); const moveRightPanelSurface = useCallback( (surfaceId: string, toIndex: number) => { @@ -7076,27 +7030,18 @@ export default function ChatView(props: ChatViewProps) { composerTimelineInsetRef.current = 0; publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); }, [composerMounted, composerOverlayElement, publishComposerOverlayHeight]); - const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); - const activeThreadReferenceCopyTarget = useMemo( - () => + const openPanelPullRequestUrlRef = + useRef>(undefined); + const copyActiveThreadReference = useCallback(() => { + const target = activeThreadId === null || !isServerThread ? null : resolveThreadReferenceCopyTarget({ threadId: activeThreadId, - openPanelPullRequestUrl, + openPanelPullRequestUrl: openPanelPullRequestUrlRef.current, pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, - }), - [ - activeThreadId, - isServerThread, - activeThread?.pullRequests, - linkedThreadPullRequest?.url, - openPanelPullRequestUrl, - ], - ); - const copyActiveThreadReference = useCallback(() => { - const target = activeThreadReferenceCopyTarget; + }); if (target === null) return; void writeTextToClipboard(target.value, target.clipboardTarget).then( (didCopy) => { @@ -7118,7 +7063,7 @@ export default function ChatView(props: ChatViewProps) { ); }, ); - }, [activeThreadReferenceCopyTarget]); + }, [activeThreadId, isServerThread, activeThread?.pullRequests, linkedThreadPullRequest?.url]); const pullRequestPanelTarget = activeThread ? threadPullRequestPanelTarget({ projectId: activeThread.projectId, @@ -7991,7 +7936,7 @@ export default function ChatView(props: ChatViewProps) { terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), previewFocus: isPreviewFocused(), - previewOpen: previewPanelOpen, + previewOpen: readActiveRightPanelSurface()?.kind === "preview" && browserAvailable, editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", @@ -8000,7 +7945,14 @@ export default function ChatView(props: ChatViewProps) { isWeb: !isElectron, isDesktop: isElectron, }), - [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase], + [ + browserAvailable, + composerRef, + readActiveRightPanelSurface, + terminalUiState.terminalOpen, + routeKind, + phase, + ], ); const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) @@ -8139,6 +8091,7 @@ export default function ChatView(props: ChatViewProps) { } if (command === "rightPanel.close") { + const activeRightPanelSurface = readActiveRightPanelSurface(); // Nothing open: leave the event alone so the shortcut keeps its // native meaning (close window on desktop, close tab in a browser). if (!activeRightPanelSurface) return; @@ -8188,6 +8141,7 @@ export default function ChatView(props: ChatViewProps) { if (!canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); + const activeRightPanelSurface = readActiveRightPanelSurface(); if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); return; @@ -8330,7 +8284,6 @@ export default function ChatView(props: ChatViewProps) { }; }, [ activeProject, - activeRightPanelSurface, activeProjectScripts, canReadTerminal, canOperateTerminal, @@ -8367,6 +8320,7 @@ export default function ChatView(props: ChatViewProps) { openThreadFind, closeThreadFind, isThreadFindActive, + readActiveRightPanelSurface, toggleRightPanel, toggleRightPanelMaximized, toggleThreadPanel, @@ -10965,12 +10919,13 @@ export default function ChatView(props: ChatViewProps) { const onOpenTurnDiff = useCallback( (runId: RunId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; + explicitDiffOpenRef.current = + readActiveRightPanelSurface()?.kind === "diff" ? null : activeThreadRef; useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, - [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen], + [activeThreadRef, isServerThread, onDiffPanelOpen, readActiveRightPanelSurface], ); // The revert handler is read from a ref at call-time so the callback // reference is fully stable and never busts TimelineRowCtx identity. @@ -11018,168 +10973,173 @@ export default function ChatView(props: ChatViewProps) { return ; } - const rightPanelContent = activeThreadRef ? ( - renderedRightPanelSurface?.kind === "preview" ? ( - - + activeThreadRef ? ( + renderedRightPanelSurface?.kind === "preview" ? ( + + { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }} + /> + + ) : renderedRightPanelSurface?.kind === "terminal" ? ( + { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }} - /> - - ) : renderedRightPanelSurface?.kind === "terminal" ? ( - - ) : renderedRightPanelSurface?.kind === "diff" ? ( - - - - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - - ) : renderedRightPanelSurface?.kind === "pull-request" ? ( - // No onClose: the surface tab's own X owns closing here, and a second X in the header - // would be the same action twice. The thread context also drops the checkout button, so it - // is only right for the thread's own pull request, whose branch is already under the - // reader's feet. A link the agent wrote can open any other one here, and that one has to be - // checkable out like it is anywhere else. - { - if (activeThreadRef) - useRightPanelStore.getState().openPullRequest(activeThreadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={activeThreadRef} - reference={{ - projectId: renderedRightPanelSurface.projectId as ProjectId, - ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), - repository: renderedRightPanelSurface.repository, - number: renderedRightPanelSurface.number, - }} - context={pullRequestPanelContext( - { - projectId: activeThread.projectId, - pullRequests: visiblePullRequests, - linkedPullRequest: linkedThreadPullRequest, - branchPullRequest: - activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, - }, - renderedRightPanelSurface, - )} - composerDraftTarget={composerDraftTarget} - onBack={ - activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 - ? addPullRequestsSurface - : undefined - } - /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - - ) : renderedRightPanelSurface?.kind === "device" ? ( - - { - closeRightPanelSurface(renderedRightPanelSurface); - useRightPanelStore.getState().show(activeThreadRef); - }} + launchContext={activeTerminalLaunchContext ?? null} + focusRequestId={terminalFocusRequestId} + keybindings={keybindings} + onAddTerminalContext={addTerminalContextToDraft} + onSplitTerminal={splitPanelTerminal} + onSplitTerminalVertical={splitPanelTerminalVertical} + onNewTerminal={addTerminalSurface} + onActiveTerminalChange={activatePanelTerminal} + onCloseTerminal={closePanelTerminal} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} /> - - ) : (renderedRightPanelSurface?.kind === "files" || - renderedRightPanelSurface?.kind === "file") && - ((activeProject && activeWorkspaceRoot) || - (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( - - + + + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + + ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + + ) : renderedRightPanelSurface?.kind === "pull-request" ? ( + // No onClose: the surface tab's own X owns closing here, and a second X in the header + // would be the same action twice. The thread context also drops the checkout button, so it + // is only right for the thread's own pull request, whose branch is already under the + // reader's feet. A link the agent wrote can open any other one here, and that one has to be + // checkable out like it is anywhere else. + { + if (activeThreadRef) + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }); + }} threadRef={activeThreadRef} + reference={{ + projectId: renderedRightPanelSurface.projectId as ProjectId, + ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), + repository: renderedRightPanelSurface.repository, + number: renderedRightPanelSurface.number, + }} + context={pullRequestPanelContext( + { + projectId: activeThread.projectId, + pullRequests: visiblePullRequests, + linkedPullRequest: linkedThreadPullRequest, + branchPullRequest: + activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, + }, + renderedRightPanelSurface, + )} composerDraftTarget={composerDraftTarget} - keybindings={keybindings} - availableEditors={availableEditors} - relativePath={ - renderedRightPanelSurface.kind === "file" - ? renderedRightPanelSurface.relativePath - : null - } - {...(renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment - ? { attachment: renderedRightPanelSurface.attachment } - : {})} - revealLine={ - renderedRightPanelSurface.kind === "file" - ? (renderedRightPanelSurface.revealLine ?? null) - : null - } - revealRequestId={ - renderedRightPanelSurface.kind === "file" - ? renderedRightPanelSurface.revealRequestId - : 0 + onBack={ + activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 + ? addPullRequestsSurface + : undefined } - onOpenFile={openFileSurface} - onPendingChange={handleFilePendingChange} - selectedFilePending={ - renderedRightPanelSurface.kind === "file" && - pendingFileSurfaceIds.has(renderedRightPanelSurface.id) - } - workspaceMutationId={workspaceMutationId} /> - - ) : null - ) : null; + ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + + ) : renderedRightPanelSurface?.kind === "device" ? ( + + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> + + ) : (renderedRightPanelSurface?.kind === "files" || + renderedRightPanelSurface?.kind === "file") && + ((activeProject && activeWorkspaceRoot) || + (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( + + + + ) : null + ) : null; const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, - onPresentationChange: setThreadPanelPresentation, + onPresentationChange: threadPanelPresentation.set, forceNewWorktree: multipleModelSelections !== null, environmentId: activeThread.environmentId, threadId: activeThread.id, @@ -11225,40 +11185,49 @@ export default function ChatView(props: ChatViewProps) { onUpdateProjectScript: updateProjectScript, onDeleteProjectScript: deleteProjectScript, }; + const threadPanelToggle = ( + + ); const panelToggleControlProps = { terminalAvailable: activeProject !== null, terminalOpen: terminalUiState.terminalOpen, terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"), - threadPanelOpen, - threadPanelPresentation, - threadPanelPopoverHandle, - threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"), rightPanelAvailable: activeProject !== null, - rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), onToggleTerminal: toggleTerminalVisibility, - onToggleThreadPanel: toggleThreadPanel, onToggleRightPanel: toggleRightPanel, - } satisfies PanelLayoutControlsProps; - const panelToggleControls = ( + } satisfies Omit; + const renderPanelToggleControls = (view: RightPanelView) => ( ); const threadPanelHeaderControl = (
- +
+ {threadPanelToggle} +
); - const panelLayoutControls = ( + const renderPanelLayoutControls = (view: RightPanelView, panelWidth?: number) => (
- {!shouldUsePlanSidebarSheet ? ( - - - +
+ +
+
) : null} -
{panelToggleControls}
+
+ {renderPanelToggleControls(view)} +
); const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ @@ -11299,10 +11261,19 @@ export default function ChatView(props: ChatViewProps) { }); const content = ( -
+ - {rightPanelControlsAtRoot ? panelLayoutControls : null} -
( +
+ {isElectron && !shouldUsePlanSidebarSheet ? ( + + ) : null} + {shouldUsePlanSidebarSheet && !view.open ? renderPanelLayoutControls(view) : null} + {!shouldUsePlanSidebarSheet && view.ownsTitleBar ? ( + + {(panel, inlineSize) => renderPanelLayoutControls(panel, inlineSize.width)} + + ) : null} + {shouldUsePlanSidebarSheet ? null : threadPanelHeaderControl} + {!shouldUsePlanSidebarSheet && !view.ownsTitleBar ? ( + + {(panel, inlineSize) => renderPanelLayoutControls(panel, inlineSize.width)} + + ) : null} + +
)} - data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > - {/* Top bar */} -
- {isElectron && rightPanelControlsAtRoot ? ( - - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
- {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -11904,16 +11879,23 @@ export default function ChatView(props: ChatViewProps) { {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( ) : null} - {activeThreadRef && - activePreviewMiniPlayer && - previewMiniPlayerVisible && - (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( - - ) : null} + + {(view) => + activeThreadRef && + activePreviewMiniPlayer && + shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer.source, + view.renderedSurface, + ) && + (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( + + ) : null + } + @@ -11987,114 +11969,117 @@ export default function ChatView(props: ChatViewProps) { onAddTerminalContext={addTerminalContextToDraft} /> ))} -
- - {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} - onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - > - {rightPanelContent} - - ) : null} - {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( - - {panelToggleControls}
- ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurface} - onCloseSurface={closeRightPanelSurface} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} - onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - > - {rightPanelContent} - - - ) : null} + + + + {(view, inlineSize) => + view.present && !shouldUsePlanSidebarSheet && activeThreadRef ? ( + { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }} + onCloseOtherSurfaces={closeOtherRightPanelSurfaces} + onCloseSurfacesToRight={closeRightPanelSurfacesToRight} + onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} + onCopyFilePath={copyRightPanelFilePath} + onAddBrowser={() => createBrowserSurface()} + onAddBrowserInProfile={createBrowserSurface} + onAddTerminal={addTerminalSurface} + onAddDiff={addDiffSurface} + onAddFiles={addFilesSurface} + onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} + onAddDevice={addDeviceSurface} + browserAvailable={canOperatePreview && browserAvailable} + terminalAvailable={activeProject !== null && canOperateTerminal} + diffAvailable={isServerThread && isGitRepo} + filesAvailable={activeProject !== null} + pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={pullRequestsSurfaceAvailable} + deviceAvailable={activeThreadRef !== null} + > + {renderRightPanelContent(view)} + + ) : view.present && shouldUsePlanSidebarSheet && activeThreadRef ? ( + + {renderPanelToggleControls(view)}
+ ) : null + } + surfaces={view.renderedSurfaces} + environmentId={activeThreadRef.environmentId} + activeSurfaceId={view.renderedSurface?.id ?? null} + pendingSurfaceIds={pendingFileSurfaceIds} + previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} + terminalLabelsById={activeTerminalLabelsById} + onActivate={activateRightPanelSurface} + onCloseSurface={closeRightPanelSurface} + onRenameDevice={(surfaceId, title) => { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }} + onCloseOtherSurfaces={closeOtherRightPanelSurfaces} + onCloseSurfacesToRight={closeRightPanelSurfacesToRight} + onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} + onCopyFilePath={copyRightPanelFilePath} + onAddBrowser={() => createBrowserSurface()} + onAddBrowserInProfile={createBrowserSurface} + onAddTerminal={addTerminalSurface} + onAddDiff={addDiffSurface} + onAddFiles={addFilesSurface} + onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} + onAddDevice={addDeviceSurface} + browserAvailable={canOperatePreview && browserAvailable} + terminalAvailable={activeProject !== null && canOperateTerminal} + diffAvailable={isServerThread && isGitRepo} + filesAvailable={activeProject !== null} + pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={pullRequestsSurfaceAvailable} + deviceAvailable={activeThreadRef !== null} + > + {renderRightPanelContent(view)} + + + ) : null + } + )} - + ); return ( }; +}) { + const url = useOpenPanelPullRequestUrl(props.threadRef); + useLayoutEffect(() => { + props.urlRef.current = url; + }, [props.urlRef, url]); + return null; +} diff --git a/apps/web/src/components/ChatWorkspace.tsx b/apps/web/src/components/ChatWorkspace.tsx new file mode 100644 index 0000000000000..8d9378ebf1a0f --- /dev/null +++ b/apps/web/src/components/ChatWorkspace.tsx @@ -0,0 +1,167 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { PanelAnimationDurationMs } from "@t3tools/contracts/settings"; +import { + createContext, + type ReactNode, + type RefObject, + use, + useEffect, + useLayoutEffect, + useState, + useMemo, +} from "react"; + +import { useDiffPanelStore } from "~/diffPanelStore"; +import { + type PreviewPanelInlineSize, + usePreviewPanelInlineSize, +} from "~/hooks/usePreviewPanelInlineSize"; +import { cn } from "~/lib/utils"; +import { usePanelPresence } from "~/panelAnimations"; +import { + type RightPanelSurface, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "~/rightPanelStore"; + +export interface RightPanelView { + readonly open: boolean; + readonly present: boolean; + readonly maximized: boolean; + readonly ownsTitleBar: boolean; + readonly activeSurface: RightPanelSurface | null; + readonly renderedSurface: RightPanelSurface | null; + readonly renderedSurfaces: readonly RightPanelSurface[]; +} + +const RightPanelViewContext = createContext(null); +const RightPanelInlineSizeContext = createContext(null); + +const EMPTY_SURFACES: readonly RightPanelSurface[] = []; + +export function ChatWorkspace(props: { + readonly threadRef: ScopedThreadRef | null; + readonly threadKey: string | null; + readonly sheet: boolean; + readonly maximizeRequestThreadRef: ScopedThreadRef; + readonly animationsActive: boolean; + readonly animationDurationMs: PanelAnimationDurationMs; + readonly explicitDiffOpenRef: RefObject; + readonly children: ReactNode; +}) { + const { threadRef, threadKey, sheet, explicitDiffOpenRef } = props; + const [row, setRow] = useState(null); + const inlineSize = usePreviewPanelInlineSize(undefined, { + container: row, + widthStorageKey: `t3code:preview-panel-width:${threadKey}`, + }); + const open = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).isOpen, + ); + const surfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const activeSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, threadRef), + ); + const diffOpen = activeSurface?.kind === "diff"; + useLayoutEffect(() => { + const explicitThreadRef = explicitDiffOpenRef.current; + explicitDiffOpenRef.current = null; + // Generic openings always show Changes, including tab fallbacks and thread changes. + // A timeline click instead opens the specific turn/file the user requested. + if (diffOpen && threadRef && explicitThreadRef !== threadRef) { + useDiffPanelStore.getState().selectGitScope(threadRef, "branch"); + } + }, [diffOpen, explicitDiffOpenRef, threadRef]); + const presenceValue = useMemo(() => ({ activeSurface, surfaces }), [activeSurface, surfaces]); + const presence = usePanelPresence( + open && threadRef !== null, + presenceValue, + props.animationsActive, + threadKey, + props.animationDurationMs, + ); + const inline = open && !sheet; + const maximized = useRightPanelStore( + (state) => + inline && selectThreadRightPanelState(state.byThreadKey, threadRef).maximized === true, + ); + useEffect(() => { + if (!inline) return; + if (useRightPanelStore.getState().consumeMaximizeRequest(props.maximizeRequestThreadRef)) { + useRightPanelStore.getState().setMaximized(props.maximizeRequestThreadRef, true); + } + }, [inline, props.maximizeRequestThreadRef]); + const renderedSurface = presence.value?.activeSurface ?? null; + const renderedSurfaces = presence.value?.surfaces ?? EMPTY_SURFACES; + const view = useMemo( + () => ({ + open, + present: presence.present, + maximized, + ownsTitleBar: inline, + activeSurface, + renderedSurface, + renderedSurfaces, + }), + [activeSurface, inline, maximized, open, presence.present, renderedSurface, renderedSurfaces], + ); + + return ( +
+ + + {props.children} + + +
+ ); +} + +function useRightPanelView(): RightPanelView { + const view = use(RightPanelViewContext); + if (!view) throw new Error("Right panel slots must render inside ChatWorkspace."); + return view; +} + +export function RightPanelViewSlot(props: { + readonly children: (view: RightPanelView) => ReactNode; +}) { + const view = useRightPanelView(); + return props.children(view); +} + +export function RightPanelSlot(props: { + readonly children: (view: RightPanelView, inlineSize: PreviewPanelInlineSize) => ReactNode; +}) { + const view = useRightPanelView(); + const inlineSize = use(RightPanelInlineSizeContext); + if (!inlineSize) throw new Error("Right panel slots must render inside ChatWorkspace."); + return props.children(view, inlineSize); +} + +export function ChatWorkspaceColumn(props: { + readonly renderHeader: (view: RightPanelView) => ReactNode; + readonly children: ReactNode; +}) { + const view = useRightPanelView(); + return ( +
+ {props.renderHeader(view)} + {props.children} +
+ ); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34d..8f2e0e356ec00 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1165,6 +1165,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})} {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})} {...(props.inlineSize ? { inlineSize: props.inlineSize } : {})} + corner={ + props.mode === "inline" && !props.layoutControls ? ( + // The workspace's fixed titlebar controls float over this corner. Filling it keeps + // tabs sliding in with the panel from showing beneath them, and the panel edge + // reveals these controls rather than passing them over the chat header. +
+ ) : undefined + } >
; - threadPanelShortcutLabel: string | null; rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; rightPanelUnavailableLabel?: string; onToggleTerminal: () => void; - onToggleThreadPanel: () => void; onToggleRightPanel: () => void; } -export const PanelLayoutControls = memo(function PanelLayoutControls({ - showThreadPanelControl = true, - showTerminalControl = true, - showRightPanelControl = true, - terminalAvailable, - terminalOpen, - terminalShortcutLabel, - threadPanelOpen, - threadPanelPresentation, - threadPanelPopoverHandle, - threadPanelShortcutLabel, - rightPanelAvailable, - rightPanelOpen, - rightPanelShortcutLabel, - rightPanelUnavailableLabel = "Right panel is unavailable", - onToggleTerminal, - onToggleThreadPanel, - onToggleRightPanel, -}: PanelLayoutControlsProps) { - const threadPanelToggle = ( +/** Toggles the workspace card, as a popover trigger whenever the card cannot dock. */ +export const ThreadPanelToggle = memo(function ThreadPanelToggle({ + threadRef, + presentation: presentationStore, + popoverHandle, + shortcutLabel, + onToggle, +}: { + threadRef: ScopedThreadRef | null; + presentation: ThreadPanelPresentationStore; + popoverHandle: ReturnType; + shortcutLabel: string | null; + onToggle: () => void; +}) { + const presentation = useThreadPanelPresentation(presentationStore); + const open = useRightPanelStore((state) => + selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, presentation), + ); + const toggle = ( ); - const threadPanelTooltip = (trigger: ReactElement) => ( + const tooltip = (trigger: ReactElement) => ( Toggle thread details - {threadPanelShortcutLabel ? ` (${threadPanelShortcutLabel})` : ""} + {shortcutLabel ? ` (${shortcutLabel})` : ""} ); + return presentation === "popover" + ? tooltip() + : tooltip(toggle); +}); +export const PanelLayoutControls = memo(function PanelLayoutControls({ + threadPanelControl, + showTerminalControl = true, + showRightPanelControl = true, + terminalAvailable, + terminalOpen, + terminalShortcutLabel, + rightPanelAvailable, + rightPanelOpen, + rightPanelShortcutLabel, + rightPanelUnavailableLabel = "Right panel is unavailable", + onToggleTerminal, + onToggleRightPanel, +}: PanelLayoutControlsProps) { return (
- {showThreadPanelControl - ? threadPanelPresentation === "popover" - ? threadPanelTooltip( - , - ) - : threadPanelTooltip(threadPanelToggle) - : null} + {threadPanelControl} {showTerminalControl ? ( }> diff --git a/apps/web/src/components/chat/threadPanelPresentation.ts b/apps/web/src/components/chat/threadPanelPresentation.ts new file mode 100644 index 0000000000000..e847f6b729dab --- /dev/null +++ b/apps/web/src/components/chat/threadPanelPresentation.ts @@ -0,0 +1,35 @@ +import { useSyncExternalStore } from "react"; + +import type { ThreadPanelPresentation } from "../../rightPanelLayout"; + +/** + * Whether the workspace card docks inline or opens as a popover. The card decides from the + * canvas it sits in and the header toggle reads it, so a change re-renders only those two + * rather than the whole chat view. + */ +export interface ThreadPanelPresentationStore { + readonly get: () => ThreadPanelPresentation; + readonly set: (presentation: ThreadPanelPresentation) => void; + readonly subscribe: (listener: () => void) => () => void; +} + +export function createThreadPanelPresentationStore(): ThreadPanelPresentationStore { + let presentation: ThreadPanelPresentation = "inline"; + const listeners = new Set<() => void>(); + return { + get: () => presentation, + set: (next) => { + if (next === presentation) return; + presentation = next; + for (const listener of listeners) listener(); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +export function useThreadPanelPresentation(store: ThreadPanelPresentationStore) { + return useSyncExternalStore(store.subscribe, store.get); +} diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 181a727c12338..a86b867f5a0fb 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -41,6 +41,8 @@ interface PreviewPanelShellProps { widthStorageKey?: string; /** Overrides the initial width (px) before the user has resized the panel. */ defaultWidth?: number; + /** Pinned to the panel's top right, so it stays put while the content slides with the edge. */ + corner?: ReactNode; children: ReactNode; } @@ -145,7 +147,7 @@ function PreviewPanelShellFrame( data-preview-panel-maximized={maximized ? "true" : "false"} > {isInline && !maximized ? : null} -
+
{props.children}
+ {props.corner ? ( +
+ {props.corner} +
+ ) : null}
); diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 6719eaf89e405..c204fd425f040 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -15,10 +15,10 @@ import { useEnvironmentQuery } from "../state/query"; import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { - const surface = useRightPanelStore((state) => - selectActiveRightPanelSurface(state.byThreadKey, threadRef), - ); - const requestedReference = surface?.kind === "pull-request" ? surface : null; + const requestedReference = useRightPanelStore((state) => { + const surface = selectActiveRightPanelSurface(state.byThreadKey, threadRef); + return surface?.kind === "pull-request" ? surface : null; + }); const environmentId = requestedReference?.environmentId ? EnvironmentId.make(requestedReference.environmentId) : threadRef?.environmentId; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index dfdf09af249dd..b0e2a063f9d28 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -123,6 +123,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; + /* Right inset of the titlebar slot beside the fixed terminal and right panel toggles. */ + --workspace-controls-cluster-inset: calc( + var(--workspace-controls-right) + 1px + 2 * (var(--workspace-titlebar-control-size) + 0.25rem) + ); --workspace-titlebar-control-gap: 0.75rem; --workspace-titlebar-scroll-fade-height: 1.5rem; --chat-content-max-width: 46rem; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 86fcbe400bc96..f7211ee1706c8 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1844,14 +1844,9 @@ function PullRequestsRouteView() { const panelToggleControls = ( undefined} rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} From 15278610036dbb6a3098bca542d37790b17934df Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 9 Oct 2026 06:12:51 +0000 Subject: [PATCH 2/2] fix(web): workspace card no longer flickers during panel transitions --- apps/web/src/components/ChatView.tsx | 18 +- apps/web/src/components/ChatWorkspace.tsx | 183 +++++++++- .../chat/ChatCanvas.settlement.test.tsx | 115 +++++++ .../src/components/chat/ChatCanvas.test.tsx | 33 +- apps/web/src/components/chat/ChatCanvas.tsx | 313 +++++++++++++----- .../src/components/chat/ChatCanvasContext.ts | 30 +- .../src/components/chat/ThreadDetailsCard.tsx | 51 +-- .../chat/threadDetailsCardLayout.test.ts | 21 ++ .../chat/threadDetailsCardLayout.ts | 12 +- .../preview/PreviewPanelShell.test.ts | 6 + .../components/preview/PreviewPanelShell.tsx | 17 +- .../preview/ThreadPreviewMiniPlayer.tsx | 6 +- apps/web/src/components/ui/sidebar.tsx | 34 +- .../src/hooks/usePreviewPanelInlineSize.ts | 34 +- apps/web/src/hooks/useResizableWidth.test.tsx | 49 +++ apps/web/src/hooks/useResizableWidth.ts | 31 +- apps/web/src/index.css | 24 +- 17 files changed, 780 insertions(+), 197 deletions(-) create mode 100644 apps/web/src/components/chat/ChatCanvas.settlement.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 680f3da69ff28..7805904a44529 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -283,6 +283,7 @@ import { RightPanelTabs } from "./RightPanelTabs"; import { ChatWorkspace, ChatWorkspaceColumn, + RightPanelControlClip, type RightPanelView, RightPanelSlot, RightPanelViewSlot, @@ -11227,7 +11228,7 @@ export default function ChatView(props: ChatViewProps) {
); - const renderPanelLayoutControls = (view: RightPanelView, panelWidth?: number) => ( + const renderPanelLayoutControls = (view: RightPanelView, panelWidth?: number | string) => (
{!shouldUsePlanSidebarSheet && view.open ? ( -
+
-
+ ) : null}
{renderPanelToggleControls(view)} @@ -11324,13 +11322,17 @@ export default function ChatView(props: ChatViewProps) { {shouldUsePlanSidebarSheet && !view.open ? renderPanelLayoutControls(view) : null} {!shouldUsePlanSidebarSheet && view.ownsTitleBar ? ( - {(panel, inlineSize) => renderPanelLayoutControls(panel, inlineSize.width)} + {(panel, inlineSize) => + renderPanelLayoutControls(panel, inlineSize.liveWidth ?? inlineSize.width) + } ) : null} {shouldUsePlanSidebarSheet ? null : threadPanelHeaderControl} {!shouldUsePlanSidebarSheet && !view.ownsTitleBar ? ( - {(panel, inlineSize) => renderPanelLayoutControls(panel, inlineSize.width)} + {(panel, inlineSize) => + renderPanelLayoutControls(panel, inlineSize.liveWidth ?? inlineSize.width) + } ) : null} void; +} | null>(null); + const RightPanelViewContext = createContext(null); const RightPanelInlineSizeContext = createContext(null); @@ -51,11 +64,45 @@ export function ChatWorkspace(props: { readonly children: ReactNode; }) { const { threadRef, threadKey, sheet, explicitDiffOpenRef } = props; - const [row, setRow] = useState(null); + const sidebar = useSidebarGeometry(); + const viewportWidth = typeof window === "undefined" ? sidebar.width : window.innerWidth; + const rowRef = useRef(null); + const rowWidthRef = useRef(0); + const completionRef = useRef<(() => void) | null>(null); + const canvasWidthRef = useRef(0); + const [measuredRowWidth, setMeasuredRowWidth] = useState(0); + useLayoutEffect(() => { + const row = rowRef.current; + if (!row) return; + const measure = (width: number) => { + rowWidthRef.current = width; + if (completionRef.current) { + completionRef.current?.(); + return; + } + setMeasuredRowWidth(row.clientWidth); + }; + measure(row.getBoundingClientRect().width); + return observeResize(row, ([entry]) => { + if (entry) measure(entry.contentRect.width); + }); + }, []); + const rowWidth = Math.max(0, sidebar.width - sidebar.occupiedWidth); const inlineSize = usePreviewPanelInlineSize(undefined, { - container: row, + containerWidth: measuredRowWidth || undefined, + container: rowRef.current, widthStorageKey: `t3code:preview-panel-width:${threadKey}`, }); + const anticipatedPanelWidth = Math.min( + inlineSize.requestedWidth ?? inlineSize.width, + getPreviewPanelMaxWidth(viewportWidth, rowWidth ? Math.round(rowWidth) : undefined), + ); + const panelTargetRef = useRef(null); + const [panelTargetWidth, setPanelTargetWidth] = useState(anticipatedPanelWidth); + useLayoutEffect(() => { + const target = panelTargetRef.current; + if (target) setPanelTargetWidth(target.getBoundingClientRect().width); + }, [anticipatedPanelWidth]); const open = useRightPanelStore( (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).isOpen, ); @@ -109,16 +156,114 @@ export function ChatWorkspace(props: { [activeSurface, inline, maximized, open, presence.present, renderedSurface, renderedSurfaces], ); + const width = maximized ? 0 : rowWidth - (inline ? panelTargetWidth : 0); + const [motion, setMotion] = useState({ + width, + open, + sidebarOpen: sidebar.open, + threadKey, + dockingWidth: Math.round(width), + moving: false, + }); + if ( + motion.width !== width || + motion.open !== open || + motion.sidebarOpen !== sidebar.open || + motion.threadKey !== threadKey || + (motion.moving && !props.animationsActive) + ) { + const moving = + props.animationsActive && + motion.threadKey === threadKey && + (motion.open !== open || motion.sidebarOpen !== sidebar.open || motion.moving); + setMotion({ + width, + open, + sidebarOpen: sidebar.open, + threadKey, + dockingWidth: moving ? Math.min(motion.dockingWidth, Math.round(width)) : Math.round(width), + moving, + }); + } + const finish = useCallback(() => { + setMeasuredRowWidth(rowRef.current?.clientWidth ?? rowWidthRef.current); + setMotion((current) => (current === motion ? { ...current, moving: false } : current)); + }, [motion]); + useLayoutEffect(() => { + if (!motion.moving) return; + const deadline = performance.now() + props.animationDurationMs; + completionRef.current = () => { + if ( + performance.now() >= deadline && + Math.abs(rowWidthRef.current - rowWidth) < 0.1 && + canvasWidthRef.current === Math.round(motion.width) + ) + finish(); + }; + const timeout = window.setTimeout(finish, props.animationDurationMs + 50); + return () => { + window.clearTimeout(timeout); + completionRef.current = null; + }; + }, [motion, rowWidth, finish, props.animationDurationMs]); + const onMeasured = useCallback((width: number) => { + canvasWidthRef.current = width; + setMotion((current) => + current.moving || current.dockingWidth === width + ? current + : { ...current, dockingWidth: width }, + ); + completionRef.current?.(); + }, []); + const liveInlineSize = useMemo( + () => + motion.moving + ? { + ...inlineSize, + width: Math.max( + inlineSize.width, + panelTargetWidth, + Math.min( + inlineSize.requestedWidth ?? inlineSize.width, + getPreviewPanelMaxWidth(viewportWidth, Math.round(rowWidthRef.current)), + ), + ), + liveWidth: getPreviewPanelLiveWidth( + inlineSize.requestedWidth ?? inlineSize.width, + viewportWidth, + ), + } + : inlineSize, + [motion.moving, inlineSize, panelTargetWidth, viewportWidth], + ); + const geometry = useMemo( + () => ({ + width: motion.width, + dockingWidth: motion.dockingWidth, + moving: motion.moving, + onMeasured, + }), + [motion.width, motion.dockingWidth, motion.moving, onMeasured], + ); + return (
- - - {props.children} - - +
+ + + + {props.children} + + +
); } @@ -145,6 +290,24 @@ export function RightPanelSlot(props: { return props.children(view, inlineSize); } +export function RightPanelControlClip(props: { + readonly width: number | string | undefined; + readonly children: ReactNode; +}) { + const [transition, setTransition] = useState({ initialWidth: props.width, resized: false }); + if (!transition.resized && transition.initialWidth !== props.width) { + setTransition({ ...transition, resized: true }); + } + return ( +
+ {props.children} +
+ ); +} + export function ChatWorkspaceColumn(props: { readonly renderHeader: (view: RightPanelView) => ReactNode; readonly children: ReactNode; diff --git a/apps/web/src/components/chat/ChatCanvas.settlement.test.tsx b/apps/web/src/components/chat/ChatCanvas.settlement.test.tsx new file mode 100644 index 0000000000000..d88a05b93f4ab --- /dev/null +++ b/apps/web/src/components/chat/ChatCanvas.settlement.test.tsx @@ -0,0 +1,115 @@ +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ChatWorkspaceGeometryContext } from "../ChatWorkspace"; +import { ChatCanvas } from "./ChatCanvas"; +import { useChatCanvas } from "./ChatCanvasContext"; + +let renderer: ReactTestRenderer; +let context: ReturnType; +let resized: () => void; +const resize = vi.hoisted(() => ({ callbacks: new Set<() => void>() })); +vi.mock("../../lib/observeResize", () => ({ + observeResize: ( + _elements: unknown, + callback: (entries: { contentRect: { width: number } }[]) => void, + ) => { + const notify = () => callback([{ contentRect: { width: 0 } }]); + resize.callbacks.add(notify); + return () => resize.callbacks.delete(notify); + }, +})); +let width = 900; +const canvas = { + get clientWidth() { + return width; + }, + clientHeight: 900, + querySelectorAll: () => [], + querySelector: () => null, + getBoundingClientRect: () => ({ left: 0, top: 0 }), +}; + +function mockElement({ props }: { props: unknown }) { + if (props && typeof props === "object" && "data-chat-reservation-source" in props) { + return { getBoundingClientRect: () => ({ width: 0 }) }; + } + return props && typeof props === "object" && "data-chat-canvas" in props ? canvas : {}; +} + +function ReadCard() { + const card = useChatCanvas(); + useLayoutEffect(() => { + context = card; + }, [card]); + return null; +} + +function Workspace({ destination = 900, moving = false }) { + return ( + {} }} + > + + + + + ); +} + +beforeEach(() => { + width = 900; + resized = () => { + for (const callback of resize.callbacks) callback(); + }; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("getComputedStyle", () => ({ + paddingLeft: "48px", + width: "736px", + minWidth: "640px", + getPropertyValue: () => "736px", + })); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + resize.callbacks.clear(); + vi.unstubAllGlobals(); +}); + +describe("workspace card settlement", () => { + it.each([ + { destination: 1521, actual: 1522, x: 1230 }, + { destination: 1011, actual: 1012, x: 720 }, + { destination: 1012, actual: 1011, x: null }, + ])("uses the measured endpoint at $actual rather than forecast $destination", async (sample) => { + await act(() => { + renderer = create(, { createNodeMock: mockElement }); + }); + await act(() => renderer.update()); + width = sample.actual; + await act(() => resized()); + expect(context?.detailsCard.placement).toBeNull(); + await act(() => renderer.update()); + expect(context?.detailsCard.placement).toEqual( + sample.x === null ? null : { x: sample.x, y: 12, width: 280, height: 876 }, + ); + }); + + it("keeps the card undocked when a transition reverses before workspace completion", async () => { + await act(() => { + renderer = create(, { createNodeMock: mockElement }); + }); + await act(() => renderer.update()); + width = 1200; + await act(() => resized()); + await act(() => renderer.update()); + expect(context?.detailsCard.placement).toBeNull(); + width = 900; + resized = () => { + for (const callback of resize.callbacks) callback(); + }; + await act(() => renderer.update()); + expect(context?.detailsCard.placement).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/ChatCanvas.test.tsx b/apps/web/src/components/chat/ChatCanvas.test.tsx index 5ab43b81cb2e1..244040959f34b 100644 --- a/apps/web/src/components/chat/ChatCanvas.test.tsx +++ b/apps/web/src/components/chat/ChatCanvas.test.tsx @@ -8,9 +8,13 @@ import { useChatCanvas } from "./ChatCanvasContext"; const resize = vi.hoisted(() => ({ callbacks: new Set<() => void>() })); vi.mock("../../lib/observeResize", () => ({ - observeResize: (_elements: unknown, callback: () => void) => { - resize.callbacks.add(callback); - return () => resize.callbacks.delete(callback); + observeResize: ( + _elements: unknown, + callback: (entries: { contentRect: { width: number } }[]) => void, + ) => { + const notify = () => callback([{ contentRect: { width: 0 } }]); + resize.callbacks.add(notify); + return () => resize.callbacks.delete(notify); }, })); @@ -57,11 +61,12 @@ beforeEach(() => { } return new DOMRect(200, 100, 1800, 1000); }); - vi.spyOn(window, "getComputedStyle").mockReturnValue({ - paddingLeft: "48px", - width: "736px", - minWidth: "640px", - } as CSSStyleDeclaration); + const styles = document.createElement("div").style; + styles.paddingLeft = "48px"; + styles.width = "736px"; + styles.minWidth = "640px"; + styles.setProperty("--chat-content-max-width", "736px"); + vi.spyOn(window, "getComputedStyle").mockReturnValue(styles); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -91,29 +96,29 @@ it.each([16, 24])( async (size) => { fontSize = size; await renderFind(false); - expect(canvas?.layout.frame?.y).toBe(12); + expect(canvas?.previewFrame?.y).toBe(12); await renderFind(true); expect(canvas?.detailsCardTopInset).toBe(3 * size); - expect(canvas?.layout.frame?.y).toBe(3 * size + 12); + expect(canvas?.previewFrame?.y).toBe(3 * size + 12); await renderFind(false); expect(canvas?.detailsCardTopInset).toBe(0); - expect(canvas?.layout.frame?.y).toBe(12); + expect(canvas?.previewFrame?.y).toBe(12); await renderFind(true); - expect(canvas?.layout.frame?.y).toBe(3 * size + 12); + expect(canvas?.previewFrame?.y).toBe(3 * size + 12); }, ); it("remeasures the open bar when its font size changes without resizing the canvas", async () => { await renderFind(true, 1200); - expect(canvas?.layout.frame?.y).toBe(12); + expect(canvas?.previewFrame?.y).toBe(12); fontSize = 24; await act(() => { for (const callback of resize.callbacks) callback(); }); expect(canvas?.detailsCardTopInset).toBe(72); - expect(canvas?.layout.frame?.y).toBe(84); + expect(canvas?.previewFrame?.y).toBe(84); }); diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 809443c8f6861..fe4704a974d60 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -1,4 +1,5 @@ import { + use, useCallback, useLayoutEffect, useMemo, @@ -7,15 +8,40 @@ import { type ComponentProps, type CSSProperties, } from "react"; +import { observeResize } from "../../lib/observeResize"; +import { ChatWorkspaceGeometryContext } from "../ChatWorkspace"; import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; -import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; -import { observeResize } from "../../lib/observeResize"; +import { + resolveThreadDetailsCardLayout, + THREAD_DETAILS_CARD_GAP, + THREAD_DETAILS_CARD_WIDTH, +} from "./threadDetailsCardLayout"; +import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout"; + +const CARD_SPACE = THREAD_DETAILS_CARD_WIDTH + THREAD_DETAILS_CARD_GAP + DETAILS_CARD_CLEARANCE; + +function cardReservation(width: number, lane: { padding: number; maxChatWidth: number }) { + return Math.max( + 0, + Math.min(lane.maxChatWidth + CARD_SPACE * 2 - width, CARD_SPACE - lane.padding), + ); +} + +function useCardBox(placement: ReturnType, settled = false) { + const x = settled ? placement?.x : undefined; + const y = placement?.y, + width = placement?.width, + height = placement?.height; + return useMemo( + () => + y === undefined || width === undefined || height === undefined + ? null + : { x, y, width, height }, + [x, y, width, height], + ); +} -/** - * Owns the available conversation space. Cards only report where they sit; the - * canvas decides when chat moves over to make room for them. - */ export function ChatCanvas({ composerOverlayElement, detailsCardTopInset = 0, @@ -27,23 +53,15 @@ export function ChatCanvas({ }) { const elementRef = useRef(null); const widthProbeRef = useRef(null); + const reservationProbeRef = useRef(null); const [timelineElement, registerTimeline] = useState(null); const [preview, setPreview] = useState(null); - const [detailsCard, setDetailsCard] = useState(null); - const reportDetailsCard = useCallback((next: PreviewMiniPlayerObstacles["detailsCard"]) => { - setDetailsCard((current) => - current?.left === next?.left && - current?.right === next?.right && - current?.bottom === next?.bottom - ? current - : next, - ); - }, []); + const [card, setCard] = useState({ inlineOpen: false, contentHeight: 0 }); const [measurements, setMeasurements] = useState({ width: 0, height: 0, padding: 48, - maxChatWidth: 768, + maxChatWidth: 736, minChatWidth: 640, composerHeight: 0, timelineGutter: 0, @@ -51,23 +69,84 @@ export function ChatCanvas({ findBarRight: 0, findBarBottom: 0, }); - const reportPreview = useCallback((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 measurementsRef = useRef(measurements); + const containerRef = useRef({ width: 0, height: 0 }); + const readContainer = useCallback(() => containerRef.current, []); + const geometry = use(ChatWorkspaceGeometryContext); + const estimatedWidth = geometry?.width ?? measurements.width; + const moving = geometry?.moving ?? false; + const previewActive = preview !== null; + const targetWidth = moving ? Math.round(estimatedWidth) : measurements.width; + const dockingWidth = moving && geometry ? geometry.dockingWidth : measurements.width; + const findBar = + detailsCardTopInset > 0 + ? { + left: measurements.findBarLeft, + right: measurements.findBarRight, + bottom: measurements.findBarBottom, + } + : null; + const cardLayout = { + container: { width: targetWidth, height: measurements.height }, + lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, + frame: null, + topInset: findBar?.bottom ?? 0, + }; + const targetCard = resolveThreadDetailsCardLayout(cardLayout); + const container = { + width: preview ? measurements.width : targetWidth, + height: measurements.height, + }; + const preferred = resolveThreadDetailsCardLayout({ + ...cardLayout, + container, + dockingWidth, + }); + const reservation = + card.inlineOpen && targetCard ? cardReservation(targetWidth, measurements) : 0; + const liveReservation = + !preview && moving && preferred !== null && targetCard !== null && card.inlineOpen; + const detailsCard = + card.inlineOpen && preferred && card.contentHeight > 0 + ? { + left: preferred.x, + right: preferred.x + preferred.width, + bottom: preferred.y + Math.min(card.contentHeight, preferred.height), + } + : null; + const layout = resolveChatCanvasLayout({ + ...measurements, + container, + preview, + detailsCard, + findBar, + }); + const placement = resolveThreadDetailsCardLayout({ + ...cardLayout, + container, + frame: layout.frame, + overlapsDetailsCard: layout.overlapsDetailsCard, + dockingWidth, + }); + const insetEnd = preview || !moving ? layout.chat.insetEnd : reservation; + const reservationValue = liveReservation + ? `clamp(0px, calc(${measurements.maxChatWidth === Infinity ? "100%" : `${measurements.maxChatWidth}px`} + ${CARD_SPACE * 2}px - 100% - ${Math.round(estimatedWidth) - estimatedWidth}px), ${CARD_SPACE - measurements.padding}px)` + : `${insetEnd}px`; + useLayoutEffect(() => { + const probe = reservationProbeRef.current; + const targets = elementRef.current?.querySelectorAll( + ".chat-composer-lane, .messages-timeline-scroll, .chat-scroll-to-bottom", ); - }, []); - const clearPreview = useCallback( - (key: string) => setPreview((current) => (current?.key === key ? null : current)), - [], - ); + if (!probe || !targets) return; + const apply = (width: number) => { + const value = moving && !previewActive ? `${width}px` : reservationValue; + targets.forEach((target) => target.style.setProperty("--chat-card-reservation", value)); + }; + apply(probe.getBoundingClientRect().width); + return observeResize(probe, ([entry]) => { + if (entry) apply(entry.contentRect.width); + }); + }, [reservationValue, moving, previewActive, timelineElement, composerOverlayElement, children]); useLayoutEffect(() => { const element = elementRef.current; const probe = widthProbeRef.current; @@ -78,27 +157,46 @@ export function ChatCanvas({ const canvasBounds = element.getBoundingClientRect(); const findBarBounds = findBar?.getBoundingClientRect(); const styles = getComputedStyle(probe); + const width = element.clientWidth; + const height = element.clientHeight; + containerRef.current = { width, height }; + geometry?.onMeasured(width); const next = { - findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0, - findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0, + findBarLeft: + moving && !previewActive + ? measurementsRef.current.findBarLeft + : findBarBounds + ? findBarBounds.left - canvasBounds.left + : 0, + findBarRight: + moving && !previewActive + ? measurementsRef.current.findBarRight + : findBarBounds + ? findBarBounds.right - canvasBounds.left + : 0, findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0, - width: element.clientWidth, - height: element.clientHeight, + width: previewActive || !moving ? width : measurementsRef.current.width, + height, padding: Number.parseFloat(styles.paddingLeft), - maxChatWidth: Number.parseFloat(styles.width), + maxChatWidth: + styles.getPropertyValue("--chat-content-max-width").trim() === "100%" + ? Infinity + : Number.parseFloat(styles.width), minChatWidth: Number.parseFloat(styles.minWidth), composerHeight: composerOverlayElement?.getBoundingClientRect().height ?? 0, timelineGutter: timelineElement ? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2 : 0, }; - setMeasurements((current) => + if ( Object.keys(next).every( - (key) => current[key as keyof typeof current] === next[key as keyof typeof next], + (key) => + measurementsRef.current[key as keyof typeof next] === next[key as keyof typeof next], ) - ? current - : next, - ); + ) + return; + measurementsRef.current = next; + setMeasurements(next); }; measure(); const observed: Element[] = [element, probe]; @@ -106,44 +204,69 @@ export function ChatCanvas({ if (composerOverlayElement) observed.push(composerOverlayElement); if (timelineElement) observed.push(timelineElement); return observeResize(observed, measure); - }, [composerOverlayElement, timelineElement, detailsCardTopInset]); - const context = useMemo(() => { - const container = { width: measurements.width, height: measurements.height }; - const findBar = - detailsCardTopInset > 0 - ? { - left: measurements.findBarLeft, - right: measurements.findBarRight, - bottom: measurements.findBarBottom, - } - : null; - return { - container, - lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, - layout: resolveChatCanvasLayout({ - ...measurements, - container, - preview, - detailsCard, - findBar, - }), + }, [ + composerOverlayElement, + timelineElement, + geometry, + moving, + previewActive, + detailsCardTopInset, + ]); + const reportPreview = useCallback((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 = useCallback( + (key: string) => setPreview((current) => (current?.key === key ? null : current)), + [], + ); + const reportDetailsCard = useCallback((inlineOpen: boolean, contentHeight: number) => { + setCard((current) => + current.inlineOpen === inlineOpen && current.contentHeight === contentHeight + ? current + : { inlineOpen, contentHeight }, + ); + }, []); + const preferredBox = useCardBox(preferred); + const placementBox = useCardBox(placement, !moving); + const context = useMemo( + () => ({ + readContainer, + detailsCardTopInset: findBar?.bottom ?? 0, previewKey: preview?.key ?? null, + previewFrame: layout.frame, + detailsCard: { + preferred: preferredBox, + placement: placementBox, + containerHeight: measurements.height, + }, reportPreview, clearPreview, registerTimeline, reportDetailsCard, - detailsCardTopInset: findBar?.bottom ?? 0, - }; - }, [ - measurements, - preview, - detailsCard, - reportPreview, - clearPreview, - reportDetailsCard, - detailsCardTopInset, - ]); - const { layout } = context; + }), + [ + readContainer, + findBar?.bottom, + preview?.key, + layout.frame, + preferredBox, + placementBox, + measurements.height, + reportPreview, + clearPreview, + reportDetailsCard, + ], + ); return (
- {children} + data-chat-lane + data-animated={moving && !preview && card.inlineOpen ? "true" : "false"} + data-live-reservation={liveReservation ? "true" : "false"} + className="flex min-h-0 min-w-0 flex-1 flex-col" + style={ + { + "--chat-timeline-gutter": `${measurements.timelineGutter}px`, + "--chat-lane-inset-start": `${layout.chat.insetStart}px`, + "--chat-canvas-rounding": `${Math.round(estimatedWidth) - estimatedWidth}px`, + } as CSSProperties + } + > +
+
+ {children} +
); diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index f5fc19d6a7f02..efbdbd323c5c9 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -1,18 +1,32 @@ import { createContext, useContext } from "react"; -import type { ChatCanvasPreview, resolveChatCanvasLayout } from "./chatCanvasLayout"; -import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; +import type { ChatCanvasPreview } from "./chatCanvasLayout"; +import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout"; + +/** The card's docked box; during motion it pins to the canvas's right edge. */ +export interface ThreadDetailsCardBox { + x: number | undefined; + y: number; + width: number; + height: number; +} export const ChatCanvasContext = createContext<{ - container: { width: number; height: number }; - lane: { padding: number; minChatWidth: number }; - layout: ReturnType; + /** The latest canvas size, for gestures; reading it does not re-render on every resize. */ + readContainer: () => { width: number; height: number }; + detailsCardTopInset: number; previewKey: string | null; + previewFrame: PreviewMiniPlayerFrame | null; + detailsCard: { + /** Where the card docks with no preview in the way, or null when it cannot dock. */ + preferred: ThreadDetailsCardBox | null; + placement: ThreadDetailsCardBox | null; + containerHeight: number; + }; reportPreview: (preview: ChatCanvasPreview) => void; clearPreview: (key: string) => void; registerTimeline: (element: HTMLElement | null) => void; - reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void; - /** Height reserved above the details card, such as the open find bar. */ - detailsCardTopInset: number; + /** Reports whether the user keeps the card open where it can dock, and its content height. */ + reportDetailsCard: (inlineOpen: boolean, contentHeight: number) => void; } | null>(null); export const useChatCanvas = () => useContext(ChatCanvasContext); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 0b0ebb8398bda..e734954850ae8 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -9,11 +9,10 @@ import { useChatCanvas } from "./ChatCanvasContext"; import { THREAD_DETAILS_CARD_GAP, resolveThreadDetailsCardDensity, - resolveThreadDetailsCardLayout, } from "./threadDetailsCardLayout"; import { observeResize } from "../../lib/observeResize"; -/** One card owns its placement and folds content only when that content cannot fit. */ +/** Docks where the chat canvas places it and folds content only when that content cannot fit. */ export function ThreadDetailsCard({ threadRef, anchor, @@ -28,23 +27,8 @@ export function ThreadDetailsCard({ 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?.detailsCard.preferred ?? null; + const placement = canvas?.detailsCard.placement ?? null; const mode = placement ? "inline" : "popover"; const topInset = canvas?.detailsCardTopInset ?? 0; const inlineOpen = useRightPanelStore((state) => @@ -62,25 +46,14 @@ export function ThreadDetailsCard({ }); const contentHeights = measurements.key === measurementKey ? measurements.heights : { full: 0, compact: 0 }; - const height = placement?.height ?? Math.max(0, (canvas?.container.height ?? 0) - 52); + const height = placement?.height ?? Math.max(0, (canvas?.detailsCard.containerHeight ?? 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; + const contentHeight = measurements.key === measurementKey ? measurements.fullContentHeight : 0; 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]); + reportDetailsCard?.(inlineOpen, contentHeight); + }, [reportDetailsCard, inlineOpen, contentHeight]); + useLayoutEffect(() => () => reportDetailsCard?.(false, 0), [reportDetailsCard]); useLayoutEffect(() => { onPresentationChange(mode); if (mode === "inline" && popoverOpen) @@ -168,9 +141,11 @@ export function ThreadDetailsCard({ "transition-[top] duration-150 ease-out motion-reduce:transition-none", )} style={{ - // Anchored to the right edge like the find bar, so both follow a resizing - // canvas in the same frame instead of waiting for its next measurement. - right: placement.right, + left: placement.x, + right: + placement.x === undefined + ? `calc(${THREAD_DETAILS_CARD_GAP}px - var(--chat-canvas-rounding))` + : undefined, top: placement.y, width: placement.width, maxHeight: height, diff --git a/apps/web/src/components/chat/threadDetailsCardLayout.test.ts b/apps/web/src/components/chat/threadDetailsCardLayout.test.ts index 0814e13a9bbef..0f94d4cd68ec8 100644 --- a/apps/web/src/components/chat/threadDetailsCardLayout.test.ts +++ b/apps/web/src/components/chat/threadDetailsCardLayout.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { resolveChatCanvasLayout } from "./chatCanvasLayout"; import { resolveThreadDetailsCardDensity, resolveThreadDetailsCardLayout, @@ -37,6 +38,26 @@ describe("workspace card", () => { expect(resolve(1012, 900)).toMatchObject({ x: 720 }); expect(resolve(1011, 900)).toBeNull(); }); + it("keeps the docking threshold aligned with rounded canvas measurements", () => { + expect(resolve(1011.4, 900)).toBeNull(); + expect(resolve(1011.5, 900)).not.toBeNull(); + expect(resolve(1011.6, 900)).not.toBeNull(); + }); + it("matches the closed-form reservation at every chat width and docking threshold", () => { + for (const maxChatWidth of [736, 1152, Infinity]) { + for (const width of [1011, 1012, 1013, 1100, 1300, 1600, 2000]) { + const card = resolve(width, 900); + const chat = resolveChatCanvasLayout({ + container: { width, height: 900 }, + preview: null, + maxChatWidth, + detailsCard: card && { left: card.x, right: card.x + card.width, bottom: 400 }, + }).chat; + const reservation = card ? Math.max(0, Math.min(maxChatWidth + 648 - width, 276)) : 0; + expect(chat.insetEnd).toBe(reservation); + } + } + }); it("keeps the card at the top right while the preview is freely dragged vertically", () => { for (const y of [12, 170, 250, 400, 648]) { expect(resolve(1600, 900, y)).toEqual({ x: 1308, right: 12, y: 12, width: 280, height: 876 }); diff --git a/apps/web/src/components/chat/threadDetailsCardLayout.ts b/apps/web/src/components/chat/threadDetailsCardLayout.ts index 3a95787a84b9e..d52b6ad42434e 100644 --- a/apps/web/src/components/chat/threadDetailsCardLayout.ts +++ b/apps/web/src/components/chat/threadDetailsCardLayout.ts @@ -3,8 +3,7 @@ import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout"; /** Inset of the card from the canvas edges; the find bar shares it to line up. */ export const THREAD_DETAILS_CARD_GAP = 12; -// Keep in sync with --thread-details-panel-width, which sizes the popover and the find bar. -const THREAD_DETAILS_CARD_WIDTH = 280; +export const THREAD_DETAILS_CARD_WIDTH = 280; export function resolveThreadDetailsCardDensity( height: number, @@ -25,6 +24,7 @@ export function resolveThreadDetailsCardLayout({ frame, overlapsDetailsCard = false, topInset = 0, + dockingWidth = container.width, }: { container: { width: number; height: number }; lane: { padding: number; minChatWidth: number }; @@ -32,11 +32,17 @@ export function resolveThreadDetailsCardLayout({ overlapsDetailsCard?: boolean; /** Space taken above the card, such as the open find bar. */ topInset?: number; + dockingWidth?: number; }) { const gap = THREAD_DETAILS_CARD_GAP; const width = THREAD_DETAILS_CARD_WIDTH; const x = container.width - width - gap; - if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null; + if ( + Math.round(dockingWidth) - width - gap - DETAILS_CARD_CLEARANCE - lane.padding < + lane.minChatWidth + ) { + return null; + } const y = gap + topInset; // Resizing consumes the height above the player. Dragging first tries to // clear the full card and folds it only when there is no readable placement. diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts index 765e0b0a1b025..7d25ce7806c64 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.test.ts +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -11,6 +11,12 @@ describe("getPreviewPanelMaxWidth", () => { expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400); }); + it("preserves the JavaScript viewport cap at floating-point boundaries", () => { + expect(getPreviewPanelMaxWidth(1_300)).toBe(909); + expect(getPreviewPanelMaxWidth(1_310)).toBe(916); + expect(getPreviewPanelMaxWidth(1_320)).toBe(923); + }); + it("reserves the sibling column minimum when the flex row is known", () => { // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256. // The 70% fraction (1058) would leave the chat column only ~198px; diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index a86b867f5a0fb..e492d174bc442 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -78,27 +78,33 @@ function PreviewPanelShellFrame( const localHostRef = useRef(null); const hostRef = props.hostRef ?? localHostRef; const { width, handlers } = props.inlineSize; + const live = props.inlineSize.liveWidth !== undefined; // Derive suppression before the layout commits so the browser never creates // a width transition for resize or maximize changes. const [layoutTransition, setLayoutTransition] = useState(() => ({ open, - width, + width: width, + live, maximized, suppressed: false, })); if ( layoutTransition.open !== open || layoutTransition.width !== width || + layoutTransition.live !== live || layoutTransition.maximized !== maximized ) { setLayoutTransition({ open, - width, + width: width, + live, maximized, suppressed: collapsible && layoutTransition.open === open && - (layoutTransition.width !== width || layoutTransition.maximized !== maximized), + (layoutTransition.width !== width || + layoutTransition.maximized !== maximized || + layoutTransition.live !== live), }); } const suppressWidthTransition = layoutTransition.suppressed; @@ -134,6 +140,7 @@ function PreviewPanelShellFrame( isInline ? ({ [RESIZABLE_WIDTH_PROPERTY]: `${width}px`, + maxWidth: maximized ? undefined : props.inlineSize.liveWidth, width: maximized ? "100%" : collapsible && !open @@ -152,7 +159,9 @@ function PreviewPanelShellFrame( className="flex h-full min-h-0 min-w-0 flex-col" style={ collapsible && !maximized - ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 1px)` } + ? { + width: `calc(${props.inlineSize.liveWidth ? `min(var(${RESIZABLE_WIDTH_PROPERTY}), ${props.inlineSize.liveWidth})` : `var(${RESIZABLE_WIDTH_PROPERTY})`} - 1px)`, + } : undefined } > diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index b249c44afc108..581a78fa85aaa 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -357,10 +357,9 @@ function MiniPlayerShell({ document.addEventListener("pointerdown", dismiss, true); return () => document.removeEventListener("pointerdown", dismiss, true); }, [pillOpen]); - const container = canvas?.container ?? null; const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); - const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; + const frame = canvas?.previewKey === sourceKey ? canvas.previewFrame : null; const { width: sourceWidth, height: sourceHeight } = sourceSize; const reportPreview = canvas?.reportPreview; const clearPreview = canvas?.clearPreview; @@ -412,7 +411,8 @@ function MiniPlayerShell({ const handlePointerMove = (event: ReactPointerEvent) => { const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId || !container) return; + if (!gesture || gesture.pointerId !== event.pointerId || !canvas) return; + const container = canvas.readContainer(); const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; gesture.moved = true; diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index c1bd6b277c5d8..8caa951a01437 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -17,6 +17,7 @@ import { } from "~/components/ui/sheet"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useResizeDrag } from "~/hooks/useResizeDrag"; +import { observeResize } from "~/lib/observeResize"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; @@ -30,6 +31,8 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { + workspaceWidth: number; + occupiedWidth: number; state: ResponsiveSidebarState; open: boolean; setOpen: (open: boolean) => void; @@ -74,6 +77,11 @@ type SidebarInstanceContextProps = { side: "left" | "right"; }; +export function useSidebarGeometry() { + const { workspaceWidth, occupiedWidth, isMobile, open } = useSidebar(); + return { width: workspaceWidth, occupiedWidth, open: !isMobile && open }; +} + const SidebarContext = React.createContext(null); const SidebarInstanceContext = React.createContext(null); @@ -91,6 +99,20 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } +function useSidebarElementWidth() { + const ref = React.useRef(null); + const [width, setWidth] = React.useState(null); + React.useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + setWidth(element.getBoundingClientRect().width); + return observeResize(element, ([entry]) => { + if (entry) setWidth(entry.contentRect.width); + }); + }, []); + return [ref, width] as const; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -105,6 +127,8 @@ function SidebarProvider({ onOpenChange?: (open: boolean) => void; }) { const isMobile = useIsMobile(); + const [wrapperRef, wrapperWidth] = useSidebarElementWidth(); + const [widthProbeRef, sidebarWidth] = useSidebarElementWidth(); const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. @@ -142,6 +166,8 @@ function SidebarProvider({ const contextValue = React.useMemo( () => ({ + workspaceWidth: wrapperWidth ?? 0, + occupiedWidth: !isMobile && open ? (sidebarWidth ?? 256) : 0, isMobile, open, openMobile, @@ -150,7 +176,7 @@ function SidebarProvider({ state, toggleSidebar, }), - [state, open, setOpen, isMobile, openMobile, toggleSidebar], + [state, open, setOpen, isMobile, openMobile, toggleSidebar, wrapperWidth, sidebarWidth], ); return ( @@ -161,6 +187,7 @@ function SidebarProvider({ "group/sidebar-wrapper flex min-h-svh w-full max-sm:[--workspace-titlebar-control-size:--spacing(8)]", className, )} + ref={wrapperRef} data-sidebar-state={state} data-slot="sidebar-wrapper" style={ @@ -174,6 +201,11 @@ function SidebarProvider({ } {...props} > +
{children}
diff --git a/apps/web/src/hooks/usePreviewPanelInlineSize.ts b/apps/web/src/hooks/usePreviewPanelInlineSize.ts index 1f91c53ccb1f7..52e1db9e045f5 100644 --- a/apps/web/src/hooks/usePreviewPanelInlineSize.ts +++ b/apps/web/src/hooks/usePreviewPanelInlineSize.ts @@ -5,6 +5,8 @@ import { observeResize } from "../lib/observeResize"; export interface PreviewPanelInlineSize { readonly width: number; + readonly requestedWidth?: number; + readonly liveWidth?: string; readonly handlers: ResizableWidthHandlers; } @@ -29,16 +31,28 @@ export function usePreviewPanelInlineSize( readonly enabled?: boolean | undefined; readonly widthStorageKey?: string | undefined; readonly defaultWidth?: number | undefined; - /** Measure this row instead of the panel's parent. */ readonly container?: HTMLElement | null | undefined; + readonly containerWidth?: number | undefined; } = {}, ): PreviewPanelInlineSize { - const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container); + const maxWidth = useViewportClampedMaxWidth( + hostRef, + options.enabled ?? true, + options.container, + options.containerWidth, + ); return useResizableWidth({ storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, minWidth: PREVIEW_PANEL_MIN_WIDTH, maxWidth, + readMaxWidth: () => + getPreviewPanelMaxWidth( + typeof window === "undefined" ? 1280 : window.innerWidth, + options.container?.clientWidth ?? + hostRef?.current?.parentElement?.clientWidth ?? + options.containerWidth, + ), edge: "left", }); } @@ -52,12 +66,13 @@ function useViewportClampedMaxWidth( hostRef: RefObject | undefined, enabled: boolean, container: HTMLElement | null | undefined, + containerWidth: number | undefined, ): number { const [maxWidth, setMaxWidth] = useState(() => getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth), ); useLayoutEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === "undefined" || containerWidth !== undefined) return; const row = container ?? (enabled ? hostRef?.current?.parentElement : null) ?? null; // Measure before first paint: the persisted width must be clamped against // the row on the initial render, not one observer tick later (the panel @@ -83,8 +98,13 @@ function useViewportClampedMaxWidth( if (frame !== 0) window.cancelAnimationFrame(frame); stopObserving?.(); }; - }, [container, hostRef, enabled]); - return maxWidth; + }, [container, containerWidth, hostRef, enabled]); + return containerWidth === undefined + ? maxWidth + : getPreviewPanelMaxWidth( + typeof window === "undefined" ? 1280 : window.innerWidth, + containerWidth, + ); } export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); @@ -96,3 +116,7 @@ export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: // drag-end persistence, overwrite the user's stored width). return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap)); } + +export function getPreviewPanelLiveWidth(requestedWidth: number, viewportWidth: number) { + return `min(${requestedWidth}px, max(${PREVIEW_PANEL_MIN_WIDTH}px, min(${getPreviewPanelMaxWidth(viewportWidth)}px, round(nearest, 100cqw, 1px) - ${SIBLING_COLUMN_MIN_WIDTH}px)))`; +} diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index 2d727eb79dcfd..c3bceb756cd70 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -52,16 +52,19 @@ function Panel({ edge = "left", maxWidth = 800, storageKey = "test-panel-width", + readMaxWidth, }: { edge?: "left" | "right"; maxWidth?: number; storageKey?: string; + readMaxWidth?: () => number; }) { const resize = useResizableWidth({ storageKey, defaultWidth: 400, minWidth: 200, maxWidth, + ...(readMaxWidth ? { readMaxWidth } : {}), edge, }); useLayoutEffect(() => { @@ -204,6 +207,52 @@ describe("panel resize cleanup", () => { }); }); +describe("panel container clamping", () => { + it("starts and updates a drag against live bounds while render measurements are cached", async () => { + savedWidths.set("wide-panel", "1120"); + let maximum = 684; + await act(() => + renderer.update( + maximum} />, + ), + ); + expect(result.width).toBe(909); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(190)); + }); + await act(() => frame?.(0)); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("594px"); + expect(result.width).toBe(909); + maximum = 550; + await act(() => result.handlers.onPointerMove(pointer(190))); + await act(() => frame?.(0)); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("550px"); + expect(result.width).toBe(909); + maximum = 684; + await act(() => result.handlers.onPointerUp(pointer(190))); + expect(result.width).toBe(594); + await act(() => renderer.update()); + expect(result.width).toBe(594); + expect(result.requestedWidth).toBe(594); + expect(setItem).toHaveBeenCalledExactlyOnceWith("wide-panel", "594"); + }); + + it("retains the requested width while the sibling needs more room", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerUp(pointer(-100)); + }); + expect(result.width).toBe(600); + await act(() => renderer.update()); + expect(result.width).toBe(360); + expect(result.requestedWidth).toBe(600); + await act(() => renderer.update()); + expect(result.width).toBe(600); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "600"); + }); +}); + describe("panel width storage changes", () => { it("restores separate thread widths without remounting and retains them after reload", async () => { await act(() => { diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index fc0d016c78b97..e9e5b8708f92e 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -24,6 +24,7 @@ export interface UseResizableWidthOptions { readonly defaultWidth: number; readonly minWidth: number; readonly maxWidth: number; + readonly readMaxWidth?: () => number; /** * Which edge of the host element carries the drag handle: * - "left" → panel grows leftward (right-anchored panels) @@ -52,14 +53,15 @@ export interface ResizableWidthHandlers { */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; + readonly requestedWidth: number; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; const clamp = useCallback( - (value: number): number => { + (value: number, maximum = maxWidth): number => { if (!Number.isFinite(value)) return defaultWidth; - return Math.max(minWidth, Math.min(maxWidth, value)); + return Math.max(minWidth, Math.min(maximum, value)); }, [defaultWidth, maxWidth, minWidth], ); @@ -82,10 +84,20 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } const clampedWidth = clamp(widthState.width); - const latestOptions = useRef({ clamp, storageKey, width: clampedWidth }); + const latestOptions = useRef({ + clamp, + storageKey, + width: clampedWidth, + readMaxWidth: options.readMaxWidth, + }); useLayoutEffect(() => { - latestOptions.current = { clamp, storageKey, width: clampedWidth }; - }, [clamp, clampedWidth, storageKey]); + latestOptions.current = { + clamp, + storageKey, + width: clampedWidth, + readMaxWidth: options.readMaxWidth, + }; + }, [clamp, clampedWidth, storageKey, options.readMaxWidth]); const { refresh, ...handlers } = useResizeDrag((event) => { const host = event.currentTarget.parentElement; @@ -93,10 +105,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // renders its own transition-duration, so override a property it leaves alone. host?.style.setProperty("transition-property", "none"); return { - width: clampedWidth, + width: clamp(widthState.width, options.readMaxWidth?.()), edge, resize(value) { - const nextWidth = latestOptions.current.clamp(value); + const nextWidth = latestOptions.current.clamp( + value, + latestOptions.current.readMaxWidth?.(), + ); host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, @@ -123,5 +138,5 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // rewrites the committed width, so re-apply the live pointer position. useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); - return { width: clampedWidth, handlers }; + return { width: clampedWidth, requestedWidth: widthState.width, handlers }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b0e2a063f9d28..5d14218588101 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2337,37 +2337,47 @@ code { padding-inline-start: calc( env(safe-area-inset-left) + 0.75rem + var(--chat-lane-inset-start, 0px) ); - padding-inline-end: calc( - env(safe-area-inset-right) + 0.75rem + var(--chat-lane-inset-end, 0px) - ); + padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem + var(--chat-card-reservation)); } [data-chat-canvas] .messages-timeline-scroll { padding-inline-start: calc( 0.75rem + var(--chat-lane-inset-start, 0px) - var(--chat-timeline-gutter, 0px) ); padding-inline-end: calc( - 0.75rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px) + 0.75rem + var(--chat-card-reservation) - var(--chat-timeline-gutter, 0px) ); } .chat-scroll-to-bottom, .chat-banner-lane { inset-inline-start: var(--chat-lane-inset-start, 0px); - inset-inline-end: var(--chat-lane-inset-end, 0px); + inset-inline-end: var(--chat-card-reservation); } @media (min-width: 640px) { .chat-composer-lane { padding-inline-start: calc( env(safe-area-inset-left) + 3rem + var(--chat-lane-inset-start, 0px) ); - padding-inline-end: calc(env(safe-area-inset-right) + 3rem + var(--chat-lane-inset-end, 0px)); + padding-inline-end: calc(env(safe-area-inset-right) + 3rem + var(--chat-card-reservation)); } [data-chat-canvas] .messages-timeline-scroll { padding-inline-start: calc( 3rem + var(--chat-lane-inset-start, 0px) - var(--chat-timeline-gutter, 0px) ); padding-inline-end: calc( - 3rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px) + 3rem + var(--chat-card-reservation) - var(--chat-timeline-gutter, 0px) ); } } } + +@property --chat-card-reservation { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +[data-panel-animations="true"] + [data-chat-lane][data-animated="true"][data-live-reservation="false"] + [data-chat-reservation-source] { + transition: width var(--panel-animation-duration) var(--ease-out); +}