diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx
index 794a34cf..b38890eb 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 a482f712..26c263ee 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");
}
}