From e4d52902dcc58c49c59b3af878b0e61743e0ac1c Mon Sep 17 00:00:00 2001 From: Carlos Rico-Ospina Date: Sat, 1 Aug 2026 17:43:32 -0400 Subject: [PATCH 1/3] fix(web): stop a tall terminal drawer pushing the composer out of the chat area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer is an overlay, and the chat area it overlays could shrink to nothing (`min-h-0`) while the terminal drawer held an explicit height of up to 75% of the window. Once a tall drawer left the chat area shorter than the composer, nothing clipped the overlay — it spilled out of the area instead. Docked, it spilled upward over the thread header and off the top of the viewport. On a new thread it is worse: the hero composer is centred in the chat area, and a centred flex item that outgrows its container overflows *both* ends, so it covered the header and ran back down across the top of the terminal, with the headline pushed off-screen above. Its glass surface let the header and the terminal read through it either way. The chat area now reserves the height the composer actually needs, so flex layout shrinks the drawer rather than the composer's room. Docked, that is the overlay's own height. In the hero state the overlay spans the whole area, so the reserve is the composer stack, plus twice any banner — centring mirrors a banner's space below the composer, and banners carry actions. The hero headline is deliberately not reserved. It is decorative, and paying for it costs the drawer 138px it should keep; it hides instead once the area cannot hold it, and comes back when the drawer shrinks. Hidden means `visibility: hidden`, since the decision reads its measured height and unmounting it would oscillate. The drawer now keeps 644 of its 675px cap at a 900px window instead of 506, and is untouched above ~1024px. The drawer became shrinkable to allow the reserve (its wrapper is `display: contents` so the drawer itself is the flex child that yields) and keeps a floor at MIN_DRAWER_HEIGHT so the resize handle stays reachable. Drags now start from the height layout actually granted, so the divider keeps tracking the pointer while the drawer is being squeezed. Co-Authored-By: Claude Opus 5 --- .../web/src/components/ChatView.logic.test.ts | 105 ++++++++++++++ apps/web/src/components/ChatView.logic.ts | 57 ++++++++ apps/web/src/components/ChatView.tsx | 137 +++++++++++++++--- .../components/ThreadTerminalDrawer.test.ts | 13 ++ .../src/components/ThreadTerminalDrawer.tsx | 44 +++++- 5 files changed, 327 insertions(+), 29 deletions(-) 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..ceacfde8705 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,76 @@ 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(); + }, [composerOverlayElement, isDraftHeroState]); + + 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 +5848,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 +5943,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..c86383fc0da 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + resolveDrawerResizeStartHeight, resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, @@ -90,3 +91,15 @@ 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); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index dd7da738626..6b38878a546 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -79,6 +79,24 @@ 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); +} + function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } @@ -913,6 +931,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); @@ -1112,7 +1131,10 @@ export default function ThreadTerminalDrawer({ resizeStateRef.current = { pointerId: event.pointerId, startY: event.clientY, - startHeight: drawerHeightRef.current, + startHeight: resolveDrawerResizeStartHeight( + drawerElementRef.current?.getBoundingClientRect().height ?? null, + drawerHeightRef.current, + ), }; }, []); @@ -1190,12 +1212,18 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return (