diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4d4499eb6..8e9c4df74 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2662,6 +2662,100 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("[geometry:linux] keeps the transcript-to-composer gap constant when a task card joins the composer stack", async () => { + // Regression guard for the oversized gap between the transcript tail and the + // composer when a stacked panel (active task list) is showing. The composer + // is a flex sibling below the transcript, so the flex layout already reserves + // the stack height once; the in-list bottom spacer must stay at its constant + // baseline (MessagesTimeline BOTTOM_CONTENT_INSET_PX) instead of also growing + // with the chrome. If it double-counts, the gap above the composer stack + // grows by ~the chrome height when the task card appears. + // + // We anchor on the bottom spacer's top (the true end of transcript content, + // after any live-turn activity indicator) rather than the last message row, + // so a running turn's working indicator does not confound the measurement. + // Mirrors MessagesTimeline BOTTOM_CONTENT_INSET_PX (the fixed tail spacer). + const TRANSCRIPT_BOTTOM_SPACER_PX = 64; + const measureComposerGap = async ( + mounted: Awaited>, + { expectTaskCard }: { expectTaskCard: boolean }, + ): Promise => { + const scrollContainer = await waitForElement( + () => document.querySelector("[data-chat-scroll-container='true']"), + "Unable to find message scroll container.", + ); + // The fixture overflows the viewport, so scrolling to the end pins the tail + // spacer against the viewport bottom where the gap is well defined. + const layout = await mounted.measureLayout(); + expect(layout.scrollHeightPx).toBeGreaterThan(layout.scrollClientHeightPx); + + let gapPx = 0; + await vi.waitFor( + async () => { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + expect(getScrollContainerDistanceFromBottom(scrollContainer)).toBeLessThanOrEqual( + AUTO_SCROLL_BOTTOM_THRESHOLD_PX, + ); + + const taskCard = document.querySelector( + '[data-testid="active-task-list-card"]', + ); + if (expectTaskCard) { + expect(taskCard, "Expected the active task list card to be present.").not.toBeNull(); + } else { + expect(taskCard, "Expected no active task list card.").toBeNull(); + } + + const composerStack = document.querySelector( + '[data-chat-composer-stack="true"]', + ); + expect(composerStack, "Unable to find composer stack wrapper.").not.toBeNull(); + const bottomSpacer = document.querySelector( + '[data-testid="transcript-bottom-spacer"]', + ); + expect(bottomSpacer, "Unable to find transcript bottom spacer.").not.toBeNull(); + + const transcriptContentBottom = bottomSpacer!.getBoundingClientRect().top; + const composerStackTop = composerStack!.getBoundingClientRect().top; + gapPx = composerStackTop - transcriptContentBottom; + // The composer stack sits just below the transcript tail, never a + // screenful away. + expect(gapPx).toBeGreaterThan(0); + expect(gapPx).toBeLessThan(TRANSCRIPT_BOTTOM_SPACER_PX); + }, + { timeout: 8_000, interval: 16 }, + ); + return gapPx; + }; + + const active = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithActiveInlinePlan(), + }); + let gapWithTaskCard = 0; + try { + gapWithTaskCard = await measureComposerGap(active, { expectTaskCard: true }); + } finally { + await active.cleanup(); + } + + const settled = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSettledInlinePlan(), + }); + let gapWithoutTaskCard = 0; + try { + gapWithoutTaskCard = await measureComposerGap(settled, { expectTaskCard: false }); + } finally { + await settled.cleanup(); + } + + // The stacked task card must not widen the transcript-to-composer gap. + expect(Math.abs(gapWithTaskCard - gapWithoutTaskCard)).toBeLessThanOrEqual(3); + }); + it("stays pinned to the bottom after delayed attachment loads expand the timeline", async () => { attachmentResponseDelayMs = 160; const mounted = await mountChatView({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 05d2cb072..dd4dd2d55 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2808,9 +2808,10 @@ export default function ChatView({ const planSidebarToggleLabel = planSidebarOpen ? `Hide ${planSidebarLabel}` : planSidebarLabel; const planSidebarToggleTitle = `${planSidebarOpen ? "Hide" : "Show"} ${planSidebarLabel.toLowerCase()} sidebar`; // Measured height of the whole stack of panels rendered above the composer input - // (live file changes, active task list, queued follow-ups). The composer overlaps the - // scrolling transcript, so the transcript reserves matching bottom space to keep its - // last rows clear of this chrome instead of letting them slide underneath and clip. + // (live file changes, active task list, queued follow-ups). The composer is a flex + // sibling below the transcript, so a taller stack shrinks the transcript viewport from + // the bottom; this height feeds the scroll compensation below (not any bottom inset) + // that keeps the transcript pinned to its end while the chrome grows. const [composerStackedChromeHeight, setComposerStackedChromeHeight] = useState(0); const composerStackedChromeObserverRef = useRef(null); const previousComposerStackedChromeHeightRef = useRef(0); @@ -5118,6 +5119,10 @@ export default function ChatView({ autoFollowThreadIdRef.current = null; animateNextAutoFollowScrollRef.current = false; }, []); + // Keep the transcript pinned to its end while the stacked composer chrome grows. The + // composer is a flex sibling, so a taller chrome shrinks the transcript viewport from + // the bottom and would otherwise let the last rows drift up out of view. When already + // at the end, nudge scrollTop by the growth so the end stays put. useLayoutEffect(() => { const previousHeight = previousComposerStackedChromeHeightRef.current; previousComposerStackedChromeHeightRef.current = composerStackedChromeHeight; @@ -10709,9 +10714,9 @@ export default function ChatView({ > {/* Single measured wrapper around every panel stacked above the composer input. - Its height drives the transcript bottom inset and scroll compensation so the - last rows stay clear of this chrome (see measureComposerStackedChrome). A bare - div keeps the panels' -mb-px seam onto the input shell via margin collapse. */} + Its height drives the transcript scroll compensation so the last rows stay + pinned as this chrome grows (see measureComposerStackedChrome). A bare div + keeps the panels' -mb-px seam onto the input shell via margin collapse. */}
{showComposerLiveChangesHeader ? ( setOpenAgentActivityId(null)} scrollButtonVisible={showScrollToBottom} onScrollToBottom={onScrollToBottom} - bottomContentInsetPx={ - composerStackedChromeHeight > 0 ? composerStackedChromeHeight + 8 : undefined - } contentInsetRightPx={ environmentAppliesContentInset ? ENVIRONMENT_DOCKED_CONTENT_INSET_PX @@ -11537,6 +11539,7 @@ export default function ChatView({
( () => ({ ...(contentInsetRightPx ? { paddingRight: contentInsetRightPx } : {}), - paddingBottom: Math.max(bottomContentInsetPx ?? 0, MIN_DETAIL_BOTTOM_INSET_PX), + paddingBottom: DETAIL_BOTTOM_INSET_PX, }), - [bottomContentInsetPx, contentInsetRightPx], + [contentInsetRightPx], ); const prompt = findPrompt(detail.entries); const result = findResult(detail.entries); diff --git a/apps/web/src/components/chat/ChatTranscriptPane.tsx b/apps/web/src/components/chat/ChatTranscriptPane.tsx index 6974090c4..e70336202 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -40,7 +40,6 @@ interface ChatTranscriptPaneProps { activeTurnInProgress: boolean; activeTurnStartedAt: string | null; agentActivityDetail?: AgentActivityDetail | null; - bottomContentInsetPx?: ComponentProps["bottomContentInsetPx"]; contentInsetRightPx?: ComponentProps["contentInsetRightPx"]; chatFontSizePx: number; emptyStateContent?: ReactNode; @@ -101,7 +100,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ activeTurnInProgress, activeTurnStartedAt, agentActivityDetail, - bottomContentInsetPx, contentInsetRightPx, chatFontSizePx, emptyStateContent, @@ -193,7 +191,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ {agentActivityDetail && onCloseAgentActivityDetail ? (