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. */}
+
@@ -743,7 +787,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
)}
-
+
{
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 && (
+
+ )}
+
{/* Match the trailing space to the live bottom composer height. */}
diff --git a/hooks/useAgentSession.test.mjs b/hooks/useAgentSession.test.mjs
index 8d9147827..9a0586a85 100644
--- a/hooks/useAgentSession.test.mjs
+++ b/hooks/useAgentSession.test.mjs
@@ -105,11 +105,16 @@ test("keeps a newly sent user message at the top while its response starts", ()
);
assert.match(streamUpdateSource, /!pendingScrollToUserRef\.current && isNearBottomRef\.current/);
+ assert.match(source, /const \[promptAnchorActive, setPromptAnchorActive\] = useState\(false\)/);
+ assert.match(source, /pendingScrollToUserRef\.current = true;\s*setPromptAnchorActive\(true\)/);
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\(\)/);
+ assert.match(chatWindowSource, /const maxScrollTopWithoutAnchor = Math\.max\([\s\S]*?container\.scrollHeight - promptAnchorSpacerHeightRef\.current - container\.clientHeight/);
+ assert.match(chatWindowSource, /const nextPromptAnchorSpacerHeight = Math\.max\([\s\S]*?Math\.ceil\(targetTop - maxScrollTopWithoutAnchor\)/);
+ assert.match(chatWindowSource, //);
});
test("sizes the message tail from the rendered bottom composer", () => {
diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts
index 8964a7da8..43f5f60d2 100644
--- a/hooks/useAgentSession.ts
+++ b/hooks/useAgentSession.ts
@@ -372,6 +372,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const [compactError, setCompactError] = useState(null);
const [compactResult, setCompactResult] = useState(null);
const [agentPhase, setAgentPhase] = useState(null);
+ const [promptAnchorActive, setPromptAnchorActive] = useState(false);
const [slashCommands, setSlashCommands] = useState([]);
const [slashCommandsLoading, setSlashCommandsLoading] = useState(false);
const [noticeState, dispatchNotice] = useReducer(noticeReducer, { visible: [], pending: [] });
@@ -1274,6 +1275,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
setAgentPhase(isSlashCommandPrompt ? { kind: "running_command" } : { kind: "waiting_model" });
dispatch({ type: "start" });
pendingScrollToUserRef.current = true;
+ setPromptAnchorActive(true);
completionScrollAllowedRef.current = true;
const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
@@ -1823,6 +1825,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
};
}, [messages.length, loading, handleScrollPositionChange, markUserScrollIntent]);
+ useEffect(() => {
+ if (!agentRunning) setPromptAnchorActive(false);
+ }, [agentRunning]);
+
useLayoutEffect(() => {
if (messages.length > 0) {
if (pendingScrollToUserRef.current) {
@@ -1885,6 +1891,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
isAutoModelSelection: isNew && newSessionModel === null,
agentPhase,
isNew,
+ promptAnchorActive,
// Refs
sessionIdRef, eventSourceRef, messagesEndRef, scrollContainerRef,
lastUserMsgRef, pendingScrollToUserRef, initialScrollDoneRef,
@@ -1894,7 +1901,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
handleRecallQueue,
handleBuiltinSlashCommand,
handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages,
- scrollToBottom,
+ scrollToBottom, scrollUserMsgToTop,
dispatch, setAgentRunning, setForkingEntryId,
bashRunning, pendingBash,
// Subscriptions