Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
44 changes: 14 additions & 30 deletions interface/src/components/portal/PortalTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -188,7 +189,7 @@ export function PortalTimeline({
sendCount,
}: PortalTimelineProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const previousLengthRef = useRef(0);
const contentRef = useRef<HTMLDivElement>(null);

// Fetch workers for this channel to resolve worker_run items.
const workersQuery = useQuery({
Expand All @@ -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) => {
Expand All @@ -250,7 +234,7 @@ export function PortalTimeline({

return (
<div ref={scrollRef} className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-2 px-4 py-6 pb-[180px]">
<div ref={contentRef} className="mx-auto flex max-w-3xl flex-col gap-2 px-4 py-6 pb-[180px]">
{visibleItems.map((item) => {
if (item.type === "message") {
const attachments = item.attachments ?? [];
Expand Down
75 changes: 75 additions & 0 deletions interface/src/hooks/useStickToBottom.ts
Original file line number Diff line number Diff line change
@@ -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<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;

/** 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]);
}