feat(web): auto-follow streaming output and add scroll-to-bottom button

- ChatWindow: follow the streaming bubble on every chunk (scrollIntoView block:end),
  driven by followStreamRef (true by default, only genuine scroll gestures flip it)
- Remove the agentRunning spacer that pushed content above the viewport at the bottom
- Move the scroll-to-bottom button into the input row (ChatInput scrollToBottomButton slot),
  always visible, dimmed at the bottom; click jumps to the live stream or the end
- useAgentSession: drop window pointerdown as scroll-intent marker (send-click was
  misread as a scroll-away, disabling follow before the stream started); expose
  atBottom/followStreamRef/scrollToBottom
- i18n: add chat.scrollToBottom (en/zh-CN)
This commit is contained in:
luckyyzh
2026-08-05 04:46:58 +08:00
parent 19ae25040d
commit cb9ec7a869
6 changed files with 128 additions and 17 deletions
+7 -1
View File
@@ -62,6 +62,9 @@ interface Props {
behavior: "steer" | "followUp",
images?: AttachedImage[],
) => void;
// Optional trailing control rendered inside the input row (right side,
// before the send/steer buttons) — used for the scroll-to-bottom button.
scrollToBottomButton?: React.ReactNode;
isStreaming: boolean;
model?: { provider: string; modelId: string } | null;
isAutoModelSelection?: boolean;
@@ -412,6 +415,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
onSoundToggle,
onAudioUnlock,
onPromptWithStreamingBehavior,
scrollToBottomButton,
draftKey,
cwd,
}: Props,
@@ -2210,7 +2214,9 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
maxHeight: 200,
overflow: "auto",
}}
/>
/>
{scrollToBottomButton}
{isStreaming ? (
<div
+60 -5
View File
@@ -207,11 +207,14 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
isNew,
sessionIdRef, messagesEndRef, scrollContainerRef,
lastUserMsgRef,
atBottom,
followStreamRef,
handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
handleRecallQueue,
handleBuiltinSlashCommand,
handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands,
scrollToBottom,
} = useAgentSession({
session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked,
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen,
@@ -259,6 +262,30 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
container.scrollTop = restoreScrollTop(container.scrollHeight, prevScrollDistanceRef.current);
prevScrollDistanceRef.current = null;
}, [visibleCount, scrollContainerRef]);
// Live-follow the model's streaming output: pin the streaming bubble to
// the bottom of the viewport on every chunk while auto-follow is engaged
// (the default). Scrolling away from the bottom flips followStreamRef off
// inside useAgentSession; scrolling back to the bottom or clicking the
// scroll-to-bottom button re-engages it.
const streamBubbleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!streamState.isStreaming || !streamState.streamingMessage) return;
if (!followStreamRef.current) return;
streamBubbleRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
}, [streamState]);
// Jump back to the newest content: scroll to the streaming bubble while a
// stream is live (its bottom edge is the live bottom), otherwise to the
// end of the conversation. Also re-engages auto-follow.
const handleScrollToConversationBottom = useCallback(() => {
followStreamRef.current = true;
if (streamState.isStreaming && streamState.streamingMessage) {
streamBubbleRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
} else {
scrollToBottom("smooth");
}
}, [streamState, followStreamRef, scrollToBottom]);
// Push session stats up to AppShell for the top bar.
// Compare scalar fields to avoid loops from new object identity each render.
const statsKey = sessionStats
@@ -341,6 +368,36 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
onSteer={agentRunning ? handleSteer : undefined}
onFollowUp={agentRunning ? handleFollowUp : undefined}
onPromptWithStreamingBehavior={agentRunning ? handlePromptWithStreamingBehavior : undefined}
scrollToBottomButton={
isEmptyNew ? null : (
<button
type="button"
onClick={handleScrollToConversationBottom}
aria-label={t("chat.scrollToBottom")}
title={t("chat.scrollToBottom")}
style={{
flexShrink: 0,
alignSelf: "flex-end",
width: 32,
height: 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text-muted)",
cursor: "pointer",
opacity: atBottom ? 0.55 : 1,
transition: "opacity 0.15s, background 0.15s",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)
}
isStreaming={sessionBusy}
model={displayModelValue}
isAutoModelSelection={isAutoModelSelection}
@@ -683,7 +740,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
);
})()}
{streamState.isStreaming && streamState.streamingMessage && (
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
<div ref={streamBubbleRef}>
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
</div>
)}
{agentRunning && !streamState.streamingMessage && (
@@ -710,10 +769,6 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
/>
)}
{agentRunning && (
<div style={{ height: scrollContainerRef.current ? scrollContainerRef.current.clientHeight : "80vh" }} />
)}
<div ref={messagesEndRef} />
</div>
</div>