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 默认不启用,按需配置 |
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart |
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart、WebUI 流式输出自动跟踪与回到底部按钮 |
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 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 |
| 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` |
| 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.
+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>
+57 -9
View File
@@ -195,6 +195,10 @@ export type ThinkingLevelOption =
const PROGRAMMATIC_SCROLL_IGNORE_MS = 700;
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_POLL_MS = 600;
const PROMPT_SETTLE_MAX_MS = 20_000;
@@ -437,6 +441,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
isStreaming: false,
streamingMessage: null,
});
const [atBottom, setAtBottom] = useState(true);
const [agentRunning, setAgentRunning] = useState(false);
const [bashRunning, setBashRunning] = useState(false);
const [pendingBash, setPendingBash] = useState<{
@@ -523,6 +528,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToUserRef = useRef(false);
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 =
useRef<
(
@@ -970,7 +982,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
});
break;
}
case "setStatus":
case "setStatus": {
setExtensionStatuses((prev) => {
const rest = prev.filter((item) => item.key !== request.statusKey);
return request.statusText !== undefined
@@ -978,7 +990,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
: rest;
});
break;
case "setWidget":
}
case "setWidget": {
setExtensionWidgets((prev) => {
const rest = prev.filter((item) => item.key !== request.widgetKey);
return request.widgetLines
@@ -993,6 +1006,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
: rest;
});
break;
}
case "setTitle":
if (request.title) document.title = request.title;
break;
@@ -1431,6 +1445,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
dispatch({ type: "start" });
pendingScrollToUserRef.current = true;
completionScrollAllowedRef.current = true;
followStreamRef.current = true;
const piImages = images?.map((img) => ({
type: "image" as const,
@@ -1995,6 +2010,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
followStreamRef.current = true;
ignoreProgrammaticScrollUntilRef.current =
Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS;
messagesEndRef.current?.scrollIntoView({ behavior });
@@ -2026,10 +2042,36 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, []);
const handleScrollPositionChange = useCallback(() => {
if (!agentRunningRef.current) return;
if (Date.now() < ignoreProgrammaticScrollUntilRef.current) return;
const container = scrollContainerRef.current;
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;
completionScrollAllowedRef.current = false;
// 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;
}
}
}, []);
// Load session on mount
@@ -2105,13 +2147,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, [data?.tree, activeLeafId, handleLeafChange, onBranchDataChange]);
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("pointerdown", markUserScrollIntent, {
passive: true,
});
return () => {
window.removeEventListener("keydown", markUserScrollIntent);
window.removeEventListener("pointerdown", markUserScrollIntent);
};
}, [markUserScrollIntent]);
@@ -2247,6 +2292,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
lastUserMsgRef,
pendingScrollToUserRef,
initialScrollDoneRef,
followStreamRef,
atBottom,
scrollToBottom,
// Actions
handleSend,
handleAbort,
+1
View File
@@ -240,6 +240,7 @@ export const enLocale: LocalePlugin = {
"chat.commandCopy": "Copy the last assistant message",
"chat.compacted": "Compacted",
"chat.tokensSaved": "{saved} saved",
"chat.scrollToBottom": "Scroll to bottom",
"mcp.addServer": "Add server",
"mcp.addEnv": "Add env",
"mcp.serverName": "Server name",
+1
View File
@@ -240,6 +240,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.commandCopy": "复制最后一条助手消息",
"chat.compacted": "已压缩",
"chat.tokensSaved": "节省 {saved}",
"chat.scrollToBottom": "滚动到底部",
"mcp.addServer": "添加服务器",
"mcp.addEnv": "添加环境变量",
"mcp.serverName": "服务器名称",