diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index 49b6bf56d..73e9d7b44 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useRef, useState} from "react"; import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat"; +import {useStickToBottom} from "@/hooks/useStickToBottom"; import {Markdown} from "@/components/Markdown"; import {ToolCall, type ToolCallPair} from "@/components/ToolCall"; import { @@ -382,7 +383,8 @@ export function CortexChatPanel({ } = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt}); const [input, setInput] = useState(""); const [threadListOpen, setThreadListOpen] = useState(false); - const messagesEndRef = useRef(null); + const scrollRef = useRef(null); + const contentRef = useRef(null); const initialPromptSentRef = useRef(false); // Auto-send initial prompt once the fresh thread is ready @@ -399,9 +401,7 @@ export function CortexChatPanel({ } }, [initialPrompt, threadId, isStreaming, messages.length, sendMessage]); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({behavior: "smooth"}); - }, [messages.length, isStreaming, toolActivity.length]); + useStickToBottom(scrollRef, contentRef); const handleSubmit = () => { const trimmed = input.trim(); @@ -469,8 +469,8 @@ export function CortexChatPanel({ )} {/* Messages */} -
-
+
+
{messages.map((message) => (
{message.role === "user" ? ( @@ -513,7 +513,6 @@ export function CortexChatPanel({ {error}
)} -
diff --git a/interface/src/components/portal/PortalTimeline.tsx b/interface/src/components/portal/PortalTimeline.tsx index f0fead486..89ab35ca5 100644 --- a/interface/src/components/portal/PortalTimeline.tsx +++ b/interface/src/components/portal/PortalTimeline.tsx @@ -1,4 +1,5 @@ import {useEffect, useRef} from "react"; +import {useStickToBottom} from "@/hooks/useStickToBottom"; import {useQuery} from "@tanstack/react-query"; import {InlineBranchCard, MessageBubble} from "@spacedrive/ai"; import {File as FileIcon} from "@phosphor-icons/react"; @@ -188,7 +189,7 @@ export function PortalTimeline({ sendCount, }: PortalTimelineProps) { const scrollRef = useRef(null); - const previousLengthRef = useRef(0); + const contentRef = useRef(null); // Fetch workers for this channel to resolve worker_run items. const workersQuery = useQuery({ @@ -211,37 +212,20 @@ export function PortalTimeline({ return workerIds.has(item.id); }); - // Smart auto-scroll: only when near bottom - useEffect(() => { - const element = scrollRef.current; - if (!element) return; - - const previousLength = previousLengthRef.current; - const currentLength = visibleItems.length; - const distanceFromBottom = - element.scrollHeight - element.scrollTop - element.clientHeight; - const isNearBottom = distanceFromBottom < 160; - const shouldAutoScroll = - (currentLength > previousLength || isTyping) && - (previousLength === 0 || isNearBottom); - - if (shouldAutoScroll) { - requestAnimationFrame(() => { - element.scrollTo({top: element.scrollHeight, behavior: "auto"}); - }); - } - - previousLengthRef.current = currentLength; - }, [visibleItems.length, isTyping]); + // Stick to bottom: ResizeObserver catches tool-result expansion, async + // markdown reflow (highlighter, fonts, images), MessageBubble copy-action + // mount, and the streaming text growth uniformly. Preserves scroll-up + // intent. + useStickToBottom(scrollRef, contentRef); - // Always scroll to bottom when the user sends a message. + // Force-pin to bottom when the user sends a message — even if they had + // scrolled up to read history, they expect to see their own message + // land at the bottom. useEffect(() => { if (sendCount === 0) return; - const element = scrollRef.current; - if (!element) return; - requestAnimationFrame(() => { - element.scrollTo({top: element.scrollHeight, behavior: "smooth"}); - }); + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; }, [sendCount]); const copyMessage = async (content: string) => { @@ -250,7 +234,7 @@ export function PortalTimeline({ return (
-
+
{visibleItems.map((item) => { if (item.type === "message") { const attachments = item.attachments ?? []; diff --git a/interface/src/hooks/useStickToBottom.ts b/interface/src/hooks/useStickToBottom.ts new file mode 100644 index 000000000..b9c169708 --- /dev/null +++ b/interface/src/hooks/useStickToBottom.ts @@ -0,0 +1,75 @@ +import {useEffect, useRef, type RefObject} from "react"; + +/** Scroll within this many pixels of the bottom counts as "user is at the + * bottom" — small enough to feel pinned, large enough to forgive sub-pixel + * scroll offsets and momentum overshoot. */ +const NEAR_BOTTOM_PX = 64; + +/** Keeps a scroll container pinned to the bottom of its content while the + * user is already near the bottom; respects scroll-up intent so reading + * history isn't yanked back to bottom by new messages or async layout shifts. + * + * Why a `ResizeObserver` instead of an effect with content deps: tool result + * expansion, async markdown reflow (highlighter, fonts, images), and + * `ThinkingIndicator` toggling all change height without changing the deps + * a normal effect could watch. Observing the content directly catches them + * all uniformly. + * + * Uses `behavior: "auto"`: smooth scroll animations race intervening layout + * shifts and land short, which is the original bug. Auto repaints once, + * then the next observed shift snaps us forward again. */ +export function useStickToBottom( + scrollRef: RefObject, + contentRef: RefObject, +) { + const isPinnedRef = useRef(true); + + useEffect(() => { + const scroll = scrollRef.current; + const content = contentRef.current; + if (!scroll || !content) return; + + const isNearBottom = () => + scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < + NEAR_BOTTOM_PX; + + /** Snap to the bottom now, then again on each of the next two + * animation frames. The follow-up snaps catch growth that happens + * AFTER the current ResizeObserver callback returns: scrollbar + * appearing and narrowing content (extra row of wrap), web-font + * swap, late Markdown layout (images, code blocks), or a sibling + * that mounts a frame later (e.g. a hover-action toolbar). Cheap to + * over-call — `scrollTop = scrollHeight` is a no-op once we're at + * the bottom. */ + const settleToBottom = () => { + scroll.scrollTop = scroll.scrollHeight; + requestAnimationFrame(() => { + if (!isPinnedRef.current) return; + scroll.scrollTop = scroll.scrollHeight; + requestAnimationFrame(() => { + if (!isPinnedRef.current) return; + scroll.scrollTop = scroll.scrollHeight; + }); + }); + }; + + // Land at the bottom on first mount regardless of the initial + // scrollTop value the browser remembered. + settleToBottom(); + + const onScroll = () => { + isPinnedRef.current = isNearBottom(); + }; + scroll.addEventListener("scroll", onScroll, {passive: true}); + + const ro = new ResizeObserver(() => { + if (isPinnedRef.current) settleToBottom(); + }); + ro.observe(content); + + return () => { + scroll.removeEventListener("scroll", onScroll); + ro.disconnect(); + }; + }, [scrollRef, contentRef]); +}