Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions interface/src/components/CortexChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -382,7 +383,8 @@ export function CortexChatPanel({
} = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt});
const [input, setInput] = useState("");
const [threadListOpen, setThreadListOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const initialPromptSentRef = useRef(false);

// Auto-send initial prompt once the fresh thread is ready
Expand All @@ -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();
Expand Down Expand Up @@ -469,8 +469,8 @@ export function CortexChatPanel({
)}

{/* Messages */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-5 p-3 pb-4">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div ref={contentRef} className="flex flex-col gap-5 p-3 pb-4">
{messages.map((message) => (
<div key={message.id}>
{message.role === "user" ? (
Expand Down Expand Up @@ -513,7 +513,6 @@ export function CortexChatPanel({
{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>

Expand Down
59 changes: 59 additions & 0 deletions interface/src/hooks/useStickToBottom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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<HTMLElement | null>,
contentRef: RefObject<HTMLElement | null>,
) {
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;

const scrollToBottom = () => {
scroll.scrollTop = scroll.scrollHeight;
};

// Land at the bottom on first mount regardless of the initial
// scrollTop value the browser remembered.
scrollToBottom();

const onScroll = () => {
isPinnedRef.current = isNearBottom();
};
scroll.addEventListener("scroll", onScroll, {passive: true});

const ro = new ResizeObserver(() => {
if (isPinnedRef.current) scrollToBottom();
});
ro.observe(content);

return () => {
scroll.removeEventListener("scroll", onScroll);
ro.disconnect();
};
}, [scrollRef, contentRef]);
}