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 (