From 61491076bb14fe3961815fde80aaa31cae1a66d6 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 19:18:18 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Fix=20oversized=20transcript=E2=80=93compos?= =?UTF-8?q?er=20gap=20when=20a=20task=20panel=20is=20showing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer is a flex sibling below the transcript, so the stacked composer chrome (live-changes header, active task list, queued follow-ups) already reserves its own vertical space through the flex layout. The transcript then fed that same chrome height into its in-list bottom spacer (bottomContentInsetPx = composerStackedChromeHeight + 8), reserving the space a second time. The result was a large, growing gap between the last message and the composer whenever a task/stacked panel was present, while the no-task case (spacer at its 64px baseline) already looked correct. Remove the double reservation: the transcript's bottom spacer is now a fixed 64px in every state, matching the perfect no-task gap. This also retires the now-vestigial bottomContentInsetPx prop chain (ChatView → ChatTranscriptPane → MessagesTimeline / AgentActivityDetailView), collapsing both consumers to their constant baseline and correcting the misleading "composer overlaps / reserves matching bottom space" comments. composerStackedChromeHeight is kept for its real remaining job: the scroll-compensation layout effect that keeps the transcript pinned to its end as the flex viewport shrinks while the chrome grows. - Task-present gap now equals the no-task gap (single flex reservation). - Agent activity detail view uses the same fixed baseline (it was also double-counting). - No behavior change to the no-task case. Verified: oxfmt, oxlint (0 errors), tsc --noEmit (web), and the affected component/scroll unit tests (106 passing). Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 20 ++++++++++--------- .../chat/AgentActivityDetailView.tsx | 10 +++++----- .../components/chat/ChatTranscriptPane.tsx | 4 ---- .../src/components/chat/MessagesTimeline.tsx | 17 ++++++---------- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 05d2cb072..7c694ca0f 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 diff --git a/apps/web/src/components/chat/AgentActivityDetailView.tsx b/apps/web/src/components/chat/AgentActivityDetailView.tsx index 9fac34625..fadec423e 100644 --- a/apps/web/src/components/chat/AgentActivityDetailView.tsx +++ b/apps/web/src/components/chat/AgentActivityDetailView.tsx @@ -31,11 +31,12 @@ import { isReasoningUpdateWorkEntry, } from "./agentActivity.logic"; -const MIN_DETAIL_BOTTOM_INSET_PX = 64; +// The composer overlaps this detail view by design (same -mt-5 as the transcript), so a +// fixed tail inset keeps the last content clear of it. +const DETAIL_BOTTOM_INSET_PX = 64; interface AgentActivityDetailViewProps { detail: AgentActivityDetail; - bottomContentInsetPx?: number | undefined; chatFontSizePx: number; contentInsetRightPx?: number | undefined; markdownCwd: string | undefined; @@ -47,7 +48,6 @@ interface AgentActivityDetailViewProps { export const AgentActivityDetailView = memo(function AgentActivityDetailView({ detail, - bottomContentInsetPx, chatFontSizePx, contentInsetRightPx, markdownCwd, @@ -67,9 +67,9 @@ export const AgentActivityDetailView = memo(function AgentActivityDetailView({ const scrollStyle = useMemo( () => ({ ...(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 ? (