Skip to content
Merged
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
33 changes: 30 additions & 3 deletions components/frontend/src/components/session/MessagesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import React, { useState, useRef, useEffect, useMemo, useLayoutEffect, useCallback } from "react";
import { MessageSquare } from "lucide-react";
import { MessageSquare, ChevronUp } from "lucide-react";
import { StreamMessage } from "@/components/ui/stream-message";
import { LoadingDots } from "@/components/ui/message";
import { Button } from "@/components/ui/button";
Expand All @@ -15,6 +15,9 @@ import type { QueuedMessageItem } from "@/hooks/use-session-queue";
/** Maximum number of messages rendered at once. Older messages are loaded on demand. */
const MAX_VISIBLE_MESSAGES = 100;

/** Scroll distance (px) from the top before the scroll-to-top button appears. */
const SCROLL_TO_TOP_THRESHOLD = 300;

/** Derive a stable React key for any message variant. */
function getMessageKey(m: MessageObject | ToolUseMessages | HierarchicalToolMessage, idx: number): string {
if ('id' in m && m.id) return m.id;
Expand Down Expand Up @@ -66,6 +69,7 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat

const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [showScrollToTop, setShowScrollToTop] = useState(false);
// How many messages (counting from the end) are currently rendered.
const [loadedMessageCount, setLoadedMessageCount] = useState(MAX_VISIBLE_MESSAGES);
// Refs for scroll-position preservation when loading earlier messages.
Expand Down Expand Up @@ -100,9 +104,11 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
};

const handleScroll = () => {
const container = messagesContainerRef.current;
const bottom = checkIfAtBottom();
// Avoid a re-render when the value hasn't changed.
setIsAtBottom((prev) => (prev === bottom ? prev : bottom));
const scrolledPastThreshold = !!container && container.scrollTop > SCROLL_TO_TOP_THRESHOLD;
setShowScrollToTop((prev) => (prev === scrolledPastThreshold ? prev : scrolledPastThreshold));
};

const scrollToBottom = () => {
Expand All @@ -112,6 +118,13 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
}
};

const scrollToTop = useCallback(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({ top: 0, behavior: "smooth" });
}
}, []);

// Load earlier messages and preserve the user's visual scroll position.
const loadEarlierMessages = useCallback(() => {
const container = messagesContainerRef.current;
Expand Down Expand Up @@ -193,10 +206,11 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat

return (
<div className="flex flex-col h-full">
<div className="relative flex-1 min-h-0">
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="flex-1 flex flex-col gap-2 overflow-y-auto p-3 scrollbar-thin"
className="h-full flex flex-col gap-2 overflow-y-auto p-3 scrollbar-thin"
>
{showWelcomeExperience && welcomeExperienceComponent}

Expand Down Expand Up @@ -273,6 +287,19 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
)}
</div>

{showScrollToTop && (
<Button
variant="outline"
size="icon-sm"
onClick={scrollToTop}
aria-label="Scroll to top"
className="absolute bottom-3 right-5 z-10 rounded-full shadow-md transition-opacity duration-200"
>
<ChevronUp className="h-4 w-4" />
</Button>
)}
</div>

<ChatInputBox
value={chatInput}
onChange={setChatInput}
Expand Down
Loading