diff --git a/.gitignore b/.gitignore index 3a44c749f..c7a4d5b77 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ # next.js /.next/ +/.next-dev/ +.next-dev/ /out/ # production diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..15ea55049 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -8,6 +8,7 @@ import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantB import { MessageView } from "./MessageView"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; +import { ScrollToolbar } from "./ScrollToolbar"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; import { useI18n } from "@/hooks/useI18n"; import { useAgentSession, type AgentPhase, type NoticeItem } from "@/hooks/useAgentSession"; @@ -196,6 +197,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate chatInputRef?.current?.insertIfEmpty(content); }, [chatInputRef]); + // Follow-streaming toggle (long-press on the 'latest' scroll button). + const followStreamingRef = useRef(false); + const { loading, error, messages, entryIds, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, @@ -216,6 +220,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate } = useAgentSession({ session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen, + followStreamingRef, }); const sessionBusy = agentRunning || bashRunning; @@ -720,13 +725,29 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate )} {agentRunning && ( -
+ /* Room below the last message while the agent runs so the + follow-step can land the last message at the top 1/4 of the + viewport with 3/4 of blank space below for the output to + grow. (Was a full viewport, then 96px — both broke the step's + landing.) */ +
)}
+ {isMobile ? null : ( ; + messagesEndRef: React.RefObject; + messageRefs: React.MutableRefObject<(HTMLDivElement | null)[]>; + /** user/assistant-filtered messages, index-aligned with messageRefs */ + visibleMessages: AgentMessage[]; + /** total messages (lazy pagination check) */ + messagesLength: number; + agentRunning: boolean; + isMobile: boolean; + setVisibleCount: (updater: (current: number) => number) => void; + /** + * Long-press on the 'latest' button toggles follow-streaming. True → the + * list scrolls with the latest message even while the agent runs; clicking + * any scroll-navigation button clears it. + */ + followStreamingRef: React.RefObject; +} + +export function ScrollToolbar({ + scrollContainerRef, + messagesEndRef, + messageRefs, + visibleMessages, + messagesLength, + agentRunning, + isMobile, + setVisibleCount, + followStreamingRef, +}: ScrollToolbarProps) { + const { t } = useI18n(); + + // Draggable toolbar position. Default: bottom-right (aligned to the message + // column). Stored in localStorage so the user's hand preference survives + // reloads. Coordinates are viewport-relative (left/top of the button + // column). + const DEFAULT_POS = { align: "right" as "left" | "right", y: 12 }; + const [pos, setPos] = useState<{ align: "left" | "right"; y: number }>(() => { + try { + const raw = localStorage.getItem("pi-scroll-toolbar-pos"); + if (raw) { + const parsed = JSON.parse(raw) as { align?: "left" | "right"; y: number }; + if (typeof parsed.y === "number" && (parsed.align === "left" || parsed.align === "right")) return { align: parsed.align, y: parsed.y }; + } + } catch { /* ignore */ } + return DEFAULT_POS; + }); + const dragRef = useRef<{ startX: number; startY: number; startY0: number; moved: boolean; pointerId: number; startTime: number } | null>(null); + const toolbarRef = useRef(null); + const dragModeRef = useRef(false); + // Set when a tap (touch pointerup without drag) already navigated, so the + // trailing click doesn't navigate twice. + const tapNavigatedRef = useRef(false); + // Element whose action menu was revealed by the last prev/next jump; the + // next jump hides it again before revealing the new target's menu. + const lastNavTargetRef = useRef(null); + // Mirror of pos so the pointer-up handler can persist the LATEST dragged + // position (the pos state captured in its closure would be stale). + const posRef = useRef(pos); + const setPosAndRef = useCallback((p: { align: "left" | "right"; y: number }) => { + posRef.current = p; + setPos(p); + }, []); + + // Track whether the message list is at the top / bottom so the buttons only + // appear when there is something to scroll to. Buttons show while scrolling + // and hide shortly after it stops; hovering keeps them visible. + const [scrollAnchors, setScrollAnchors] = useState<{ atTop: boolean; atBottom: boolean }>({ atTop: true, atBottom: true }); + const [scrollActive, setScrollActive] = useState(false); + const [scrollBtnsHovered, setScrollBtnsHovered] = useState(false); + const [scrollTooltip, setScrollTooltip] = useState<"earliest" | "prevUser" | "nextUser" | "latest" | null>(null); + // Tooltips auto-dismiss after ~1.5s. On touch there is no reliable + // pointer-leave, so without this a tapped button's tooltip would stay + // pinned to the toolbar forever; on mouse it also avoids waiting for the + // cursor to leave the button. tooltipShowCountRef caps the hints at 3 + // shows per page session (refresh resets). + const tooltipShowCountRef = useRef(0); + const tooltipTimerRef = useRef | null>(null); + const showScrollTooltip = useCallback((kind: "earliest" | "prevUser" | "nextUser" | "latest") => { + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); + setScrollTooltip(kind); + tooltipTimerRef.current = setTimeout(() => setScrollTooltip(null), 1500); + }, []); + const hideScrollTooltip = useCallback(() => { + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); + tooltipTimerRef.current = null; + setScrollTooltip(null); + }, []); + const scrollIdleTimerRef = useRef | null>(null); + // Brief "auto-scroll follow on" toast after a successful long-press. + const [followToast, setFollowToast] = useState(false); + const followToastTimerRef = useRef | null>(null); + // Per-page-session hint counters (a refresh resets them, i.e. the refs + // start at 0 again on a fresh page load): + // - toolbarCycleCountRef: toolbar show → hide cycles. The 'long-press to + // follow' tooltip shows for the first 3 cycles, then stops. + // - followToastCountRef: how many times the 'following on' toast appeared + // (max 3 per page session). + const toolbarCycleCountRef = useRef(0); + const followToastCountRef = useRef(0); + const [followStreaming, setFollowStreaming] = useState(false); + const updateFollowStreaming = useCallback((v: boolean) => { + if (followStreamingRef) followStreamingRef.current = v; + setFollowStreaming(v); + }, [followStreamingRef]); + + // Long-press on the 'latest' button toggles follow-streaming. + const longPressTimerRef = useRef | null>(null); + const didLongPressRef = useRef(false); + const clearLongPress = useCallback(() => { + if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } + }, []); + + // --- Drag-to-reposition the whole toolbar (long-press + move) --- + // Horizontal position snaps to LEFT or RIGHT edge only (the user chooses + // which side by dragging across the screen midpoint). Vertical position is + // free. Both are persisted; on reload the horizontal edge is recomputed + // against the current viewport and the saved vertical offset is applied. + // Drag via window-level native listeners: once the pointer goes down on the + // toolbar, ALL subsequent pointermove/up are tracked globally so the drag + // never stops when the finger leaves the small button column (real touch + // pointer events do not bubble after leaving the element). + const dragMoveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null); + const dragUpHandlerRef = useRef<(() => void) | null>(null); + + const onToolbarPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + startY0: pos.y, + moved: false, + pointerId: e.pointerId, + startTime: Date.now(), + }; + dragModeRef.current = true; + + const onMove = (ev: PointerEvent) => { + const drag = dragRef.current; + if (!drag || ev.pointerId !== drag.pointerId) return; + const dx = ev.clientX - drag.startX; + const dy = ev.clientY - drag.startY; + if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 8) return; + drag.moved = true; + // Stop the browser from hijacking the gesture as scroll/touch panning. + try { ev.preventDefault(); } catch { /* ignore */ } + clearLongPress(); + const el = toolbarRef.current; + if (!el) return; + const maxY = Math.max(8, window.innerHeight - el.offsetHeight - 8); + const ny = Math.min(Math.max(8, drag.startY0 + dy), maxY); + const nx = ev.clientX < window.innerWidth / 2 ? "left" : "right"; + setPosAndRef({ align: nx, y: ny }); + }; + const onUp = () => { + const drag = dragRef.current; + if (!drag) return; + const wasDrag = drag.moved; + dragRef.current = null; + dragModeRef.current = false; + if (wasDrag) { + try { + localStorage.setItem("pi-scroll-toolbar-pos", JSON.stringify({ align: posRef.current.align, y: posRef.current.y })); + } catch { /* ignore */ } + } + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragMoveHandlerRef.current = null; + dragUpHandlerRef.current = null; + }; + dragMoveHandlerRef.current = onMove; + dragUpHandlerRef.current = onUp; + window.addEventListener("pointermove", onMove, { passive: false }); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }, [pos.y, clearLongPress, setPosAndRef]); + + const handleScrollAnchorChange = useCallback(() => { + const c = scrollContainerRef.current; + if (!c) return; + const atTop = c.scrollTop <= SCROLL_ANCHOR_THRESHOLD; + const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight <= SCROLL_ANCHOR_THRESHOLD; + setScrollAnchors((prev) => (prev.atTop === atTop && prev.atBottom === atBottom ? prev : { atTop, atBottom })); + setScrollActive(true); + if (scrollIdleTimerRef.current) clearTimeout(scrollIdleTimerRef.current); + scrollIdleTimerRef.current = setTimeout(() => setScrollActive(false), 2000); + }, [scrollContainerRef]); + + // Bind the scroll-position tracking to the container so ChatWindow does + // not need an onScroll handler for it. + useEffect(() => { + const c = scrollContainerRef.current; + if (!c) return; + c.addEventListener("scroll", handleScrollAnchorChange, { passive: true }); + return () => c.removeEventListener("scroll", handleScrollAnchorChange); + }, [handleScrollAnchorChange, scrollContainerRef]); + + useEffect(() => () => { + if (scrollIdleTimerRef.current) clearTimeout(scrollIdleTimerRef.current); + }, []); + + // Touch devices have no real hover: a tap fires mouseenter but the matching + // mouseleave never arrives, so scrollBtnsHovered would stay true forever and + // the buttons would never auto-hide. On mobile ignore hover entirely. + const showScrollButtons = (!scrollAnchors.atTop || !scrollAnchors.atBottom) && (scrollActive || (!isMobile && scrollBtnsHovered)); + + const scrollToEarliest = useCallback(() => { + updateFollowStreaming(false); + scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, [scrollContainerRef, updateFollowStreaming]); + + const scrollToLatest = useCallback(() => { + updateFollowStreaming(false); + const c = scrollContainerRef.current; + if (!c) return; + // Scroll so the LAST MESSAGE's bottom sits at the viewport bottom. + // + // Layout (top → bottom): + // [...messages] + // [agent-running spacer, height = clientHeight] ← only when agentRunning + //
+ // + // messagesEndRef.offsetTop already includes the spacer, but offsetTop is + // relative to the nearest positioned ancestor (not necessarily the scroll + // container). Compute the end sentinel's position inside the container + // via getBoundingClientRect, then back off by the spacer height AND the + // viewport height so the last message lands at the bottom and the spacer + // stays below the fold (no blank screen). ≈40px visual keep-out below the + // last message (sentinel 28px + the last message's own ~16px bottom + // margin → extra 40-28-16 = -4). + const end = messagesEndRef.current; + if (end) { + const endInContainer = + end.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; + const spacerH = agentRunning ? c.clientHeight * 0.75 : 0; + // ≈40px visual keep-out below the last message (sentinel 28px + the + // last message's own ~16px bottom margin → extra 40-28-16 = -4). + const target = Math.max(0, endInContainer - spacerH - c.clientHeight - 4); + c.scrollTo({ top: target, behavior: "smooth" }); + return; + } + c.scrollTo({ top: c.scrollHeight, behavior: "smooth" }); + }, [agentRunning, messagesEndRef, scrollContainerRef, updateFollowStreaming]); + + /** + * Scroll to the previous/next user message relative to the current viewport. + * Buttons sit between "earliest" and "latest" and jump from question to + * question, skipping assistant/tool content. + */ + // Latest reference so the lazy-load retry can re-invoke navigation. + const scrollToUserMessageRef = useRef<((dir: -1 | 1) => void) | null>(null); + const scrollToUserMessage = useCallback((dir: -1 | 1) => { + updateFollowStreaming(false); + const c = scrollContainerRef.current; + if (!c) return; + const refs = messageRefs.current; + if (!refs || refs.length === 0) return; + // How many messages are actually RENDERED (refs filled). Lazy pagination + // renders only a window of the list; navigation needs all user messages + // to be present, so load more when the rendered count is short of the + // full list. + const renderedCount = refs.filter(Boolean).length; + let anchor = -1; + if (dir === -1) { + // prev: anchor = the last user message at/above the VIEWPORT TOP (the + // question we're currently reading). If it sits above the viewport, + // jump TO it; if it's at the top already, go one earlier. + const limit = c.scrollTop + 8; + for (let i = 0; i < refs.length; i++) { + const el = refs[i]; + if (!el || visibleMessages[i]?.role !== "user") continue; + const elTop = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; + if (elTop <= limit + 4) { anchor = i; continue; } + if (anchor === -1) anchor = i; + break; + } + if (anchor === -1) anchor = refs.length - 1; + const anchorEl = refs[anchor]; + const anchorTop = anchorEl + ? anchorEl.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop + : -1; + const anchorVisible = anchorTop >= c.scrollTop; + // Search before the anchor (start = anchor - 1) if the anchor is on + // screen; otherwise jump to the anchor itself. + const start = anchorVisible ? anchor - 1 : anchor; + for (let i = start; i >= 0; i--) { + if (visibleMessages[i]?.role !== "user") continue; + const el = refs[i]; + if (!el) continue; + const elTop = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; + c.scrollTo({ top: Math.max(0, elTop - 24), behavior: "smooth" }); + // Reveal the target message's action menu (copy/edit/new-session) — + // the same state a real mouse-over produces. The synthetic mouseover + // must bubble THROUGH the message container that owns onMouseEnter; + // messageRefs points at the wrapper div around the message, so target + // its first child (the actual message container). + const menuTarget = (el.firstElementChild ?? el) as HTMLElement; + // Restore the previously revealed message's menu before showing the + // new target's, so switching never leaves two action menus open. + if (lastNavTargetRef.current && lastNavTargetRef.current !== menuTarget) { + lastNavTargetRef.current.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); + } + menuTarget.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + lastNavTargetRef.current = menuTarget; + return; + } + } else { + // next: anchor = the FIRST message at/at-below the VIEWPORT TOP (any + // role). "Next user" = the user message after that first visible one, + // so we never re-select the question sitting at the top of the screen. + for (let i = 0; i < refs.length; i++) { + const el = refs[i]; + if (!el) continue; + const elTop = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; + if (elTop >= c.scrollTop) { anchor = i; break; } + } + if (anchor === -1) anchor = refs.length - 1; + for (let i = anchor + 1; i < visibleMessages.length; i++) { + if (visibleMessages[i]?.role !== "user") continue; + const el = refs[i]; + if (!el) continue; + const elTop = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; + c.scrollTo({ top: Math.max(0, elTop - 24), behavior: "smooth" }); + // Reveal the target message's action menu (see prev branch). + const menuTarget = (el.firstElementChild ?? el) as HTMLElement; + // Restore the previously revealed message's menu before showing the + // new target's, so switching never leaves two action menus open. + if (lastNavTargetRef.current && lastNavTargetRef.current !== menuTarget) { + lastNavTargetRef.current.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); + } + menuTarget.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + lastNavTargetRef.current = menuTarget; + return; + } + } + // No user message found within the loaded window — there may be older/ + // newer messages hidden by lazy pagination. Load more and retry so the + // button eventually works even on a long history. + if (setVisibleCount && renderedCount < messagesLength) { + setVisibleCount((current) => Math.max(current, messagesLength * 2)); + setTimeout(() => scrollToUserMessageRef.current?.(dir), 250); + } + }, [messageRefs, visibleMessages, messagesLength, scrollContainerRef, setVisibleCount, updateFollowStreaming]); + scrollToUserMessageRef.current = scrollToUserMessage; + + return ( + <> + {showScrollButtons && ( +
setScrollBtnsHovered(true)} + onMouseLeave={() => setScrollBtnsHovered(false)} + > + {( + + )} + {( + + )} + {( + + )} + {( + // 60px hit-area wrapper for the long-press gesture (visual button + // stays 44px inside). +
{ + // Long-press (>600ms) toggles follow-streaming; a quick + // press/click still jumps to the latest message. Bound on + // the hit-area wrapper so the gesture is easy to land on and + // small screens don't trigger text selection. NOTE: do NOT + // preventDefault here — on mobile that suppresses the + // subsequent click, so follow could never be unlocked again. + // stopPropagation: a long-press must not also start a toolbar + // drag when the finger wiggles a few px. + e.stopPropagation(); + clearLongPress(); + longPressTimerRef.current = setTimeout(() => { + didLongPressRef.current = true; + // Toggle: long-press again turns follow OFF. + const nowOn = !(followStreamingRef.current ?? false); + updateFollowStreaming(nowOn); + document.getSelection()?.removeAllRanges(); + const prev = document.body.style.userSelect; + document.body.style.userSelect = "none"; + // The "following on" toast is only useful the first few + // times; after that it's noise. Show it at most 3 times per + // page session (a refresh resets the counter). + if (nowOn) { + // The "following on" toast is only useful the first few + // times; after that it's noise. Show it at most 3 times + // per page session (a refresh resets the counter). + if (followToastCountRef.current < 3) { + followToastCountRef.current += 1; + setFollowToast(true); + } + } + if (followToastTimerRef.current) clearTimeout(followToastTimerRef.current); + followToastTimerRef.current = setTimeout(() => { + setFollowToast(false); + document.body.style.userSelect = prev; + }, 2000); + }, 600); + }} + onPointerUp={clearLongPress} + onPointerCancel={clearLongPress} + onPointerLeave={clearLongPress} + onContextMenu={(e) => e.preventDefault()} + > + +
+ )} + {scrollTooltip && ( +
+ {scrollTooltip === "earliest" ? t("chat.scrollToEarliest") + : scrollTooltip === "prevUser" ? t("chat.scrollToPrevUser") + : scrollTooltip === "nextUser" ? t("chat.scrollToNextUser") + : t("chat.scrollToLatestFollowHint")} +
+ )} +
+ )} + {followToast && ( +
+ {t("chat.followStreamingOn")} +
+ )} + + ); +} diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index a482f7120..11160cd8c 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -150,12 +150,18 @@ export interface UseAgentSessionOptions { onSystemPromptChange?: (prompt: string | null) => void; onSessionStatsPanelOpen?: () => void; setToolPreset?: (preset: "none" | "default" | "full") => void; + /** + * Long-press on the 'latest' scroll button flips this to true → the message + * list follows streaming output even while the agent runs. Clicking any + * scroll-navigation button flips it back to false. + */ + followStreamingRef?: React.RefObject; } export type ThinkingLevelOption = "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; const PROGRAMMATIC_SCROLL_IGNORE_MS = 700; -const USER_SCROLL_INTENT_MS = 1200; +const USER_SCROLL_INTENT_MS = 6000; const PROMPT_SETTLE_INITIAL_DELAY_MS = 800; const PROMPT_SETTLE_POLL_MS = 600; const PROMPT_SETTLE_MAX_MS = 20_000; @@ -1682,7 +1688,25 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; - messagesEndRef.current?.scrollIntoView({ behavior }); + const container = scrollContainerRef.current; + const end = messagesEndRef.current; + if (!container || !end) return; + // The end sentinel sits BELOW the agent-running spacer (96px). + // scrollIntoView on the sentinel would put that blank spacer in the + // viewport — hence the blank screen while follow-streaming during a run. + // Back off by the spacer + viewport height so the LAST MESSAGE lands + // ~40px above the viewport bottom (sentinel 28px + the last message's + // own ~16px bottom margin → extra 40-28-16 = -4). + const endInContainer = end.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop; + // The agent-running spacer (3/4 viewport) sits between the last message + // and the sentinel; back it out so the LAST MESSAGE lands ~40px above the + // viewport bottom. + const spacerH = agentRunningRef.current ? container.clientHeight * 0.75 : 0; + // Visual keep-out below the last message ≈40px. The sentinel is 28px tall + // and the last message's own bottom margin (~16px) sits between it and the + // sentinel, so back off (40 - 28 - 16) = -4 on top of the sentinel. + const target = Math.max(0, endInContainer - spacerH - container.clientHeight - 4); + container.scrollTo({ top: target, behavior }); }, []); const scrollUserMsgToTop = useCallback(() => { @@ -1784,6 +1808,39 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }; }, [messages.length, loading, handleScrollPositionChange, markUserScrollIntent]); + // Smart follow: only scroll when the last message is about to leave the + // viewport (40px keep-out below), so a visible last message does not cause + // constant jumping. Called on message-count changes AND on streaming chunk + // updates (streaming grows the visible message without changing the count). + const smartFollowCheck = useCallback(() => { + if (!opts.followStreamingRef?.current) return; + if (Date.now() < userScrollIntentUntilRef.current) return; + const container = scrollContainerRef.current; + const end = messagesEndRef.current; + if (!container || !end) return; + const endTop = end.getBoundingClientRect().top - container.getBoundingClientRect().top; + const spacerH = agentRunningRef.current ? container.clientHeight * 0.75 : 0; + const lastMsgBottom = endTop - 28 - spacerH; + // Step-follow: when new content pushes the last message past the small + // keep-out zone, step so the last message lands at the top 1/4 of the + // viewport. The bottom 3/4 refills as the output grows — gentler than a + // full jump-to-bottom every message, and the output is always visible. + if (lastMsgBottom > container.clientHeight - 40) { + const lastMsgAbs = + end.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop - 28 - spacerH; + // Land the last message at the top 1/4 of the viewport: plenty of room + // below for the output to grow before the next step. INSTANT, not + // smooth: a streaming chunk arrives every few hundred ms and re-runs + // this check — a smooth animation gets cancelled mid-flight by the next + // chunk's scrollTo, so the step never completes and the view only + // creeps up a little (no big blank area below). Instant lands the full + // step every time. + const target = Math.max(0, lastMsgAbs - container.clientHeight / 4); + ignoreProgrammaticScrollUntilRef.current = Date.now() + 1200; + container.scrollTo({ top: target, behavior: "instant" }); + } + }, [opts.followStreamingRef, scrollContainerRef, messagesEndRef]); + useEffect(() => { if (messages.length > 0) { if (pendingScrollToUserRef.current) { @@ -1795,9 +1852,33 @@ export function useAgentSession(opts: UseAgentSessionOptions) { scrollToBottom("instant"); } else if (!agentRunningRef.current && completionScrollAllowedRef.current) { scrollToBottom("smooth"); + } else if (opts.followStreamingRef?.current) { + // Follow-streaming on: scroll only when new content pushes the last + // message out of view; while it is still visible, don't jump. + smartFollowCheck(); } } - }, [messages.length, agentRunning, scrollToBottom, scrollUserMsgToTop]); + }, [messages.length, agentRunning, scrollToBottom, scrollUserMsgToTop, opts.followStreamingRef, smartFollowCheck]); + + // Streaming chunks grow the visible message without changing messages.length; + // re-run the smart-follow check on every chunk so the growing message is + // scrolled into view once it exceeds the viewport. + useEffect(() => { + if (streamState.isStreaming) smartFollowCheck(); + }, [streamState, smartFollowCheck]); + + // The queue banner / input area sits BELOW the scroll container in a flex + // column. When the queue grows (or shrinks), the container's clientHeight + // changes without any message event — the last message can end up hidden + // behind the queue banner. Re-run the smart-follow check on size changes so + // follow re-aims at the new (smaller) viewport. + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + const ro = new ResizeObserver(() => smartFollowCheck()); + ro.observe(container); + return () => ro.disconnect(); + }, [smartFollowCheck, scrollContainerRef]); // Load model list useEffect(() => { diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..8acb76277 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -241,6 +241,12 @@ export const enLocale: LocalePlugin = { "chat.commandCopy": "Copy the last assistant message", "chat.compacted": "Compacted", "chat.tokensSaved": "{saved} saved", + "chat.scrollToEarliest": "Jump to earliest message", + "chat.scrollToLatest": "Jump to latest message", + "chat.scrollToPrevUser": "Previous user message", + "chat.scrollToNextUser": "Next user message", + "chat.scrollToLatestFollowHint": "Jump to latest (long-press this button to follow streaming)", + "chat.followStreamingOn": "Auto-scroll following enabled", "i18n.close": "Close", "i18n.copy": "Copy", "i18n.copied": "Copied", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..1578b6d4c 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -241,6 +241,12 @@ export const zhCNLocale: LocalePlugin = { "chat.commandCopy": "复制最后一条助手消息", "chat.compacted": "已压缩", "chat.tokensSaved": "节省 {saved}", + "chat.scrollToEarliest": "到最早消息", + "chat.scrollToLatest": "到最新消息", + "chat.scrollToPrevUser": "上一个用户输入", + "chat.scrollToNextUser": "下一个用户输入", + "chat.scrollToLatestFollowHint": "到最新消息(长按此按钮可开启自动跟随)", + "chat.followStreamingOn": "已开启自动滚动跟随", "i18n.close": "关闭", "i18n.copy": "复制", "i18n.copied": "已复制",