Compare commits

...

3 Commits

Author SHA1 Message Date
Eva Ho
def9651b55 adding space at the end to push the content up when new message 2025-11-12 22:05:52 -05:00
Eva Ho
dd63aac6bd no auto scrolling when streaming 2025-11-12 18:19:39 -05:00
Eva Ho
78830f6e9d wip 2025-11-12 14:20:46 -05:00
8 changed files with 453 additions and 415 deletions

View File

@@ -27,7 +27,8 @@
"remark-math": "^6.0.0",
"streamdown": "^1.4.0",
"unist-builder": "^4.0.0",
"unist-util-parents": "^3.0.0"
"unist-util-parents": "^3.0.0",
"use-stick-to-bottom": "^1.1.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
@@ -12739,6 +12740,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-stick-to-bottom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.1.tgz",
"integrity": "sha512-JkDp0b0tSmv7HQOOpL1hT7t7QaoUBXkq045WWWOFDTlLGRzgIIyW7vyzOIJzY7L2XVIG7j1yUxeDj2LHm9Vwng==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",

View File

@@ -36,7 +36,8 @@
"remark-math": "^6.0.0",
"streamdown": "^1.4.0",
"unist-builder": "^4.0.0",
"unist-util-parents": "^3.0.0"
"unist-util-parents": "^3.0.0",
"use-stick-to-bottom": "^1.1.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",

View File

