From 7ab0c2a6b55e62482d1a8870cdb978d06ff55a8c Mon Sep 17 00:00:00 2001 From: DT_Lin <962018407@qq.com> Date: Sat, 1 Aug 2026 01:43:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E9=9A=8F?= =?UTF-8?q?=E5=B1=8F=E6=BB=9A=E5=8A=A8+=E4=BC=98=E5=8C=96=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=8D=A0=E4=BD=8D=E7=9B=92=E5=AD=90=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ChatWindow.tsx | 11 ++++++++--- hooks/useAgentSession.ts | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 909dd0461..f51850a28 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -224,6 +224,11 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate soundedExtensionDialogIdRef.current = extensionDialog.id; playDoneSoundRef.current(); }, [extensionDialog]); + + // Reserve enough bottom padding in the message list so the last message is + // not hidden behind the fixed ChatInput. The input area's minimum height is + // ~52px (textarea + padding + bottom controls), so we keep a static spacer. + const inputHeight = 52; // Register the abort handler for the global Esc shortcut useEffect(() => { @@ -719,9 +724,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate /> )} - {agentRunning && ( -
- )} + {/* Spacer sized to the bottom input area so the last message is + not hidden behind ChatInput, without wasting a full viewport. */} +
diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index a482f7120..26c263eec 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -156,6 +156,10 @@ export type ThinkingLevelOption = "auto" | "off" | "minimal" | "low" | "medium" const PROGRAMMATIC_SCROLL_IGNORE_MS = 700; const USER_SCROLL_INTENT_MS = 1200; +// Distance from the bottom of the scroll container within which live-follow +// scrolling is active. Larger values make follow more lenient; smaller values +// require the user to stay closer to the bottom. +const SCROLL_BOTTOM_THRESHOLD = 150; const PROMPT_SETTLE_INITIAL_DELAY_MS = 800; const PROMPT_SETTLE_POLL_MS = 600; const PROMPT_SETTLE_MAX_MS = 20_000; @@ -396,6 +400,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const lastUserMsgRef = useRef(null); const pendingScrollToUserRef = useRef(false); const completionScrollAllowedRef = useRef(true); + const isNearBottomRef = useRef(true); const executeBashRef = useRef<(command: string, excludeFromContext: boolean) => Promise | undefined>(undefined); const userScrollIntentUntilRef = useRef(0); const ignoreProgrammaticScrollUntilRef = useRef(0); @@ -1129,6 +1134,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { dispatch({ type: "update", message: normalizeToolCalls(msg as AgentMessage) }); } setAgentPhase(null); + // Live-follow the streaming output only when the user is already near + // the bottom of the message list. If they scrolled up, leave them there. + if (isNearBottomRef.current) { + // Defer the scroll so React has time to update the DOM with the new + // streaming content; otherwise scrollIntoView may target stale layout. + requestAnimationFrame(() => scrollToBottom("auto")); + } break; } case "message_end": { @@ -1703,6 +1715,11 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }, []); const handleScrollPositionChange = useCallback(() => { + const container = scrollContainerRef.current; + if (container) { + const { scrollTop, clientHeight, scrollHeight } = container; + isNearBottomRef.current = scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD; + } if (!agentRunningRef.current) return; if (Date.now() < ignoreProgrammaticScrollUntilRef.current) return; if (Date.now() > userScrollIntentUntilRef.current) return; @@ -1793,7 +1810,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } else if (!initialScrollDoneRef.current) { initialScrollDoneRef.current = true; scrollToBottom("instant"); - } else if (!agentRunningRef.current && completionScrollAllowedRef.current) { + } else if (!agentRunningRef.current && (completionScrollAllowedRef.current || isNearBottomRef.current)) { scrollToBottom("smooth"); } } From 49b6506ff22ff2f4e575e8f67ba82aca39801c6c Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 6 Aug 2026 22:01:10 +0800 Subject: [PATCH 2/4] fix: preserve chat tail scrolling --- components/ChatWindow.tsx | 66 ++++++++++++++++++++++++++++------ hooks/useAgentSession.test.mjs | 27 ++++++++++++++ hooks/useAgentSession.ts | 29 ++++++++++----- 3 files changed, 103 insertions(+), 19 deletions(-) diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index f51850a28..0a25d1a0f 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -1,6 +1,6 @@ "use client"; import { registerAbortHandler } from "@/hooks/useKeyboardShortcuts"; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } from "@/lib/types"; import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi"; import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; @@ -212,7 +212,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, - handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, + handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollToBottom, } = useAgentSession({ session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen, @@ -224,11 +224,6 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate soundedExtensionDialogIdRef.current = extensionDialog.id; playDoneSoundRef.current(); }, [extensionDialog]); - - // Reserve enough bottom padding in the message list so the last message is - // not hidden behind the fixed ChatInput. The input area's minimum height is - // ~52px (textarea + padding + bottom controls), so we keep a static spacer. - const inputHeight = 52; // Register the abort handler for the global Esc shortcut useEffect(() => { @@ -336,6 +331,56 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate const isEmptyNew = isNew && messages.length === 0 && !streamState.isStreaming && !sessionBusy; const messageCwd = session?.cwd ?? newSessionCwd ?? undefined; + const bottomComposerRef = useRef(null); + const [bottomComposerHeight, setBottomComposerHeight] = useState(0); + const bottomComposerHeightRef = useRef(0); + const bottomComposerScrollFrameRef = useRef(null); + + useLayoutEffect(() => { + const composer = bottomComposerRef.current; + if (!composer) { + bottomComposerHeightRef.current = 0; + setBottomComposerHeight(0); + return; + } + + const updateBottomComposerHeight = () => { + const nextHeight = Math.ceil(composer.getBoundingClientRect().height); + if (bottomComposerHeightRef.current === nextHeight) return; + + const previousHeight = bottomComposerHeightRef.current; + bottomComposerHeightRef.current = nextHeight; + setBottomComposerHeight(nextHeight); + + if (bottomComposerScrollFrameRef.current !== null) { + cancelAnimationFrame(bottomComposerScrollFrameRef.current); + } + bottomComposerScrollFrameRef.current = requestAnimationFrame(() => { + bottomComposerScrollFrameRef.current = null; + const currentContainer = scrollContainerRef.current; + const distanceFromBottom = currentContainer + ? currentContainer.scrollHeight - currentContainer.clientHeight - currentContainer.scrollTop + : Number.POSITIVE_INFINITY; + // Preserve a tail-pinned view while avoiding a jump for history readers. + if (distanceFromBottom <= Math.abs(nextHeight - previousHeight) + 1) { + scrollToBottom("auto"); + } + }); + }; + updateBottomComposerHeight(); + + const observer = typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(updateBottomComposerHeight); + observer?.observe(composer); + return () => { + observer?.disconnect(); + if (bottomComposerScrollFrameRef.current !== null) { + cancelAnimationFrame(bottomComposerScrollFrameRef.current); + bottomComposerScrollFrameRef.current = null; + } + }; + }, [error, isEmptyNew, loading, scrollContainerRef, scrollToBottom]); const availableThinkingLevels = displayModelValue ? (modelThinkingLevels[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) @@ -724,9 +769,8 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate /> )} - {/* Spacer sized to the bottom input area so the last message is - not hidden behind ChatInput, without wasting a full viewport. */} -
+ {/* Match the trailing space to the live bottom composer height. */} + -
+
{ assert.match(chatWindowSource, /soundedExtensionDialogIdRef\.current = extensionDialog\.id/); assert.match(chatWindowSource, /playDoneSoundRef\.current\(\)/); }); + +test("keeps live following cancellable when the user scrolls away from the tail", () => { + const streamUpdateSource = source.slice( + source.indexOf('case "message_start"'), + source.indexOf('case "message_end"'), + ); + const scrollHandlerSource = source.slice( + source.indexOf("const handleScrollPositionChange"), + source.indexOf("// Load session on mount"), + ); + + assert.match(source, /const liveFollowFrameRef = useRef\(null\)/); + assert.match(streamUpdateSource, /liveFollowFrameRef\.current === null/); + assert.match(streamUpdateSource, /requestAnimationFrame\(\(\) => \{[\s\S]*?liveFollowFrameRef\.current = null;[\s\S]*?if \(isNearBottomRef\.current\) scrollToBottom\("auto"\)/); + assert.match(scrollHandlerSource, /cancelAnimationFrame\(liveFollowFrameRef\.current\)/); +}); + +test("sizes the message tail from the rendered bottom composer", () => { + assert.match(chatWindowSource, /const bottomComposerRef = useRef\(null\)/); + assert.match(chatWindowSource, /useLayoutEffect\(\(\) => \{/); + assert.match(chatWindowSource, /new ResizeObserver\(updateBottomComposerHeight\)/); + assert.match(chatWindowSource, /bottomComposerScrollFrameRef = useRef\(null\)/); + assert.match(chatWindowSource, /distanceFromBottom <= Math\.abs\(nextHeight - previousHeight\) \+ 1/); + assert.match(chatWindowSource, /scrollToBottom\("auto"\)/); + assert.match(chatWindowSource, /
/); + assert.match(chatWindowSource, /height: bottomComposerHeight/); +}); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 26c263eec..da7a99726 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -401,6 +401,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const pendingScrollToUserRef = useRef(false); const completionScrollAllowedRef = useRef(true); const isNearBottomRef = useRef(true); + const liveFollowFrameRef = useRef(null); const executeBashRef = useRef<(command: string, excludeFromContext: boolean) => Promise | undefined>(undefined); const userScrollIntentUntilRef = useRef(0); const ignoreProgrammaticScrollUntilRef = useRef(0); @@ -415,6 +416,11 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const setToolPresetState = opts.setToolPreset ?? setToolPreset; + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; + messagesEndRef.current?.scrollIntoView({ behavior }); + }, []); + const currentModel = currentModelOverride ?? data?.context.model ?? pendingModel ?? null; const displayModel = isNew ? (newSessionModel ?? newSessionDefaultModel) : currentModel; @@ -1136,10 +1142,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setAgentPhase(null); // Live-follow the streaming output only when the user is already near // the bottom of the message list. If they scrolled up, leave them there. - if (isNearBottomRef.current) { + if (isNearBottomRef.current && liveFollowFrameRef.current === null) { // Defer the scroll so React has time to update the DOM with the new // streaming content; otherwise scrollIntoView may target stale layout. - requestAnimationFrame(() => scrollToBottom("auto")); + liveFollowFrameRef.current = requestAnimationFrame(() => { + liveFollowFrameRef.current = null; + if (isNearBottomRef.current) scrollToBottom("auto"); + }); } break; } @@ -1227,7 +1236,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleExtensionUiRequest(event as ExtensionUiRequest); break; } - }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, scheduleEventStreamClose, settleUiStage]); + }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, scheduleEventStreamClose, scrollToBottom, settleUiStage]); handleAgentEventRef.current = handleAgentEvent; const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => { @@ -1692,11 +1701,6 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } }, [setToolPresetState]); - const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { - ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; - messagesEndRef.current?.scrollIntoView({ behavior }); - }, []); - const scrollUserMsgToTop = useCallback(() => { const container = scrollContainerRef.current; const el = lastUserMsgRef.current; @@ -1719,6 +1723,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (container) { const { scrollTop, clientHeight, scrollHeight } = container; isNearBottomRef.current = scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD; + if (!isNearBottomRef.current && liveFollowFrameRef.current !== null) { + cancelAnimationFrame(liveFollowFrameRef.current); + liveFollowFrameRef.current = null; + } } if (!agentRunningRef.current) return; if (Date.now() < ignoreProgrammaticScrollUntilRef.current) return; @@ -1763,6 +1771,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }); } return () => { + if (liveFollowFrameRef.current !== null) { + cancelAnimationFrame(liveFollowFrameRef.current); + liveFollowFrameRef.current = null; + } bashRecoveryIdRef.current += 1; cancelEventStreamGrace(); closeEvents(); @@ -1872,6 +1884,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleRecallQueue, handleBuiltinSlashCommand, handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, + scrollToBottom, dispatch, setAgentRunning, setForkingEntryId, bashRunning, pendingBash, // Subscriptions From e5a3d621287b4f513ac05123a1514268ef3d88a1 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 6 Aug 2026 22:14:27 +0800 Subject: [PATCH 3/4] fix: preserve prompt top scroll --- hooks/useAgentSession.test.mjs | 22 ++++++++++++++++++++++ hooks/useAgentSession.ts | 18 ++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/hooks/useAgentSession.test.mjs b/hooks/useAgentSession.test.mjs index f06a03efb..8d9147827 100644 --- a/hooks/useAgentSession.test.mjs +++ b/hooks/useAgentSession.test.mjs @@ -90,6 +90,28 @@ test("keeps live following cancellable when the user scrolls away from the tail" assert.match(scrollHandlerSource, /cancelAnimationFrame\(liveFollowFrameRef\.current\)/); }); +test("keeps a newly sent user message at the top while its response starts", () => { + const streamUpdateSource = source.slice( + source.indexOf('case "message_start"'), + source.indexOf('case "message_end"'), + ); + const userScrollSource = source.slice( + source.indexOf("const scrollUserMsgToTop"), + source.indexOf("const markUserScrollIntent"), + ); + const scrollEffectSource = source.slice( + source.indexOf("useLayoutEffect(() => {\n if (messages.length > 0)"), + source.indexOf("// Load model list"), + ); + + assert.match(streamUpdateSource, /!pendingScrollToUserRef\.current && isNearBottomRef\.current/); + assert.match(userScrollSource, /const targetTop = Math\.min\(Math\.max\(0, elAbsTop - 16\), maxScrollTop\)/); + assert.match(userScrollSource, /cancelAnimationFrame\(liveFollowFrameRef\.current\)/); + assert.match(userScrollSource, /isNearBottomRef\.current = targetTop >= maxScrollTop - SCROLL_BOTTOM_THRESHOLD/); + assert.match(userScrollSource, /container\.scrollTo\(\{ top: targetTop, behavior: "smooth" \}\)/); + assert.match(scrollEffectSource, /pendingScrollToUserRef\.current = false;[\s\S]*?scrollUserMsgToTop\(\)/); +}); + test("sizes the message tail from the rendered bottom composer", () => { assert.match(chatWindowSource, /const bottomComposerRef = useRef\(null\)/); assert.match(chatWindowSource, /useLayoutEffect\(\(\) => \{/); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index da7a99726..8964a7da8 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useRef, useEffect, useMemo, useReducer } from "react"; +import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo, useReducer } from "react"; import type { AgentMessage, ExtensionStatusItem, @@ -1142,7 +1142,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setAgentPhase(null); // Live-follow the streaming output only when the user is already near // the bottom of the message list. If they scrolled up, leave them there. - if (isNearBottomRef.current && liveFollowFrameRef.current === null) { + if (!pendingScrollToUserRef.current && isNearBottomRef.current && liveFollowFrameRef.current === null) { // Defer the scroll so React has time to update the DOM with the new // streaming content; otherwise scrollIntoView may target stale layout. liveFollowFrameRef.current = requestAnimationFrame(() => { @@ -1706,8 +1706,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const el = lastUserMsgRef.current; if (!container || !el) return; const elAbsTop = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop; + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + const targetTop = Math.min(Math.max(0, elAbsTop - 16), maxScrollTop); + + if (liveFollowFrameRef.current !== null) { + cancelAnimationFrame(liveFollowFrameRef.current); + liveFollowFrameRef.current = null; + } + // A smooth scroll reports its position after the first streaming event can + // arrive, so update the tail state before the browser emits that event. + isNearBottomRef.current = targetTop >= maxScrollTop - SCROLL_BOTTOM_THRESHOLD; ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; - container.scrollTo({ top: elAbsTop - 16, behavior: "smooth" }); + container.scrollTo({ top: targetTop, behavior: "smooth" }); }, []); const markUserScrollIntent = useCallback((event: Event) => { @@ -1813,7 +1823,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }; }, [messages.length, loading, handleScrollPositionChange, markUserScrollIntent]); - useEffect(() => { + useLayoutEffect(() => { if (messages.length > 0) { if (pendingScrollToUserRef.current) { pendingScrollToUserRef.current = false; From 2a515d12f31e85a494dab453ea0f91605f5744ac Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 6 Aug 2026 22:32:58 +0800 Subject: [PATCH 4/4] fix: reserve prompt scroll anchor --- components/ChatWindow.tsx | 75 +++++++++++++++++++++++++++++++++- hooks/useAgentSession.test.mjs | 5 +++ hooks/useAgentSession.ts | 9 +++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 0a25d1a0f..92ac4c7fa 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -207,12 +207,12 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate agentPhase, isNew, sessionIdRef, messagesEndRef, scrollContainerRef, - lastUserMsgRef, + lastUserMsgRef, promptAnchorActive, handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, - handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollToBottom, + handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollToBottom, scrollUserMsgToTop, } = useAgentSession({ session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen, @@ -335,6 +335,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate const [bottomComposerHeight, setBottomComposerHeight] = useState(0); const bottomComposerHeightRef = useRef(0); const bottomComposerScrollFrameRef = useRef(null); + const [promptAnchorSpacerHeight, setPromptAnchorSpacerHeight] = useState(0); + const promptAnchorSpacerHeightRef = useRef(0); + const promptAnchorScrollPendingRef = useRef(false); useLayoutEffect(() => { const composer = bottomComposerRef.current; @@ -382,6 +385,70 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate }; }, [error, isEmptyNew, loading, scrollContainerRef, scrollToBottom]); + useLayoutEffect(() => { + if (!agentRunning || !promptAnchorActive) { + promptAnchorScrollPendingRef.current = false; + if (promptAnchorSpacerHeightRef.current !== 0) { + promptAnchorSpacerHeightRef.current = 0; + setPromptAnchorSpacerHeight(0); + } + return; + } + + const container = scrollContainerRef.current; + const userMessage = lastUserMsgRef.current; + if (!container || !userMessage) return; + + const updatePromptAnchorSpacer = () => { + const userMessageTop = userMessage.getBoundingClientRect().top + - container.getBoundingClientRect().top + + container.scrollTop; + const targetTop = Math.max(0, userMessageTop - 16); + // Exclude the current spacer so each measurement converges instead of + // alternating between adding it and removing it. + const maxScrollTopWithoutAnchor = Math.max( + 0, + container.scrollHeight - promptAnchorSpacerHeightRef.current - container.clientHeight, + ); + const nextPromptAnchorSpacerHeight = Math.max( + 0, + Math.ceil(targetTop - maxScrollTopWithoutAnchor), + ); + + if (nextPromptAnchorSpacerHeight !== promptAnchorSpacerHeightRef.current) { + const needsInitialScroll = promptAnchorSpacerHeightRef.current === 0 + && nextPromptAnchorSpacerHeight > 0; + promptAnchorSpacerHeightRef.current = nextPromptAnchorSpacerHeight; + promptAnchorScrollPendingRef.current ||= needsInitialScroll; + setPromptAnchorSpacerHeight(nextPromptAnchorSpacerHeight); + return; + } + + if (promptAnchorScrollPendingRef.current) { + promptAnchorScrollPendingRef.current = false; + scrollUserMsgToTop(); + } + }; + + updatePromptAnchorSpacer(); + const observer = typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(updatePromptAnchorSpacer); + observer?.observe(container); + observer?.observe(userMessage); + return () => observer?.disconnect(); + }, [ + agentRunning, + bottomComposerHeight, + lastUserMsgRef, + messages.length, + promptAnchorActive, + promptAnchorSpacerHeight, + scrollContainerRef, + scrollUserMsgToTop, + streamState.streamingMessage, + ]); + const availableThinkingLevels = displayModelValue ? (modelThinkingLevels[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) : null; @@ -769,6 +836,10 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate /> )} + {promptAnchorSpacerHeight > 0 && ( +