diff --git a/README.md b/README.md index a7399b4..6bcbb89 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pi-web/components/ChatInput.tsx b/pi-web/components/ChatInput.tsx index a7302b1..bf6937b 100644 --- a/pi-web/components/ChatInput.tsx +++ b/pi-web/components/ChatInput.tsx @@ -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(function ChatInput( onSoundToggle, onAudioUnlock, onPromptWithStreamingBehavior, + scrollToBottomButton, draftKey, cwd, }: Props, @@ -2210,7 +2214,9 @@ export const ChatInput = forwardRef(function ChatInput( maxHeight: 200, overflow: "auto", }} - /> + /> + + {scrollToBottomButton} {isStreaming ? (
(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 : ( + + ) + } isStreaming={sessionBusy} model={displayModelValue} isAutoModelSelection={isAutoModelSelection} @@ -683,7 +740,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate ); })()} {streamState.isStreaming && streamState.streamingMessage && ( - +
+ +
)} {agentRunning && !streamState.streamingMessage && ( @@ -710,10 +769,6 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate /> )} - {agentRunning && ( -
- )} -
diff --git a/pi-web/hooks/useAgentSession.ts b/pi-web/hooks/useAgentSession.ts index 846f627..4a3065e 100644 --- a/pi-web/hooks/useAgentSession.ts +++ b/pi-web/hooks/useAgentSession.ts @@ -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(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, diff --git a/pi-web/lib/i18n/messages/en.ts b/pi-web/lib/i18n/messages/en.ts index c8f5464..7b7a778 100644 --- a/pi-web/lib/i18n/messages/en.ts +++ b/pi-web/lib/i18n/messages/en.ts @@ -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", diff --git a/pi-web/lib/i18n/messages/zh-CN.ts b/pi-web/lib/i18n/messages/zh-CN.ts index fc6b7d3..e161c89 100644 --- a/pi-web/lib/i18n/messages/zh-CN.ts +++ b/pi-web/lib/i18n/messages/zh-CN.ts @@ -240,6 +240,7 @@ export const zhCNLocale: LocalePlugin = { "chat.commandCopy": "复制最后一条助手消息", "chat.compacted": "已压缩", "chat.tokensSaved": "节省 {saved}", + "chat.scrollToBottom": "滚动到底部", "mcp.addServer": "添加服务器", "mcp.addEnv": "添加环境变量", "mcp.serverName": "服务器名称",