diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..b251e76a532 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -16,6 +16,7 @@ import { buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, + chatAreaReservedComposerHeight, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, @@ -28,6 +29,7 @@ import { resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, + shouldShowDraftHeroHeadline, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -678,3 +680,106 @@ describe("hasServerAcknowledgedLocalDispatch", () => { expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); }); + +describe("chatAreaReservedComposerHeight", () => { + it("reserves the docked composer's height so it cannot spill over the header", () => { + expect( + chatAreaReservedComposerHeight({ + isDraftHeroState: false, + composerOverlayHeight: 318, + composerStackHeight: 310, + heroBannerHeight: 0, + }), + ).toBe(318); + }); + + it("reserves only the hero composer stack, so the terminal keeps its height", () => { + expect( + chatAreaReservedComposerHeight({ + isDraftHeroState: true, + composerOverlayHeight: 173, + composerStackHeight: 204, + heroBannerHeight: 0, + }), + ).toBe(204); + }); + + it("reserves twice any hero banner, since centering mirrors it below", () => { + expect( + chatAreaReservedComposerHeight({ + isDraftHeroState: true, + composerOverlayHeight: 173, + composerStackHeight: 204, + heroBannerHeight: 40, + }), + ).toBe(284); + }); + + it("ignores the hero overlay's own height, which is the chat area's height", () => { + expect( + chatAreaReservedComposerHeight({ + isDraftHeroState: true, + composerOverlayHeight: 900, + composerStackHeight: 204, + heroBannerHeight: 0, + }), + ).toBe(204); + }); + + it("reserves nothing before the composer has been measured", () => { + expect( + chatAreaReservedComposerHeight({ + isDraftHeroState: false, + composerOverlayHeight: 0, + composerStackHeight: 0, + heroBannerHeight: 0, + }), + ).toBe(0); + }); +}); + +describe("shouldShowDraftHeroHeadline", () => { + it("shows the headline when the chat area holds it above and below the composer", () => { + expect( + shouldShowDraftHeroHeadline({ + chatAreaHeight: 342, + composerStackHeight: 204, + heroHeadlineHeight: 69, + heroBannerHeight: 0, + }), + ).toBe(true); + }); + + it("hides the headline one pixel short, rather than shrinking the terminal", () => { + expect( + shouldShowDraftHeroHeadline({ + chatAreaHeight: 341, + composerStackHeight: 204, + heroHeadlineHeight: 69, + heroBannerHeight: 0, + }), + ).toBe(false); + }); + + it("counts banners, which keep their space, against the headline's room", () => { + expect( + shouldShowDraftHeroHeadline({ + chatAreaHeight: 342, + composerStackHeight: 204, + heroHeadlineHeight: 69, + heroBannerHeight: 40, + }), + ).toBe(false); + }); + + it("assumes room before anything has been measured", () => { + expect( + shouldShowDraftHeroHeadline({ + chatAreaHeight: 0, + composerStackHeight: 0, + heroHeadlineHeight: 0, + heroBannerHeight: 0, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd4551..a85d5c605fe 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -293,6 +293,63 @@ export function deriveComposerSendState(options: { }; } +/** + * Height the chat area must reserve for the composer. + * + * The composer is an overlay, so a chat area shorter than the composer does not + * clip it — it spills out instead, across the thread header above and the + * terminal drawer below. Reserving its height keeps the drawer from squeezing + * the chat area past that point. + * + * Docked, the composer is pinned to the bottom and the overlay box is the + * composer, so its height is the reserve. + * + * In the draft hero state the overlay instead spans the whole chat area and + * centers the composer stack inside it, with the headline and banners hanging + * above the stack. Reserving the overlay's own height there would latch the + * chat area at whatever size it already had, so the stack is the reserve, plus + * twice any banner — centering mirrors a banner's space below the composer, and + * banners carry actions that must not be pushed under the header. + * + * The hero headline is deliberately not reserved. It is decorative, and paying + * for it costs the terminal drawer far more height than it is worth; it hides + * instead (see {@link shouldShowDraftHeroHeadline}). + */ +export function chatAreaReservedComposerHeight(input: { + isDraftHeroState: boolean; + composerOverlayHeight: number; + composerStackHeight: number; + heroBannerHeight: number; +}): number { + if (!input.isDraftHeroState) return Math.max(0, input.composerOverlayHeight); + return Math.max(0, input.composerStackHeight) + 2 * Math.max(0, input.heroBannerHeight); +} + +/** + * Whether the draft hero headline has room to show above the centered composer. + * + * The composer is centered, so everything above it needs matching space below: + * the headline only fits when the chat area holds the composer stack plus twice + * the headline and banners. Below that the headline hides rather than forcing + * the terminal drawer to give up the height. + * + * Hidden means `visibility: hidden`, not unmounted — this reads the headline's + * measured height, so removing it from layout would make the decision oscillate. + */ +export function shouldShowDraftHeroHeadline(input: { + chatAreaHeight: number; + composerStackHeight: number; + heroHeadlineHeight: number; + heroBannerHeight: number; +}): boolean { + // Nothing measured yet: show it, matching the roomy case it renders into. + if (input.chatAreaHeight <= 0 || input.composerStackHeight <= 0) return true; + const needed = + input.composerStackHeight + + 2 * (Math.max(0, input.heroHeadlineHeight) + Math.max(0, input.heroBannerHeight)); + return input.chatAreaHeight >= needed; +} + export function buildExpiredTerminalContextToastCopy( expiredTerminalContextCount: number, variant: "omitted" | "empty", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..06b5bae6e41 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -254,6 +254,7 @@ import { buildLocalDraftThread, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, + chatAreaReservedComposerHeight, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -261,6 +262,7 @@ import { hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, + shouldShowDraftHeroHeadline, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -925,8 +927,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra return null; } + // `contents` keeps the drawer itself the flex child of the thread column, so + // it is the box that shrinks when the chat area reserves room for the composer. return ( -
+
(null); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + // Composer geometry the chat area sizes itself from. The stack, headline and + // banners are all content-sized, so none of them tracks the chat area back. + const [composerHeroExtent, setComposerHeroExtent] = useState({ + stack: 0, + headline: 0, + banners: 0, + }); + const [chatAreaElement, setChatAreaElement] = useState(null); + const [chatAreaHeight, setChatAreaHeight] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); - useLayoutEffect(() => { - if (!composerOverlayElement) return; - - const updateHeight = () => { - const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); - if (nextHeight <= 0) return; - setComposerOverlayHeight((currentHeight) => - currentHeight === nextHeight ? currentHeight : nextHeight, - ); - }; - - updateHeight(); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver(updateHeight); - observer.observe(composerOverlayElement); - return () => observer.disconnect(); - }, [composerOverlayElement]); - const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); @@ -2396,6 +2390,79 @@ function ChatViewContent(props: ChatViewProps) { attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, ] = useDraftHeroLayoutTransition(isDraftHeroState); + + useLayoutEffect(() => { + if (!composerOverlayElement) return; + + // The hero overlay spans the whole chat area and centers the composer inside + // it, so its own height says nothing about how much room the composer needs. + // Measure the composer stack, and the headline and banners hanging above it. + const stackElement = composerOverlayElement.querySelector( + "[data-chat-composer-stack]", + ); + const headlineElement = isDraftHeroState + ? composerOverlayElement.querySelector("[data-chat-composer-hero-headline]") + : null; + const bannersElement = isDraftHeroState + ? composerOverlayElement.querySelector("[data-chat-composer-hero-banners]") + : null; + + const updateHeight = () => { + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + if (nextHeight > 0) { + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + } + const nextStack = stackElement ? Math.ceil(stackElement.getBoundingClientRect().height) : 0; + const nextHeadline = headlineElement + ? Math.ceil(headlineElement.getBoundingClientRect().height) + : 0; + const nextBanners = bannersElement + ? Math.ceil(bannersElement.getBoundingClientRect().height) + : 0; + setComposerHeroExtent((current) => + current.stack === nextStack && + current.headline === nextHeadline && + current.banners === nextBanners + ? current + : { stack: nextStack, headline: nextHeadline, banners: nextBanners }, + ); + }; + + updateHeight(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(updateHeight); + observer.observe(composerOverlayElement); + if (stackElement) observer.observe(stackElement); + if (headlineElement) observer.observe(headlineElement); + if (bannersElement) observer.observe(bannersElement); + return () => observer.disconnect(); + // The overlay node is reused across threads, so the thread key is what + // re-measures on navigation. Leaving it to the observer would reserve the + // previous thread's composer for a frame, and a taller one would spill. + }, [composerOverlayElement, isDraftHeroState, activeThreadKey]); + + useLayoutEffect(() => { + if (!chatAreaElement) return; + const updateChatAreaHeight = () => { + const nextHeight = Math.floor(chatAreaElement.getBoundingClientRect().height); + setChatAreaHeight((current) => (current === nextHeight ? current : nextHeight)); + }; + updateChatAreaHeight(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(updateChatAreaHeight); + observer.observe(chatAreaElement); + return () => observer.disconnect(); + }, [chatAreaElement]); + + const showDraftHeroHeadline = shouldShowDraftHeroHeadline({ + chatAreaHeight, + composerStackHeight: composerHeroExtent.stack, + heroHeadlineHeight: composerHeroExtent.headline, + heroBannerHeight: composerHeroExtent.banners, + }); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { @@ -5784,7 +5851,21 @@ function ChatViewContent(props: ChatViewProps) { onDismiss={() => setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */} -
+ {/* The composer overlays this row, so it reserves the composer's height: + the terminal drawer below shrinks instead of pushing the composer out + over the thread header and back down across the terminal. */} +
{/* Chat column */}
{/* Provider status overlays the timeline without changing its content height. */} @@ -5865,13 +5946,22 @@ function ChatViewContent(props: ChatViewProps) { >
{isDraftHeroState ? ( -
+
+ {/* Kept in layout while hidden so its height stays + measurable — the decision to hide it depends on that + height, and unmounting it would oscillate. */}
- +
+ +
) : ( diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index e60d1d71678..be668385165 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import { + resolveDrawerResizeStartHeight, + resolveDrawerResizeStoredHeight, resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, @@ -90,3 +92,71 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); }); }); + +describe("resolveDrawerResizeStartHeight", () => { + it("starts the drag from the height the layout granted, not the stored one", () => { + expect(resolveDrawerResizeStartHeight(462.4, 700)).toBe(462); + }); + + it("falls back to the stored height when the drawer has not been measured", () => { + expect(resolveDrawerResizeStartHeight(null, 320)).toBe(320); + expect(resolveDrawerResizeStartHeight(0, 320)).toBe(320); + expect(resolveDrawerResizeStartHeight(Number.NaN, 320)).toBe(320); + }); +}); + +describe("resolveDrawerResizeStoredHeight", () => { + it("persists a drag that shrinks the drawer", () => { + expect( + resolveDrawerResizeStoredHeight({ + draggedHeight: 400, + dragStartHeight: 444, + storedHeight: 675, + }), + ).toBe(400); + }); + + it("keeps the taller stored height when a squeezed drag asks for more room", () => { + // Divider squeezed to 444 by the composer reserve; nudging it up must not + // quietly rewrite the user's 675 preference down to 454. + expect( + resolveDrawerResizeStoredHeight({ + draggedHeight: 454, + dragStartHeight: 444, + storedHeight: 675, + }), + ).toBe(675); + }); + + it("grows normally when the drawer was not squeezed", () => { + expect( + resolveDrawerResizeStoredHeight({ + draggedHeight: 600, + dragStartHeight: 500, + storedHeight: 500, + }), + ).toBe(600); + }); + + it("keeps the stored height when a drag ends back where it started", () => { + // The drag re-anchors on the squeezed height, so a drag that returns to its + // starting point still reaches this function — with no net resize. + expect( + resolveDrawerResizeStoredHeight({ + draggedHeight: 444, + dragStartHeight: 444, + storedHeight: 675, + }), + ).toBe(675); + }); + + it("takes the dragged height when it already exceeds the stored one", () => { + expect( + resolveDrawerResizeStoredHeight({ + draggedHeight: 700, + dragStartHeight: 444, + storedHeight: 675, + }), + ).toBe(700); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index dd7da738626..f1ee1915833 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -79,6 +79,45 @@ function clampDrawerHeight(height: number): number { return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } +/** + * Height a divider drag starts from. + * + * The chat area above the drawer reserves room for the composer, so flex layout + * can grant the drawer less than its stored height. Measuring the drag against + * the stored value would leave the divider trailing the pointer by the + * difference, so start from the height the layout actually granted. + */ +export function resolveDrawerResizeStartHeight( + renderedHeight: number | null, + storedHeight: number, +): number { + if (renderedHeight === null || !Number.isFinite(renderedHeight) || renderedHeight <= 0) { + return storedHeight; + } + return Math.round(renderedHeight); +} + +/** + * Height a finished drag should persist. + * + * While the chat area's composer reserve squeezes the drawer, the divider sits + * below the stored height. A drag that ends above where it started is the user + * asking for more room, so persisting the dragged value would quietly discard + * the taller height they had already chosen — and they cannot see the + * difference, because the squeeze caps what renders either way. Only a drag + * that ends strictly below its starting point is a request to shrink — ending + * back where it started is no resize at all, and must not rewrite the stored + * height down to the squeezed one. + */ +export function resolveDrawerResizeStoredHeight(input: { + draggedHeight: number; + dragStartHeight: number; + storedHeight: number; +}): number { + if (input.draggedHeight < input.dragStartHeight) return input.draggedHeight; + return Math.max(input.draggedHeight, input.storedHeight); +} + function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } @@ -913,6 +952,7 @@ export default function ThreadTerminalDrawer({ setDrawerHeight(nextHeight); }); const [resizeEpoch, setResizeEpoch] = useState(0); + const drawerElementRef = useRef(null); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); @@ -920,6 +960,7 @@ export default function ThreadTerminalDrawer({ pointerId: number; startY: number; startHeight: number; + storedHeight: number; } | null>(null); const didResizeDuringDragRef = useRef(false); @@ -1112,7 +1153,11 @@ export default function ThreadTerminalDrawer({ resizeStateRef.current = { pointerId: event.pointerId, startY: event.clientY, - startHeight: drawerHeightRef.current, + startHeight: resolveDrawerResizeStartHeight( + drawerElementRef.current?.getBoundingClientRect().height ?? null, + drawerHeightRef.current, + ), + storedHeight: drawerHeightRef.current, }; }, []); @@ -1145,10 +1190,17 @@ export default function ThreadTerminalDrawer({ if (!didResizeDuringDragRef.current) { return; } - syncHeight(drawerHeightRef.current); + const settledHeight = resolveDrawerResizeStoredHeight({ + draggedHeight: drawerHeightRef.current, + dragStartHeight: resizeState.startHeight, + storedHeight: resizeState.storedHeight, + }); + drawerHeightRef.current = settledHeight; + setDrawerHeight(settledHeight); + syncHeight(settledHeight); setResizeEpoch((value) => value + 1); }, - [syncHeight], + [setDrawerHeight, syncHeight], ); useEffect(() => { @@ -1190,12 +1242,18 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return (