This commit is contained in:
Eva Ho 2025-11-11 11:56:50 -05:00
parent 8224cd9063
commit 78830f6e9d
8 changed files with 205 additions and 412 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 {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "./ai-elements";
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,27 +99,40 @@ export default function Chat({ chatId }: { chatId: string }) {
const sendMessageMutation = useSendMessage(chatId);
const { containerRef, handleNewUserMessage, spacerHeight } =
useMessageAutoscroll({
messages,
isStreaming,
chatId,
});
const latestMessageRef = useRef<HTMLDivElement>(null);
const prevMessageCountRef = useRef(messages.length);
// 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;
// Scroll to latest message when messages change
useEffect(() => {
// Only scroll if a new message was actually added (not just re-render)
if (messages.length > prevMessageCountRef.current) {
if (latestMessageRef.current) {
// Find the scrollable parent container
let scrollContainer = latestMessageRef.current.parentElement;
while (scrollContainer) {
const overflowY = window.getComputedStyle(scrollContainer).overflowY;
if (overflowY === "auto" || overflowY === "scroll") {
break;
}
scrollContainer = scrollContainer.parentElement;
}
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
const targetRect = latestMessageRef.current.getBoundingClientRect();
const scrollAmount =
targetRect.top - containerRect.top + scrollContainer.scrollTop;
scrollContainer.scrollTo({
top: scrollAmount,
behavior: "smooth",
});
}
}
}
prevChatIdRef.current = chatId;
}, [chatId, messages.length]);
prevMessageCountRef.current = messages.length;
}, [messages.length, latestMessageRef]);
// Simplified submit handler - ChatForm handles all the attachment logic
const handleChatFormSubmit = (
@ -168,7 +178,6 @@ export default function Chat({ chatId }: { chatId: string }) {
// Clear edit mode after submission
setEditingMessage(null);
handleNewUserMessage();
};
const handleEditMessage = (content: string, index: number) => {
@ -193,8 +202,6 @@ export default function Chat({ chatId }: { chatId: string }) {
);
};
const isWindows = navigator.platform.toLowerCase().includes("win");
return chatId === "new" || chatQuery ? (
<FileUpload
onFilesAdded={handleFilesProcessed}
@ -219,25 +226,27 @@ export default function Chat({ chatId }: { chatId: string }) {
</div>
) : (
<main className="flex h-screen w-full flex-col relative allow-context-menu select-none">
<section
<Conversation
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"}`}
className={`flex-1 overscroll-contain select-none`}
>
<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>
<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 />
</Conversation>
<div className="flex-shrink-0 sticky bottom-0 z-20">
{selectedModel && shouldShowStaleDisplay && (
@ -248,14 +257,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

@ -6,7 +6,6 @@ import { ErrorMessage } from "./ErrorMessage";
export default function MessageList({
messages,
spacerHeight,
isWaitingForLoad,
isStreaming,
downloadProgress,
@ -14,9 +13,9 @@ export default function MessageList({
editingMessageIndex,
error,
browserToolResult,
latestMessageRef,
}: {
messages: MessageType[];
spacerHeight: number;
isWaitingForLoad?: boolean;
isStreaming: boolean;
downloadProgress?: DownloadEvent;
@ -24,6 +23,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 +84,18 @@ 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}
ref={isLastMessage ? latestMessageRef : null}
>
<Message
message={message}
onEditMessage={onEditMessage}
@ -160,9 +165,6 @@ 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" />
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import clsx from "clsx";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={clsx("relative h-full w-full overflow-y-auto", className)}
initial="instant"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={clsx("flex flex-col", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={clsx(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<"button">;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<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-3 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,10 @@
export {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
type ConversationProps,
type ConversationContentProps,
type ConversationEmptyStateProps,
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);
}