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
+2 -2
View File
@@ -23,7 +23,7 @@ Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 | | 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 |
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 | | 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 | | 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart | | 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart、WebUI 流式输出自动跟踪与回到底部按钮 |
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。 本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
@@ -296,7 +296,7 @@ Pi Agent Integrated combines the Pi backend and Pi Web frontend into one indepen
| Browser automation | User-integrated | No default browser MCP | Windows uses Edge by default; macOS leaves Playwright opt-in | | Browser automation | User-integrated | No default browser MCP | Windows uses Edge by default; macOS leaves Playwright opt-in |
| Recovery and durable context | Session tree and core state | Session UI | Rewind checkpoints, repository-local memory, automatic retry | | Recovery and durable context | Session tree and core state | Session UI | Rewind checkpoints, repository-local memory, automatic retry |
| Multi-agent workflow | Extensible core | Renders tool calls | Automatic complexity policy for `pi-subagents` | | Multi-agent workflow | Extensible core | Renders tool calls | Automatic complexity policy for `pi-subagents` |
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart | | Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart, and auto-follows streaming output with a scroll-to-bottom button in the Web UI |
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows or macOS user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line. This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows or macOS user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
+6
View File
@@ -62,6 +62,9 @@ interface Props {
behavior: "steer" | "followUp", behavior: "steer" | "followUp",
images?: AttachedImage[], images?: AttachedImage[],
) => void; ) => 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; isStreaming: boolean;
model?: { provider: string; modelId: string } | null; model?: { provider: string; modelId: string } | null;
isAutoModelSelection?: boolean; isAutoModelSelection?: boolean;
@@ -412,6 +415,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
onSoundToggle, onSoundToggle,
onAudioUnlock, onAudioUnlock,
onPromptWithStreamingBehavior, onPromptWithStreamingBehavior,
scrollToBottomButton,
draftKey, draftKey,
cwd, cwd,
}: Props, }: Props,
@@ -2212,6 +2216,8 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
}} }}
/> />
{scrollToBottomButton}
{isStreaming ? ( {isStreaming ? (
<div <div
style={{ style={{
+59 -4
View File
@@ -207,11 +207,14 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
isNew, isNew,
sessionIdRef, messagesEndRef, scrollContainerRef, sessionIdRef, messagesEndRef, scrollContainerRef,
lastUserMsgRef, lastUserMsgRef,
atBottom,
followStreamRef,
handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
handleRecallQueue, handleRecallQueue,
handleBuiltinSlashCommand, handleBuiltinSlashCommand,
handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands,
scrollToBottom,
} = useAgentSession({ } = useAgentSession({
session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked, session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked,
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen,
@@ -259,6 +262,30 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
container.scrollTop = restoreScrollTop(container.scrollHeight, prevScrollDistanceRef.current); container.scrollTop = restoreScrollTop(container.scrollHeight, prevScrollDistanceRef.current);
prevScrollDistanceRef.current = null; prevScrollDistanceRef.current = null;
}, [visibleCount, scrollContainerRef]); }, [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. // Push session stats up to AppShell for the top bar.
// Compare scalar fields to avoid loops from new object identity each render. // Compare scalar fields to avoid loops from new object identity each render.
const statsKey = sessionStats const statsKey = sessionStats
@@ -341,6 +368,36 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
onSteer={agentRunning ? handleSteer : undefined} onSteer={agentRunning ? handleSteer : undefined}
onFollowUp={agentRunning ? handleFollowUp : undefined} onFollowUp={agentRunning ? handleFollowUp : undefined}
onPromptWithStreamingBehavior={agentRunning ? handlePromptWithStreamingBehavior : 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} isStreaming={sessionBusy}
model={displayModelValue} model={displayModelValue}
isAutoModelSelection={isAutoModelSelection} isAutoModelSelection={isAutoModelSelection}
@@ -683,7 +740,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
); );
})()} })()}
{streamState.isStreaming && streamState.streamingMessage && ( {streamState.isStreaming && streamState.streamingMessage && (
<div ref={streamBubbleRef}>
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} /> <MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
</div>
)} )}
{agentRunning && !streamState.streamingMessage && ( {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 ref={messagesEndRef} />
</div> </div>
</div> </div>
+56 -8
View File
@@ -195,6 +195,10 @@ export type ThinkingLevelOption =
const PROGRAMMATIC_SCROLL_IGNORE_MS = 700; const PROGRAMMATIC_SCROLL_IGNORE_MS = 700;
const USER_SCROLL_INTENT_MS = 1200; const USER_SCROLL_INTENT_MS = 1200;
// Distance from the container bottom (px) under which the view is
// considered "at the bottom". Auto-follow stays engaged within this range;
// scrolling further up disengages it.
const FOLLOW_STREAM_THRESHOLD = 100;
const PROMPT_SETTLE_INITIAL_DELAY_MS = 800; const PROMPT_SETTLE_INITIAL_DELAY_MS = 800;
const PROMPT_SETTLE_POLL_MS = 600; const PROMPT_SETTLE_POLL_MS = 600;
const PROMPT_SETTLE_MAX_MS = 20_000; const PROMPT_SETTLE_MAX_MS = 20_000;
@@ -437,6 +441,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
isStreaming: false, isStreaming: false,
streamingMessage: null, streamingMessage: null,
}); });
const [atBottom, setAtBottom] = useState(true);
const [agentRunning, setAgentRunning] = useState(false); const [agentRunning, setAgentRunning] = useState(false);
const [bashRunning, setBashRunning] = useState(false); const [bashRunning, setBashRunning] = useState(false);
const [pendingBash, setPendingBash] = useState<{ const [pendingBash, setPendingBash] = useState<{
@@ -523,6 +528,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const lastUserMsgRef = useRef<HTMLDivElement | null>(null); const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToUserRef = useRef(false); const pendingScrollToUserRef = useRef(false);
const completionScrollAllowedRef = useRef(true); const completionScrollAllowedRef = useRef(true);
// Whether the view should auto-follow new content (streaming output,
// completion). True by default; a user scroll away from the bottom flips
// it off, scrolling back to the bottom or clicking the scroll-to-bottom
// button flips it on again. A ref (not state) because only the streaming
// chunk render needs to react to it.
const followStreamRef = useRef(true);
const atBottomRef = useRef(true);
const executeBashRef = const executeBashRef =
useRef< useRef<
( (
@@ -970,7 +982,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}); });
break; break;
} }
case "setStatus": case "setStatus": {
setExtensionStatuses((prev) => { setExtensionStatuses((prev) => {
const rest = prev.filter((item) => item.key !== request.statusKey); const rest = prev.filter((item) => item.key !== request.statusKey);
return request.statusText !== undefined return request.statusText !== undefined
@@ -978,7 +990,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
: rest; : rest;
}); });
break; break;
case "setWidget": }
case "setWidget": {
setExtensionWidgets((prev) => { setExtensionWidgets((prev) => {
const rest = prev.filter((item) => item.key !== request.widgetKey); const rest = prev.filter((item) => item.key !== request.widgetKey);
return request.widgetLines return request.widgetLines
@@ -993,6 +1006,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
: rest; : rest;
}); });
break; break;
}
case "setTitle": case "setTitle":
if (request.title) document.title = request.title; if (request.title) document.title = request.title;
break; break;
@@ -1431,6 +1445,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
dispatch({ type: "start" }); dispatch({ type: "start" });
pendingScrollToUserRef.current = true; pendingScrollToUserRef.current = true;
completionScrollAllowedRef.current = true; completionScrollAllowedRef.current = true;
followStreamRef.current = true;
const piImages = images?.map((img) => ({ const piImages = images?.map((img) => ({
type: "image" as const, type: "image" as const,
@@ -1995,6 +2010,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
); );
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
followStreamRef.current = true;
ignoreProgrammaticScrollUntilRef.current = ignoreProgrammaticScrollUntilRef.current =
Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS;
messagesEndRef.current?.scrollIntoView({ behavior }); messagesEndRef.current?.scrollIntoView({ behavior });
@@ -2026,10 +2042,36 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, []); }, []);
const handleScrollPositionChange = useCallback(() => { const handleScrollPositionChange = useCallback(() => {
if (!agentRunningRef.current) return; const container = scrollContainerRef.current;
if (Date.now() < ignoreProgrammaticScrollUntilRef.current) return; if (!container) return;
const distFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distFromBottom <= FOLLOW_STREAM_THRESHOLD;
// Keep the "at bottom" indicator (scroll-to-bottom button visibility)
// in sync on every scroll, regardless of agent state.
if (atBottomRef.current !== nearBottom) {
atBottomRef.current = nearBottom;
setAtBottom(nearBottom);
}
// Auto-follow only reacts to genuine scroll gestures (wheel / touch /
// keyboard scroll keys inside the intent window). Programmatic scrolls
// must never flip it — right after sending, scrollUserMsgToTop parks
// the view at the user message (away from the bottom because of the
// running spacer); treating that as a user scroll-away would disable
// follow before the stream even starts. A plain click (send button,
// minimap) is also not a scroll gesture.
if (Date.now() > userScrollIntentUntilRef.current) return; if (Date.now() > userScrollIntentUntilRef.current) return;
// Scrolled away from the bottom → stop following the stream; scrolled
// back to the bottom → resume following.
if (followStreamRef.current !== nearBottom) {
followStreamRef.current = nearBottom;
if (!nearBottom && agentRunningRef.current) {
completionScrollAllowedRef.current = false; completionScrollAllowedRef.current = false;
}
}
}, []); }, []);
// Load session on mount // Load session on mount
@@ -2105,13 +2147,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, [data?.tree, activeLeafId, handleLeafChange, onBranchDataChange]); }, [data?.tree, activeLeafId, handleLeafChange, onBranchDataChange]);
useEffect(() => { useEffect(() => {
// Only genuine scroll gestures (wheel / touch / keyboard scroll keys)
// count as user scroll intent. A plain pointerdown (clicking send, a
// button, the minimap) is NOT — otherwise the programmatic
// scrollUserMsgToTop that runs right after sending would be treated as
// user-initiated, the handler would see the view away from the bottom
// (the user message sits above the spacer), and auto-follow would be
// disabled before the stream even starts.
window.addEventListener("keydown", markUserScrollIntent); window.addEventListener("keydown", markUserScrollIntent);
window.addEventListener("pointerdown", markUserScrollIntent, {
passive: true,
});
return () => { return () => {
window.removeEventListener("keydown", markUserScrollIntent); window.removeEventListener("keydown", markUserScrollIntent);
window.removeEventListener("pointerdown", markUserScrollIntent);
}; };
}, [markUserScrollIntent]); }, [markUserScrollIntent]);
@@ -2247,6 +2292,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
lastUserMsgRef, lastUserMsgRef,
pendingScrollToUserRef, pendingScrollToUserRef,
initialScrollDoneRef, initialScrollDoneRef,
followStreamRef,
atBottom,
scrollToBottom,
// Actions // Actions
handleSend, handleSend,
handleAbort, handleAbort,
+1
View File
@@ -240,6 +240,7 @@ export const enLocale: LocalePlugin = {
"chat.commandCopy": "Copy the last assistant message", "chat.commandCopy": "Copy the last assistant message",
"chat.compacted": "Compacted", "chat.compacted": "Compacted",
"chat.tokensSaved": "{saved} saved", "chat.tokensSaved": "{saved} saved",
"chat.scrollToBottom": "Scroll to bottom",
"mcp.addServer": "Add server", "mcp.addServer": "Add server",
"mcp.addEnv": "Add env", "mcp.addEnv": "Add env",
"mcp.serverName": "Server name", "mcp.serverName": "Server name",
+1
View File
@@ -240,6 +240,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.commandCopy": "复制最后一条助手消息", "chat.commandCopy": "复制最后一条助手消息",
"chat.compacted": "已压缩", "chat.compacted": "已压缩",
"chat.tokensSaved": "节省 {saved}", "chat.tokensSaved": "节省 {saved}",
"chat.scrollToBottom": "滚动到底部",
"mcp.addServer": "添加服务器", "mcp.addServer": "添加服务器",
"mcp.addEnv": "添加环境变量", "mcp.addEnv": "添加环境变量",
"mcp.serverName": "服务器名称", "mcp.serverName": "服务器名称",