From 4d365fc323f615be733be83898a634b803fd1fee Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:07:51 +0800 Subject: [PATCH 01/14] feat: floating scroll toolbar with half-viewport follow-streaming A 4-button floating toolbar (earliest / prev user / next user / latest) over the chat column, auto-hiding 2s after scrolling stops: - prev/next jump between user messages with lazy-pagination fallback - long-press 'latest' toggles follow-streaming (second long-press or click unlocks); follow steps half a viewport when new content pushes the last message past a 40px keep-out, so output stays visible and jumps are gentle - manual scroll pauses follow for 6s, then it resumes - drag to reposition (snaps left/right, vertical free, persisted) - touch: no hover-dependent visibility, tap-safe pointer handling - agent-running spacer reduced to 96px (was a full viewport of blank space) - scrollToBottom backs off the spacer so the last message, not the blank spacer, sits at the viewport bottom --- .gitignore | 2 + components/ChatWindow.tsx | 23 +- components/ScrollToolbar.tsx | 619 +++++++++++++++++++++++++++++++++++ hooks/useAgentSession.ts | 75 ++++- lib/i18n/messages/en.ts | 6 + lib/i18n/messages/zh-CN.ts | 6 + 6 files changed, 727 insertions(+), 4 deletions(-) create mode 100644 components/ScrollToolbar.tsx 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..9a58f5797 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 && ( -
+ /* Short buffer below the last message while the agent runs. + Was a full viewport tall (clientHeight) — that put a whole + blank screen under the message right after sending (the + message is scrolled to the top). A small spacer keeps the + scroll-lock intent without the white void. */ +
)}
+ {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); + // 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); + 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); + 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). Keep ~100px of breathing room + // below the last message (sentinel is 28px tall → extra 100-28). + const end = messagesEndRef.current; + if (end) { + const endInContainer = + end.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; + const spacerH = agentRunning ? 96 : 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" }); + 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" }); + 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. + updateFollowStreaming(!(followStreamingRef.current ?? false)); + setFollowToast(true); + document.getSelection()?.removeAllRanges(); + const prev = document.body.style.userSelect; + document.body.style.userSelect = "none"; + 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..582d32a5a 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,19 @@ 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; + const spacerH = agentRunningRef.current ? 96 : 0; + const target = Math.max(0, endInContainer - spacerH - container.clientHeight - 4); + container.scrollTo({ top: target, behavior }); }, []); const scrollUserMsgToTop = useCallback(() => { @@ -1784,6 +1802,33 @@ 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 ? 96 : 0; + const lastMsgBottom = endTop - 28 - spacerH; + // Half-viewport step-follow: when new content pushes the last message past + // the small keep-out zone, step so the last message lands at ~55% of the + // viewport height. The growing output refills the lower half before the + // next step — 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; + const target = Math.max(0, lastMsgAbs - container.clientHeight * 0.55); + ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; + container.scrollTo({ top: target, behavior: "smooth" }); + } + }, [opts.followStreamingRef, scrollContainerRef, messagesEndRef]); + useEffect(() => { if (messages.length > 0) { if (pendingScrollToUserRef.current) { @@ -1795,9 +1840,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": "已复制", From 1d87b870410adb35d9ad58e1fc293f13189e673e Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:30:03 +0800 Subject: [PATCH 02/14] feat: reveal action menu on prev/next user-message jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the toolbar's prev/next navigation scrolls to a user message, dispatch a synthetic mouseover on the message container so its action menu (copy / edit-from-here / new-session) shows — same state a real mouse-over produces. Dispatch targets the wrapper's first child: messageRefs point at the wrapper div around the message, and React's onMouseEnter lives on the container inside it, so the mouseover must originate there to bubble through it. --- components/ScrollToolbar.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 96725696c..47448a066 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -268,6 +268,12 @@ export function ScrollToolbar({ 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). + (el.firstElementChild ?? el).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); return; } } else { @@ -287,6 +293,8 @@ export function ScrollToolbar({ 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). + (el.firstElementChild ?? el).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); return; } } From a431be28bf353fba0a252cdf1670babe68b1c5fb Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:31:52 +0800 Subject: [PATCH 03/14] feat: follow steps land the last message at the top 1/3 of the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More room below for the output to grow — fewer steps, longer uninterrupted streaming before the next jump. (Was 55%.) --- hooks/useAgentSession.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 582d32a5a..9d6a70943 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1823,7 +1823,11 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (lastMsgBottom > container.clientHeight - 40) { const lastMsgAbs = end.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop - 28 - spacerH; - const target = Math.max(0, lastMsgAbs - container.clientHeight * 0.55); + // Land the last message at the top 1/3 of the viewport: more room below + // for the output to grow, so steps are less frequent and the agent has + // longer to stream before the next jump. The bottom 2/3 refills as the + // output grows. + const target = Math.max(0, lastMsgAbs - container.clientHeight / 3); ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; container.scrollTo({ top: target, behavior: "smooth" }); } From b97217c4f2e777d44338fa0fe58d30763f31282f Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:33:06 +0800 Subject: [PATCH 04/14] feat: show 'following on' toast only the first 3 times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-press on 'latest' fires a toast on every enable — noise after the user knows the gesture. Track enable count in localStorage (pi-follow-toast-shown) and only show the toast for the first 3 enables. --- components/ScrollToolbar.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 47448a066..a3977e0d5 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -500,11 +500,23 @@ export function ScrollToolbar({ longPressTimerRef.current = setTimeout(() => { didLongPressRef.current = true; // Toggle: long-press again turns follow OFF. - updateFollowStreaming(!(followStreamingRef.current ?? false)); - setFollowToast(true); + 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 (it fires on every + // long-press). Show it at most 3 times, tracked in + // localStorage. + if (nowOn) { + let shown = 0; + try { shown = parseInt(localStorage.getItem("pi-follow-toast-shown") ?? "0", 10) || 0; } catch { /* ignore */ } + if (shown < 3) { + try { localStorage.setItem("pi-follow-toast-shown", String(shown + 1)); } catch { /* ignore */ } + setFollowToast(true); + } + } if (followToastTimerRef.current) clearTimeout(followToastTimerRef.current); followToastTimerRef.current = setTimeout(() => { setFollowToast(false); From cc7b7307af2ec1e055719bcc3746b1fa5c1c4658 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:35:41 +0800 Subject: [PATCH 05/14] feat: follow hints show only the first few times per page session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'long-press to follow' tooltip and the 'following on' toast are only useful while the user is learning the gesture. Count enables in a plain ref (resets on refresh — no localStorage); after 3 enables in one session the tooltip stops showing entirely and the toast stops appearing. --- components/ScrollToolbar.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index a3977e0d5..3c6c581c5 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -77,6 +77,9 @@ export function ScrollToolbar({ // Brief "auto-scroll follow on" toast after a successful long-press. const [followToast, setFollowToast] = useState(false); const followToastTimerRef = useRef | null>(null); + // Per-page-session counter for the follow hints (toast + tooltip): show them + // the first few times, then stop — but a refresh resets the counter. + const followHintCountRef = useRef(0); const [followStreaming, setFollowStreaming] = useState(false); const updateFollowStreaming = useCallback((v: boolean) => { if (followStreamingRef) followStreamingRef.current = v; @@ -506,16 +509,11 @@ export function ScrollToolbar({ 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 (it fires on every - // long-press). Show it at most 3 times, tracked in - // localStorage. - if (nowOn) { - let shown = 0; - try { shown = parseInt(localStorage.getItem("pi-follow-toast-shown") ?? "0", 10) || 0; } catch { /* ignore */ } - if (shown < 3) { - try { localStorage.setItem("pi-follow-toast-shown", String(shown + 1)); } catch { /* ignore */ } - setFollowToast(true); - } + // times; after that it's noise. Show it at most 3 times per + // page session (a refresh resets the counter). + if (nowOn && followHintCountRef.current < 3) { + followHintCountRef.current += 1; + setFollowToast(true); } if (followToastTimerRef.current) clearTimeout(followToastTimerRef.current); followToastTimerRef.current = setTimeout(() => { @@ -546,7 +544,11 @@ export function ScrollToolbar({ }} aria-label="scrollToLatest" onMouseEnter={(e) => { - setScrollTooltip("latest"); + // The "long-press to follow" hint is only useful while the + // user is learning the gesture; once follow has been + // enabled a few times this session, stop showing ANY + // tooltip for this button. + if (followHintCountRef.current < 3) setScrollTooltip("latest"); e.currentTarget.style.background = followStreaming ? "color-mix(in srgb, var(--accent) 26%, var(--bg-panel))" : "var(--bg-hover)"; From bc85c829e4202f04421a45b47c0f2eab2548f3fb Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:47:10 +0800 Subject: [PATCH 06/14] fix: follow-step scroll animation could pause follow (review fixes) Code review found: - smartFollowCheck's step scrolls 2/3 of the viewport smooth; its animation can outlast the 700ms programmatic-scroll grace window, so the animation tail was treated as a manual scroll and follow paused for 6s mid-stream. Bump the grace window to 1200ms for the step. - stale comments (100px keep-out / 55% landing / clientHeight spacer) now match the 40px keep-out, 1/3 landing, 96px spacer. - stray blank lines in the tap-fallback handlers. --- components/ScrollToolbar.tsx | 7 +++---- hooks/useAgentSession.ts | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 3c6c581c5..5c341ba6b 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -208,8 +208,9 @@ export function ScrollToolbar({ // 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). Keep ~100px of breathing room - // below the last message (sentinel is 28px tall → extra 100-28). + // 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 = @@ -388,7 +389,6 @@ export function ScrollToolbar({ const drag = dragRef.current; if (drag && !drag.moved) { tapNavigatedRef.current = true; - scrollToUserMessage(-1); } }} @@ -436,7 +436,6 @@ export function ScrollToolbar({ const drag = dragRef.current; if (drag && !drag.moved) { tapNavigatedRef.current = true; - scrollToUserMessage(1); } }} diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 9d6a70943..873ad8570 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1815,20 +1815,22 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const endTop = end.getBoundingClientRect().top - container.getBoundingClientRect().top; const spacerH = agentRunningRef.current ? 96 : 0; const lastMsgBottom = endTop - 28 - spacerH; - // Half-viewport step-follow: when new content pushes the last message past - // the small keep-out zone, step so the last message lands at ~55% of the - // viewport height. The growing output refills the lower half before the - // next step — gentler than a full jump-to-bottom every message, and the - // output is always visible. + // 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/3 of the + // viewport. The bottom 2/3 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/3 of the viewport: more room below // for the output to grow, so steps are less frequent and the agent has - // longer to stream before the next jump. The bottom 2/3 refills as the - // output grows. + // longer to stream before the next jump. const target = Math.max(0, lastMsgAbs - container.clientHeight / 3); - ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; + // Longer programmatic-scroll grace than 700ms: the step covers 2/3 of + // the viewport and its smooth animation can exceed the default window, + // which would mark the tail of the animation as a manual scroll and + // pause follow for USER_SCROLL_INTENT_MS. + ignoreProgrammaticScrollUntilRef.current = Date.now() + 1200; container.scrollTo({ top: target, behavior: "smooth" }); } }, [opts.followStreamingRef, scrollContainerRef, messagesEndRef]); From 93dcbfdfb6abc16a497fd44b9277a9ea149c1474 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Mon, 3 Aug 2026 23:53:02 +0800 Subject: [PATCH 07/14] feat: follow steps land instantly at the top 1/4 of the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A streaming chunk re-runs the follow check every few hundred ms; a smooth step animation was cancelled mid-flight by the next chunk's scrollTo, so the step never completed and the view only crept up a little. Instant scrolling lands the full step every time — the last message sits at the top 1/4 of the viewport with 3/4 of blank room below for the output to grow. --- hooks/useAgentSession.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 873ad8570..5e3e7daee 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1816,22 +1816,22 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const spacerH = agentRunningRef.current ? 96 : 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/3 of the - // viewport. The bottom 2/3 refills as the output grows — gentler than a + // 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/3 of the viewport: more room below - // for the output to grow, so steps are less frequent and the agent has - // longer to stream before the next jump. - const target = Math.max(0, lastMsgAbs - container.clientHeight / 3); - // Longer programmatic-scroll grace than 700ms: the step covers 2/3 of - // the viewport and its smooth animation can exceed the default window, - // which would mark the tail of the animation as a manual scroll and - // pause follow for USER_SCROLL_INTENT_MS. + // 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: "smooth" }); + container.scrollTo({ top: target, behavior: "instant" }); } }, [opts.followStreamingRef, scrollContainerRef, messagesEndRef]); From d25e2226666c11fda8c77cfefb74f4b805849907 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:05:22 +0800 Subject: [PATCH 08/14] feat: follow steps land at the top 1/4 with a 3/4-viewport spacer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow step targets the last message at the top 1/4 of the viewport, but the agent-running spacer was only 96px, so there was not enough content below the last message to scroll there — the step clamped at the bottom and looked like a tiny nudge. Restore the spacer to 3/4 of the viewport height so the step lands (last message at top 1/4, 3/4 blank below for the output to grow) and sync spacerH in scrollToBottom / scrollToLatest / smartFollowCheck. --- components/ChatWindow.tsx | 12 ++++++------ components/ScrollToolbar.tsx | 2 +- hooks/useAgentSession.ts | 10 ++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 9a58f5797..15ea55049 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -725,12 +725,12 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate )} {agentRunning && ( - /* Short buffer below the last message while the agent runs. - Was a full viewport tall (clientHeight) — that put a whole - blank screen under the message right after sending (the - message is scrolled to the top). A small spacer keeps the - scroll-lock intent without the white void. */ -
+ /* 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.) */ +
)}
diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 5c341ba6b..cc4059c67 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -215,7 +215,7 @@ export function ScrollToolbar({ if (end) { const endInContainer = end.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop; - const spacerH = agentRunning ? 96 : 0; + 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); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 5e3e7daee..11160cd8c 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1698,7 +1698,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // ~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; - const spacerH = agentRunningRef.current ? 96 : 0; + // 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 }); }, []); @@ -1813,7 +1819,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const end = messagesEndRef.current; if (!container || !end) return; const endTop = end.getBoundingClientRect().top - container.getBoundingClientRect().top; - const spacerH = agentRunningRef.current ? 96 : 0; + 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 From 088612e5b4d673a08cc61970acaf115ecdc27551 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:15:11 +0800 Subject: [PATCH 09/14] fix: latest-button tooltip counts hovers so it stops after a few shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit followHintCountRef was only incremented on long-press enables, so hovering the button popped the 'long-press to follow' tooltip on every scroll-over. Count hovers too — after 3 shows this session the tooltip stops entirely. --- components/ScrollToolbar.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index cc4059c67..943108775 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -544,10 +544,12 @@ export function ScrollToolbar({ aria-label="scrollToLatest" onMouseEnter={(e) => { // The "long-press to follow" hint is only useful while the - // user is learning the gesture; once follow has been - // enabled a few times this session, stop showing ANY - // tooltip for this button. - if (followHintCountRef.current < 3) setScrollTooltip("latest"); + // user is learning the gesture; show it a few times this + // session (hover counts, not just long-press enables — + // otherwise it pops on every scroll over the button), then + // stop showing ANY tooltip for this button. + followHintCountRef.current += 1; + if (followHintCountRef.current <= 3) setScrollTooltip("latest"); e.currentTarget.style.background = followStreaming ? "color-mix(in srgb, var(--accent) 26%, var(--bg-panel))" : "var(--bg-hover)"; From bc0de93f23235fc1c1873a3262b10599e0f643ad Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:21:57 +0800 Subject: [PATCH 10/14] fix: 'long-press to follow' tooltip never shows after first long-press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until the user long-presses once, the tooltip always shows (still learning the gesture). After that it never shows again — the tooltip popping on every toolbar appearance obstructs the view. Drop the show-cycle counter, keep the follow-enabled flag. --- components/ScrollToolbar.tsx | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 943108775..fa2765d35 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -77,9 +77,14 @@ export function ScrollToolbar({ // Brief "auto-scroll follow on" toast after a successful long-press. const [followToast, setFollowToast] = useState(false); const followToastTimerRef = useRef | null>(null); - // Per-page-session counter for the follow hints (toast + tooltip): show them - // the first few times, then stop — but a refresh resets the counter. - const followHintCountRef = useRef(0); + // Per-page-session hint state (a refresh resets it): + // - followEnabledRef: has the user ever long-pressed to enable follow? + // Until then the 'long-press to follow' tooltip always shows (the user is + // still learning the gesture); after the first long-press the tooltip + // never shows again — it just gets in the way. + // - followToastCountRef: how many times the 'following on' toast appeared. + const followEnabledRef = useRef(false); + const followToastCountRef = useRef(0); const [followStreaming, setFollowStreaming] = useState(false); const updateFollowStreaming = useCallback((v: boolean) => { if (followStreamingRef) followStreamingRef.current = v; @@ -510,9 +515,15 @@ export function ScrollToolbar({ // 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 && followHintCountRef.current < 3) { - followHintCountRef.current += 1; - setFollowToast(true); + if (nowOn) { + followEnabledRef.current = true; + // 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(() => { @@ -543,13 +554,11 @@ export function ScrollToolbar({ }} aria-label="scrollToLatest" onMouseEnter={(e) => { - // The "long-press to follow" hint is only useful while the - // user is learning the gesture; show it a few times this - // session (hover counts, not just long-press enables — - // otherwise it pops on every scroll over the button), then - // stop showing ANY tooltip for this button. - followHintCountRef.current += 1; - if (followHintCountRef.current <= 3) setScrollTooltip("latest"); + // The "long-press to follow" hint always shows until the + // user has actually enabled follow at least once; after + // the first long-press it never shows again — it only + // obstructs the view. + if (!followEnabledRef.current) setScrollTooltip("latest"); e.currentTarget.style.background = followStreaming ? "color-mix(in srgb, var(--accent) 26%, var(--bg-panel))" : "var(--bg-hover)"; From 4b2d5ad25498ab6d261a3a959ef214055fd38406 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:26:04 +0800 Subject: [PATCH 11/14] fix: tooltip/toast hints limited to 3 per page session, no long-press reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'long-press to follow' tooltip shows for the first 3 toolbar show→hide cycles of the page session; the 'following on' toast shows at most 3 times. Nothing resets them mid-session — only a page refresh restarts the counters. --- components/ScrollToolbar.tsx | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index fa2765d35..247c684fa 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -77,13 +77,13 @@ export function ScrollToolbar({ // 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 state (a refresh resets it): - // - followEnabledRef: has the user ever long-pressed to enable follow? - // Until then the 'long-press to follow' tooltip always shows (the user is - // still learning the gesture); after the first long-press the tooltip - // never shows again — it just gets in the way. - // - followToastCountRef: how many times the 'following on' toast appeared. - const followEnabledRef = useRef(false); + // 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) => { @@ -516,7 +516,6 @@ export function ScrollToolbar({ // times; after that it's noise. Show it at most 3 times per // page session (a refresh resets the counter). if (nowOn) { - followEnabledRef.current = true; // 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). @@ -554,11 +553,10 @@ export function ScrollToolbar({ }} aria-label="scrollToLatest" onMouseEnter={(e) => { - // The "long-press to follow" hint always shows until the - // user has actually enabled follow at least once; after - // the first long-press it never shows again — it only - // obstructs the view. - if (!followEnabledRef.current) setScrollTooltip("latest"); + // The "long-press to follow" hint shows for the first 3 + // toolbar show → hide cycles of this page session, then + // stops obstructing the view (refresh resets it). + if (toolbarCycleCountRef.current <= 3) setScrollTooltip("latest"); e.currentTarget.style.background = followStreaming ? "color-mix(in srgb, var(--accent) 26%, var(--bg-panel))" : "var(--bg-hover)"; From a6ce2e953143b026dbfd00815a321f752c8a4d6a Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:30:39 +0800 Subject: [PATCH 12/14] feat: hide previous message's action menu when prev/next jumps to another Switching between user messages now hides the previously revealed menu (mouseout) before showing the new target's (mouseover), so only one action menu is ever open after a jump. --- components/ScrollToolbar.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 247c684fa..f22506bd8 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -58,6 +58,9 @@ export function ScrollToolbar({ // 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); @@ -282,7 +285,14 @@ export function ScrollToolbar({ // 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). - (el.firstElementChild ?? el).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + 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 { @@ -303,7 +313,14 @@ export function ScrollToolbar({ 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). - (el.firstElementChild ?? el).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + 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; } } From fa2ce2900b84ecb880e91f5fa64ce77f63156c60 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:35:48 +0800 Subject: [PATCH 13/14] feat: earliest/prev/next tooltips also limited to 3 toolbar cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four scroll-toolbar tooltips now show only during the first 3 toolbar show→hide cycles of the page session, so they teach the buttons up front and stop obstructing the view later (refresh restarts the counters). --- components/ScrollToolbar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index f22506bd8..2aa472945 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -367,7 +367,7 @@ export function ScrollToolbar({ onClick={scrollToEarliest} aria-label="scrollToEarliest" onMouseEnter={(e) => { - setScrollTooltip("earliest"); + if (toolbarCycleCountRef.current <= 3) setScrollTooltip("earliest"); e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} @@ -416,7 +416,7 @@ export function ScrollToolbar({ }} aria-label="scrollToPrevUser" onMouseEnter={(e) => { - setScrollTooltip("prevUser"); + if (toolbarCycleCountRef.current <= 3) setScrollTooltip("prevUser"); e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} @@ -463,7 +463,7 @@ export function ScrollToolbar({ }} aria-label="scrollToNextUser" onMouseEnter={(e) => { - setScrollTooltip("nextUser"); + if (toolbarCycleCountRef.current <= 3) setScrollTooltip("nextUser"); e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} From f28982219727645e92c5d0a979ef152c275595fd Mon Sep 17 00:00:00 2001 From: Wind Li Date: Tue, 4 Aug 2026 00:57:41 +0800 Subject: [PATCH 14/14] fix: toolbar tooltips auto-dismiss after 1.5s (touch taps too) Touch taps leave a sticky hover with no pointer-leave, so a tapped button's tooltip stayed pinned to the toolbar forever. Tooltips now dismiss automatically ~1.5s after showing (mouse leave still dismisses instantly), and the 3-shows-per-page-session cap is unchanged. Drop the pointer:coarse gate so touch still gets the hint. --- components/ScrollToolbar.tsx | 45 +++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/components/ScrollToolbar.tsx b/components/ScrollToolbar.tsx index 2aa472945..5c5c9ded1 100644 --- a/components/ScrollToolbar.tsx +++ b/components/ScrollToolbar.tsx @@ -76,6 +76,23 @@ export function ScrollToolbar({ 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); @@ -367,12 +384,15 @@ export function ScrollToolbar({ onClick={scrollToEarliest} aria-label="scrollToEarliest" onMouseEnter={(e) => { - if (toolbarCycleCountRef.current <= 3) setScrollTooltip("earliest"); + if (tooltipShowCountRef.current < 3) { + tooltipShowCountRef.current += 1; + showScrollTooltip("earliest"); + } e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} onMouseLeave={(e) => { - setScrollTooltip(null); + hideScrollTooltip(); e.currentTarget.style.background = "color-mix(in srgb, var(--bg-panel) 92%, transparent)"; e.currentTarget.style.color = "var(--text-muted)"; }} @@ -416,12 +436,15 @@ export function ScrollToolbar({ }} aria-label="scrollToPrevUser" onMouseEnter={(e) => { - if (toolbarCycleCountRef.current <= 3) setScrollTooltip("prevUser"); + if (tooltipShowCountRef.current < 3) { + tooltipShowCountRef.current += 1; + showScrollTooltip("prevUser"); + } e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} onMouseLeave={(e) => { - setScrollTooltip(null); + hideScrollTooltip(); e.currentTarget.style.background = "color-mix(in srgb, var(--bg-panel) 92%, transparent)"; e.currentTarget.style.color = "var(--text-muted)"; }} @@ -463,12 +486,15 @@ export function ScrollToolbar({ }} aria-label="scrollToNextUser" onMouseEnter={(e) => { - if (toolbarCycleCountRef.current <= 3) setScrollTooltip("nextUser"); + if (tooltipShowCountRef.current < 3) { + tooltipShowCountRef.current += 1; + showScrollTooltip("nextUser"); + } e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} onMouseLeave={(e) => { - setScrollTooltip(null); + hideScrollTooltip(); e.currentTarget.style.background = "color-mix(in srgb, var(--bg-panel) 92%, transparent)"; e.currentTarget.style.color = "var(--text-muted)"; }} @@ -573,14 +599,17 @@ export function ScrollToolbar({ // The "long-press to follow" hint shows for the first 3 // toolbar show → hide cycles of this page session, then // stops obstructing the view (refresh resets it). - if (toolbarCycleCountRef.current <= 3) setScrollTooltip("latest"); + if (tooltipShowCountRef.current < 3) { + tooltipShowCountRef.current += 1; + showScrollTooltip("latest"); + } e.currentTarget.style.background = followStreaming ? "color-mix(in srgb, var(--accent) 26%, var(--bg-panel))" : "var(--bg-hover)"; e.currentTarget.style.color = followStreaming ? "var(--accent)" : "var(--text)"; }} onMouseLeave={(e) => { - setScrollTooltip(null); + hideScrollTooltip(); e.currentTarget.style.background = followStreaming ? "color-mix(in srgb, var(--accent) 18%, var(--bg-panel))" : "color-mix(in srgb, var(--bg-panel) 92%, transparent)";