@@ -4,6 +4,11 @@ import { FileUpload } from "./FileUpload";
import { DisplayUpgrade } from "./DisplayUpgrade";
import { DisplayStale } from "./DisplayStale";
import { DisplayLogin } from "./DisplayLogin";
import {
ConversationContent,
ConversationScrollButton,
ConversationWithSpacer,
} from "./ai-elements/conversation";
import {
useChat,
useSendMessage,
@@ -15,14 +20,7 @@ import {
useDismissStaleModel,
} from "@/hooks/useChats";
import { useHealth } from "@/hooks/useHealth";
import { useMessageAutoscroll } from "@/hooks/useMessageAutoscroll";
import {
useState,
useEffect,
useLayoutEffect,
useRef,
useCallback,
} from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useSelectedModel } from "@/hooks/useSelectedModel";
@@ -47,7 +45,6 @@ export default function Chat({ chatId }: { chatId: string }) {
index: number;
originalMessage: Message;
} | null>(null);
const prevChatIdRef = useRef<string>(chatId);
const chatFormCallbackRef = useRef<
| ((
@@ -102,29 +99,8 @@ export default function Chat({ chatId }: { chatId: string }) {
const sendMessageMutation = useSendMessage(chatId);
const { containerRef, handleNewUserMessage, spacerHeight } =
useMessageAutoscroll({
messages,
isStreaming,
chatId,
});
const latestMessageRef = useRef<HTMLDivElement>(null);
// Scroll to bottom only when switching to a different existing chat
useLayoutEffect(() => {
// Only scroll if the chatId actually changed (not just messages updating)
if (
prevChatIdRef.current !== chatId &&
containerRef.current &&
messages.length > 0 &&
chatId !== "new"
) {
// Always scroll to the bottom when opening a chat
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
prevChatIdRef.current = chatId;
}, [chatId, messages.length]);
// Simplified submit handler - ChatForm handles all the attachment logic
const handleChatFormSubmit = (
message: string,
options: {
@@ -168,7 +144,6 @@ export default function Chat({ chatId }: { chatId: string }) {
// Clear edit mode after submission
setEditingMessage(null);
handleNewUserMessage();
};
const handleEditMessage = (content: string, index: number) => {
@@ -193,8 +168,6 @@ export default function Chat({ chatId }: { chatId: string }) {
);
};
const isWindows = navigator.platform.toLowerCase().includes("win");
return chatId === "new" || chatQuery ? (
<FileUpload
onFilesAdded={handleFilesProcessed}
@@ -219,25 +192,29 @@ export default function Chat({ chatId }: { chatId: string }) {
</div>
) : (
<main className="flex h-screen w-full flex-col relative allow-context-menu select-none">
<section
key={chatId} // This key forces React to recreate the element when chatId changes
ref={containerRef}
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 select-none ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
<ConversationWithSpacer
key={chatId}
className={`flex-1 overscroll-contain select-none`}
isStreaming={isStreaming}
messageCount={messages.length}
>
<MessageList
messages={messages}
spacerHeight={spacerHeight}
isWaitingForLoad={isWaitingForLoad}
isStreaming={isStreaming}
downloadProgress={downloadProgress}
onEditMessage={(content: string, index: number) => {
handleEditMessage(content, index);
}}
editingMessageIndex={editingMessage?.index}
error={chatError}
browserToolResult={browserToolResult}
/>
</section>
<ConversationContent isStreaming={isStreaming}>
<MessageList
messages={messages}
isWaitingForLoad={isWaitingForLoad}
isStreaming={isStreaming}
downloadProgress={downloadProgress}
onEditMessage={(content: string, index: number) => {
handleEditMessage(content, index);
}}
editingMessageIndex={editingMessage?.index}
error={chatError}
browserToolResult={browserToolResult}
latestMessageRef={latestMessageRef}
/>
</ConversationContent>
<ConversationScrollButton />
</ConversationWithSpacer>
<div className="flex-shrink-0 sticky bottom-0 z-20">
{selectedModel && shouldShowStaleDisplay && (
@@ -248,14 +225,6 @@ export default function Chat({ chatId }: { chatId: string }) {
dismissStaleModel(selectedModel?.model || "")
}
chatId={chatId}
onScrollToBottom={() => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "smooth",
});
}
}}
/>
</div>
)}

View File

@@ -3,10 +3,10 @@ import React from "react";
import Message from "./Message";
import Downloading from "./Downloading";
import { ErrorMessage } from "./ErrorMessage";
import { ConversationSpacer } from "./ai-elements/conversation";
export default function MessageList({
messages,
spacerHeight,
isWaitingForLoad,
isStreaming,
downloadProgress,
@@ -14,9 +14,9 @@ export default function MessageList({
editingMessageIndex,
error,
browserToolResult,
latestMessageRef,
}: {
messages: MessageType[];
spacerHeight: number;
isWaitingForLoad?: boolean;
isStreaming: boolean;
downloadProgress?: DownloadEvent;
@@ -24,6 +24,7 @@ export default function MessageList({
editingMessageIndex?: number;
error?: ErrorEvent | null;
browserToolResult?: any;
latestMessageRef?: React.RefObject<HTMLDivElement | null>;
}) {
const [showDots, setShowDots] = React.useState(false);
const isDownloadingModel = downloadProgress && !downloadProgress.done;
@@ -84,13 +85,19 @@ export default function MessageList({
return (
<div
className="mx-auto flex max-w-[768px] flex-1 flex-col px-6 pb-12 select-text"
className="mx-auto flex max-w-[768px] w-full flex-1 flex-col px-6 py-12 select-text"
data-role="message-list"
>
{messages.map((message, idx) => {
const lastToolQuery = lastToolQueries[idx];
const isLastMessage = idx === messages.length - 1;
return (
<div key={`${message.created_at}-${idx}`} data-message-index={idx}>
<div
key={`${message.created_at}-${idx}`}
data-message-index={idx}
data-message-role={message.role}
ref={isLastMessage ? latestMessageRef : null}
>
<Message
message={message}
onEditMessage={onEditMessage}
@@ -161,8 +168,8 @@ export default function MessageList({
</section>
)}
{/* Dynamic spacer to allow scrolling the last message to the top of the container */}
<div style={{ height: `${spacerHeight}px` }} aria-hidden="true" />
{/* Dynamic spacer */}
<ConversationSpacer />
</div>
);
}

View File

@@ -0,0 +1,385 @@
"use client";
import clsx from "clsx";
import type { ComponentProps } from "react";
import {
useCallback,
useEffect,
useRef,
createContext,
useContext,
useState,
} from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
// Create a context to share the "allow scroll" state and spacer state
const ConversationControlContext = createContext<{
allowScroll: () => void;
spacerHeight: number;
setSpacerHeight: (height: number) => void;
} | null>(null);
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
isStreaming?: boolean;
};
export const Conversation = ({
className,
isStreaming = false,
children,
...props
}: ConversationProps) => {
const shouldStopScrollRef = useRef(true);
const [spacerHeight, setSpacerHeight] = useState(0);
const allowScroll = useCallback(() => {
shouldStopScrollRef.current = false;
setTimeout(() => {
shouldStopScrollRef.current = true;
}, 100);
}, []);
return (
<ConversationControlContext.Provider
value={{ allowScroll, spacerHeight, setSpacerHeight }}
>
<StickToBottom
className={clsx("relative h-full w-full overflow-y-auto", className)}
initial="instant"
resize="instant"
role="log"
{...props}
>
<ConversationContentInternal
isStreaming={isStreaming}
shouldStopScrollRef={shouldStopScrollRef}
>
<>{children}</>
</ConversationContentInternal>
</StickToBottom>
</ConversationControlContext.Provider>
);
};
// New wrapper component that includes spacer management
export const ConversationWithSpacer = ({
isStreaming,
messageCount,
children,
...props
}: ConversationProps & {
messageCount: number;
}) => {
return (
<Conversation isStreaming={isStreaming} {...props}>
<SpacerController
isStreaming={isStreaming ?? false}
messageCount={messageCount}
/>
<>{children}</>
</Conversation>
);
};
// This component manages the spacer state but doesn't render the spacer itself
const SpacerController = ({
isStreaming,
messageCount,
}: {
isStreaming: boolean;
messageCount: number;
}) => {
const context = useContext(ConversationControlContext);
const { scrollToBottom } = useStickToBottomContext();
const previousMessageCountRef = useRef(messageCount);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const [isActiveInteraction, setIsActiveInteraction] = useState(false);
// Get reference to scroll container
useEffect(() => {
const container = document.querySelector('[role="log"]') as HTMLElement;
scrollContainerRef.current = container;
}, []);
// Calculate spacer height based on actual DOM elements
const calculateSpacerHeight = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
console.log("❌ No container");
return 0;
}
const containerHeight = container.clientHeight;
// Find all message elements
const messageElements = container.querySelectorAll(
"[data-message-index]",
) as NodeListOf<HTMLElement>;
console.log("📝 Found", messageElements.length, "message elements");
if (messageElements.length === 0) return 0;
// Log all messages and their roles
messageElements.forEach((el, i) => {
const role = el.getAttribute("data-message-role");
console.log(` Message ${i}: role="${role}"`);
});
// Find the last user message
let lastUserMessageElement: HTMLElement | null = null;
let lastUserMessageIndex = -1;
for (let i = messageElements.length - 1; i >= 0; i--) {
const el = messageElements[i];
const role = el.getAttribute("data-message-role");
if (role === "user") {
lastUserMessageElement = el;
lastUserMessageIndex = i;
console.log("✅ Found user message at index:", i);
break;
}
}
if (!lastUserMessageElement) {
console.log("❌ No user message found!");
return 0;
}
// Calculate height of content after the last user message
let contentHeightAfter = 0;
for (let i = lastUserMessageIndex + 1; i < messageElements.length; i++) {
contentHeightAfter += messageElements[i].offsetHeight;
}
const userMessageHeight = lastUserMessageElement.offsetHeight;
// Goal: Position user message at the top with some padding
// We want the user message to start at around 10% from the top of viewport
const targetTopPosition = containerHeight * 0.05; // 10% from top
// Calculate spacer: we need enough space so that when scrolled to bottom:
// spacerHeight = containerHeight - targetTopPosition - userMessageHeight - contentAfter
const calculatedHeight =
containerHeight -
targetTopPosition -
userMessageHeight -
contentHeightAfter;
const baseHeight = Math.max(0, calculatedHeight);
console.log(
"📊 Container:",
containerHeight,
"User msg:",
userMessageHeight,
"Content after:",
contentHeightAfter,
"Target top pos:",
targetTopPosition,
"→ Final spacer:",
baseHeight,
);
return baseHeight;
}, []);
// When a new message is submitted, set initial spacer height and scroll
useEffect(() => {
if (messageCount > previousMessageCountRef.current) {
console.log("🎯 NEW MESSAGE - Setting spacer");
// Allow scrolling by temporarily disabling stopScroll
context?.allowScroll();
setIsActiveInteraction(true);
const container = scrollContainerRef.current;
if (container) {
// Wait for new message to render in DOM
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const spacerHeight = calculateSpacerHeight();
console.log("📏 Calculated spacer:", spacerHeight);
context?.setSpacerHeight(spacerHeight);
// Wait for spacer to be added to DOM, then scroll
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Use the library's scrollToBottom method
console.log("📜 Scrolling to bottom");
scrollToBottom("instant");
console.log("📜 Final scrollTop:", container.scrollTop);
});
});
});
});
}
}
previousMessageCountRef.current = messageCount;
}, [messageCount, context, calculateSpacerHeight, scrollToBottom]);
// Update active interaction state
useEffect(() => {
if (isStreaming) {
setIsActiveInteraction(true);
}
// Don't automatically set to false when streaming stops
// Let the ResizeObserver handle clearing the spacer naturally
}, [isStreaming]);
// Use ResizeObserver to recalculate spacer as content changes
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || !isActiveInteraction) return;
const resizeObserver = new ResizeObserver(() => {
const newHeight = calculateSpacerHeight();
context?.setSpacerHeight(newHeight);
// Clear active interaction when spacer reaches 0
if (newHeight === 0) {
setIsActiveInteraction(false);
}
});
// Observe all message elements
const messageElements = container.querySelectorAll("[data-message-index]");
messageElements.forEach((element) => {
resizeObserver.observe(element);
});
return () => {
resizeObserver.disconnect();
};
}, [isActiveInteraction, calculateSpacerHeight, context]);
// Remove the effect that clears spacer when not streaming
// This was causing the spacer to disappear prematurely
return null;
};
const ConversationContentInternal = ({
isStreaming,
shouldStopScrollRef,
children,
}: {
isStreaming: boolean;
shouldStopScrollRef: React.MutableRefObject<boolean>;
children: React.ReactNode;
}) => {
const { stopScroll } = useStickToBottomContext();
const stopScrollRef = useRef(stopScroll);
useEffect(() => {
stopScrollRef.current = stopScroll;
}, [stopScroll]);
useEffect(() => {
if (!isStreaming) return;
const interval = setInterval(() => {
if (shouldStopScrollRef.current) {
stopScrollRef.current();
}
}, 16);
if (shouldStopScrollRef.current) {
stopScrollRef.current();
}
return () => clearInterval(interval);
}, [isStreaming, shouldStopScrollRef]);
return <>{children}</>;
};
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
> & {
isStreaming?: boolean;
};
export const ConversationContent = ({
className,
children,
...props
}: ConversationContentProps) => {
return (
<StickToBottom.Content
className={clsx("flex flex-col", className)}
{...props}
>
{children}
</StickToBottom.Content>
);
};
// Spacer component that can be placed anywhere in your content
export const ConversationSpacer = () => {
const context = useContext(ConversationControlContext);
const spacerHeight = context?.spacerHeight ?? 0;
console.log("🎨 Spacer render - height:", spacerHeight);
if (spacerHeight === 0) return null;
return (
<div
style={{
height: `${spacerHeight}px`,
flexShrink: 0,
backgroundColor: "rgba(255,0,0,0.1)", // Temporary for debugging
}}
aria-hidden="true"
/>
);
};
export type ConversationScrollButtonProps = ComponentProps<"button">;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const context = useContext(ConversationControlContext);
const handleScrollToBottom = useCallback(() => {
context?.allowScroll();
scrollToBottom();
}, [scrollToBottom, context]);
// Show button if not at bottom AND spacer is not active (height is 0)
const shouldShowButton = !isAtBottom && (context?.spacerHeight ?? 0) === 0;
return (
shouldShowButton && (
<button
className={clsx(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full z-50",
"bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700",
"p-1 shadow-lg hover:shadow-xl transition-all",
"text-neutral-700 dark:text-neutral-200 hover:scale-105",
"hover:cursor-pointer",
className,
)}
onClick={handleScrollToBottom}
type="button"
aria-label="Scroll to bottom"
{...props}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)
);
};

