mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -2212,6 +2216,8 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
||||
}}
|
||||
/>
|
||||
|
||||
{scrollToBottomButton}
|
||||
|
||||
{isStreaming ? (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -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 && (
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -240,6 +240,7 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"chat.commandCopy": "复制最后一条助手消息",
|
||||
"chat.compacted": "已压缩",
|
||||
"chat.tokensSaved": "节省 {saved}",
|
||||
"chat.scrollToBottom": "滚动到底部",
|
||||
"mcp.addServer": "添加服务器",
|
||||
"mcp.addEnv": "添加环境变量",
|
||||
"mcp.serverName": "服务器名称",
|
||||
|
||||
Reference in New Issue
Block a user