Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
buildExpiredTerminalContextToastCopy,
buildLoadingThreadFromShell,
buildThreadTurnInterruptInput,
chatAreaReservedComposerHeight,
createLocalDispatchSnapshot,
deriveComposerSendState,
dismissBranchMismatchForSession,
Expand All @@ -28,6 +29,7 @@ import {
resolveSendEnvMode,
startNewThreadForProject,
shouldShowBranchMismatchBanner,
shouldShowDraftHeroHeadline,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";

Expand Down Expand Up @@ -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);
});
});
57 changes: 57 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 116 additions & 24 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,15 @@ import {
buildLocalDraftThread,
buildLoadingThreadFromShell,
buildThreadTurnInterruptInput,
chatAreaReservedComposerHeight,
collectUserMessageBlobPreviewUrls,
createLocalDispatchSnapshot,
deriveComposerSendState,
dismissBranchMismatchForSession,
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
shouldShowBranchMismatchBanner,
shouldShowDraftHeroHeadline,
getStartedThreadModelChangeBlockReason,
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
LastInvokedScriptByProjectSchema,
Expand Down Expand Up @@ -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 (
<div className={visible ? undefined : "hidden"}>
<div className={visible ? "contents" : "hidden"}>
<ThreadTerminalDrawer
threadRef={threadRef}
threadId={threadId}
Expand Down Expand Up @@ -1331,31 +1335,21 @@ function ChatViewContent(props: ChatViewProps) {
const legendListRef = useRef<LegendListRef | null>(null);
const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
const [chatAreaHeight, setChatAreaHeight] = useState(0);
const isAtEndRef = useRef(true);
const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
const sendInFlightRef = useRef(false);
const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});

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),
);
Expand Down Expand Up @@ -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<HTMLElement>(
"[data-chat-composer-stack]",
);
const headlineElement = isDraftHeroState
? composerOverlayElement.querySelector<HTMLElement>("[data-chat-composer-hero-headline]")
: null;
const bannersElement = isDraftHeroState
? composerOverlayElement.querySelector<HTMLElement>("[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(() => {
Expand Down Expand Up @@ -5784,7 +5851,21 @@ function ChatViewContent(props: ChatViewProps) {
onDismiss={() => setThreadError(activeThread.id, null)}
/>
{/* Main content area with optional plan sidebar */}
<div className="flex min-h-0 min-w-0 flex-1">
{/* 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. */}
<div
ref={setChatAreaElement}
className="flex min-h-0 min-w-0 flex-1"
style={{
minHeight: chatAreaReservedComposerHeight({
isDraftHeroState,
composerOverlayHeight,
composerStackHeight: composerHeroExtent.stack,
heroBannerHeight: composerHeroExtent.banners,
}),
Comment thread
cursor[bot] marked this conversation as resolved.
}}
>
{/* Chat column */}
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
{/* Provider status overlays the timeline without changing its content height. */}
Expand Down Expand Up @@ -5865,13 +5946,22 @@ function ChatViewContent(props: ChatViewProps) {
>
<div
ref={attachDraftHeroTransitionGroupRef}
data-chat-composer-stack="true"
className="chat-composer-horizontal-inset w-full"
>
<div className="pointer-events-auto relative z-10">
{isDraftHeroState ? (
<div className="absolute inset-x-0 bottom-full z-0">
<div
data-chat-composer-hero-lede="true"
className="absolute inset-x-0 bottom-full z-0"
>
{/* Kept in layout while hidden so its height stays
measurable — the decision to hide it depends on that
height, and unmounting it would oscillate. */}
<div
className="pb-8"
data-chat-composer-hero-headline="true"
aria-hidden={showDraftHeroHeadline ? undefined : true}
className={cn("pb-8", !showDraftHeroHeadline && "invisible")}
style={
forceExpandedMobileComposer
? {
Expand All @@ -5885,7 +5975,9 @@ function ChatViewContent(props: ChatViewProps) {
activeProjectTitle={activeProject?.title ?? null}
/>
</div>
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
<div data-chat-composer-hero-banners="true">
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
</div>
</div>
) : (
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
Expand Down
Loading
Loading