View File

@@ -0,0 +1,8 @@
export {
Conversation,
ConversationContent,
ConversationScrollButton,
type ConversationProps,
type ConversationContentProps,
type ConversationScrollButtonProps,
} from "./conversation";

View File

@@ -1,347 +0,0 @@
import {
useRef,
useCallback,
useEffect,
useLayoutEffect,
useState,
useMemo,
} from "react";
import type { Message } from "@/gotypes";
// warning: this file is all claude code, needs to be looked into more closely
interface UseMessageAutoscrollOptions {
messages: Message[];
isStreaming: boolean;
chatId: string;
}
interface MessageAutoscrollBehavior {
handleNewUserMessage: () => void;
containerRef: React.RefObject<HTMLElement | null>;
spacerHeight: number;
}
export const useMessageAutoscroll = ({
messages,
isStreaming,
chatId,
}: UseMessageAutoscrollOptions): MessageAutoscrollBehavior => {
const containerRef = useRef<HTMLElement | null>(null);
const pendingScrollToUserMessage = useRef(false);
const [spacerHeight, setSpacerHeight] = useState(0);
const lastScrollHeightRef = useRef(0);
const lastScrollTopRef = useRef(0);
const [isActiveInteraction, setIsActiveInteraction] = useState(false);
const [hasSubmittedMessage, setHasSubmittedMessage] = useState(false);
const prevChatIdRef = useRef<string>(chatId);
// Find the last user message index from React state
const getLastUserMessageIndex = useCallback(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
return i;
}
}
return -1;
}, [messages]);
const scrollToMessage = useCallback((messageIndex: number) => {
if (!containerRef.current || messageIndex < 0) {
return;
}
const container = containerRef.current;
// select the exact element by its data-message-index to avoid index mismatches
const targetElement = container.querySelector(
`[data-message-index="${messageIndex}"]`,
) as HTMLElement | null;
if (!targetElement) return;
const containerHeight = container.clientHeight;
const containerStyle = window.getComputedStyle(container);
const paddingTop = parseFloat(containerStyle.paddingTop) || 0;
const scrollHeight = container.scrollHeight;
const messageHeight = targetElement.offsetHeight;
// Check if the message is large, which is 70% of the container height
const isLarge = messageHeight > containerHeight * 0.7;
let targetPosition: number = targetElement.offsetTop - paddingTop; // default to scrolling the message to the top of the window
if (isLarge) {
// when the message is large scroll to the bottom of it
targetPosition = scrollHeight - containerHeight;
}
// Ensure we don't scroll past content boundaries
const maxScroll = scrollHeight - containerHeight;
const finalPosition = Math.min(Math.max(0, targetPosition), maxScroll);
container.scrollTo({
top: finalPosition,
behavior: "smooth",
});
}, []);
// Calculate and set the spacer height based on container dimensions
const updateSpacerHeight = useCallback(() => {
if (!containerRef.current) {
return;
}
const containerHeight = containerRef.current.clientHeight;
// Find the last user message to calculate spacer for
const lastUserIndex = getLastUserMessageIndex();
if (lastUserIndex < 0) {
setSpacerHeight(0);
return;
}
const messageElements = containerRef.current.querySelectorAll(
"[data-message-index]",
) as NodeListOf<HTMLElement>;
if (!messageElements || messageElements.length === 0) {
setSpacerHeight(0);
return;
}
const targetElement = containerRef.current.querySelector(
`[data-message-index="${lastUserIndex}"]`,
) as HTMLElement | null;
if (!targetElement) {
setSpacerHeight(0);
return;
}
const elementsAfter = Array.from(messageElements).filter((el) => {
const idx = Number(el.dataset.messageIndex);
return Number.isFinite(idx) && idx > lastUserIndex;
});
const contentHeightAfterTarget = elementsAfter.reduce(
(sum, el) => sum + el.offsetHeight,
0,
);
// Calculate the spacer height needed to position the user message at the top
// Add extra space for assistant response area
const targetMessageHeight = targetElement.offsetHeight;
// Calculate spacer to position the last user message at the top
// For new messages, we want them to appear at the top regardless of content after
// For large messages, we want to preserve the scroll-to-bottom behavior
// which shows part of the message and space for streaming response
let baseHeight: number;
if (contentHeightAfterTarget === 0) {
// No content after the user message (new message case)
// Position it at the top with some padding
baseHeight = Math.max(0, containerHeight - targetMessageHeight);
} else {
// Content exists after the user message
// Calculate spacer to position user message at top
baseHeight = Math.max(
0,
containerHeight - contentHeightAfterTarget - targetMessageHeight,
);
}
// Only apply spacer height when actively interacting (streaming or pending new message)
// When just viewing a chat, don't add extra space
if (!isActiveInteraction) {
setSpacerHeight(0);
return;
}
// Add extra space for assistant response only when streaming
const extraSpaceForAssistant = isStreaming ? containerHeight * 0.4 : 0;
const calculatedHeight = baseHeight + extraSpaceForAssistant;
setSpacerHeight(calculatedHeight);
}, [getLastUserMessageIndex, isStreaming, isActiveInteraction]);
// Handle new user message submission
const handleNewUserMessage = useCallback(() => {
// Mark that we're expecting a new message and should scroll to it
pendingScrollToUserMessage.current = true;
setIsActiveInteraction(true);
setHasSubmittedMessage(true);
}, []);
// Use layoutEffect to scroll immediately after DOM updates
useLayoutEffect(() => {
if (pendingScrollToUserMessage.current) {
// Find the last user message from current state
const targetUserIndex = getLastUserMessageIndex();
if (targetUserIndex >= 0) {
requestAnimationFrame(() => {
updateSpacerHeight();
requestAnimationFrame(() => {
scrollToMessage(targetUserIndex);
pendingScrollToUserMessage.current = false;
});
});
} else {
pendingScrollToUserMessage.current = false;
// Reset active interaction if no target found
setIsActiveInteraction(isStreaming);
}
}
}, [
messages,
getLastUserMessageIndex,
scrollToMessage,
updateSpacerHeight,
isStreaming,
]);
// Update active interaction state based on streaming and message submission
useEffect(() => {
if (
isStreaming ||
pendingScrollToUserMessage.current ||
hasSubmittedMessage
) {
setIsActiveInteraction(true);
} else {
setIsActiveInteraction(false);
}
}, [isStreaming, hasSubmittedMessage]);
useEffect(() => {
if (prevChatIdRef.current !== chatId) {
setIsActiveInteraction(false);
setHasSubmittedMessage(false);
prevChatIdRef.current = chatId;
}
}, [chatId]);
// Recalculate spacer height when messages change
useEffect(() => {
updateSpacerHeight();
}, [messages, updateSpacerHeight]);
// Use ResizeObserver to handle dynamic content changes
useEffect(() => {
if (!containerRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout>;
let immediateUpdate = false;
const resizeObserver = new ResizeObserver((entries) => {
// Check if this is a significant height change (like collapsing content)
let hasSignificantChange = false;
for (const entry of entries) {
const element = entry.target as HTMLElement;
if (
element.dataset.messageIndex &&
entry.contentRect.height !== element.offsetHeight
) {
const heightDiff = Math.abs(
entry.contentRect.height - element.offsetHeight,
);
if (heightDiff > 50) {
hasSignificantChange = true;
break;
}
}
}
// For significant changes, update immediately
if (hasSignificantChange || immediateUpdate) {
updateSpacerHeight();
immediateUpdate = false;
} else {
// For small changes (like streaming text), debounce
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
updateSpacerHeight();
}, 100);
}
});
// Also use MutationObserver for immediate attribute changes
const mutationObserver = new MutationObserver((mutations) => {
// Check if any mutations are related to expanding/collapsing
const hasToggle = mutations.some(
(mutation) =>
mutation.type === "attributes" &&
(mutation.attributeName === "class" ||
mutation.attributeName === "style" ||
mutation.attributeName === "open" ||
mutation.attributeName === "data-expanded"),
);
if (hasToggle) {
immediateUpdate = true;
updateSpacerHeight();
}
});
// Observe the container and all messages
resizeObserver.observe(containerRef.current);
mutationObserver.observe(containerRef.current, {
attributes: true,
subtree: true,
attributeFilter: ["class", "style", "open", "data-expanded"],
});
// Observe all message elements for size changes
const messageElements = containerRef.current.querySelectorAll(
"[data-message-index]",
);
messageElements.forEach((element) => {
resizeObserver.observe(element);
});
return () => {
clearTimeout(resizeTimeout);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [messages, updateSpacerHeight]);
// Track scroll position
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const handleScroll = () => {
lastScrollTopRef.current = container.scrollTop;
lastScrollHeightRef.current = container.scrollHeight;
};
container.addEventListener("scroll", handleScroll);
// Initialize scroll tracking
lastScrollTopRef.current = container.scrollTop;
lastScrollHeightRef.current = container.scrollHeight;
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
pendingScrollToUserMessage.current = false;
};
}, []);
return useMemo(
() => ({
handleNewUserMessage,
containerRef,
spacerHeight,
}),
[handleNewUserMessage, containerRef, spacerHeight],
);
};

View File

@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}