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"); } }