adding space at the end to push the content up when new message

This commit is contained in:
Eva Ho 2025-11-12 22:05:52 -05:00
parent dd63aac6bd
commit def9651b55
3 changed files with 244 additions and 9 deletions

View File

@ -5,10 +5,10 @@ import { DisplayUpgrade } from "./DisplayUpgrade";
import { DisplayStale } from "./DisplayStale";
import { DisplayLogin } from "./DisplayLogin";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "./ai-elements";
ConversationWithSpacer,
} from "./ai-elements/conversation";
import {
useChat,
useSendMessage,
@ -192,10 +192,11 @@ export default function Chat({ chatId }: { chatId: string }) {
</div>
) : (
<main className="flex h-screen w-full flex-col relative allow-context-menu select-none">
<Conversation
key={chatId} // This key forces React to recreate the element when chatId changes
<ConversationWithSpacer
key={chatId}
className={`flex-1 overscroll-contain select-none`}
isStreaming={isStreaming}
messageCount={messages.length}
>
<ConversationContent isStreaming={isStreaming}>
<MessageList
@ -213,7 +214,7 @@ export default function Chat({ chatId }: { chatId: string }) {
/>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</ConversationWithSpacer>
<div className="flex-shrink-0 sticky bottom-0 z-20">
{selectedModel && shouldShowStaleDisplay && (

View File

@ -3,6 +3,7 @@ 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,
@ -94,6 +95,7 @@ export default function MessageList({
<div
key={`${message.created_at}-${idx}`}
data-message-index={idx}
data-message-role={message.role}
ref={isLastMessage ? latestMessageRef : null}
>
<Message
@ -165,6 +167,9 @@ export default function MessageList({
/>
</section>
)}
{/* Dynamic spacer */}
<ConversationSpacer />
</div>
);
}

View File

@ -8,12 +8,15 @@ import {
useRef,
createContext,
useContext,
useState,
} from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
// Create a context to share the "allow scroll" state
// 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> & {
@ -27,6 +30,7 @@ export const Conversation = ({
...props
}: ConversationProps) => {
const shouldStopScrollRef = useRef(true);
const [spacerHeight, setSpacerHeight] = useState(0);
const allowScroll = useCallback(() => {
shouldStopScrollRef.current = false;
@ -36,7 +40,9 @@ export const Conversation = ({
}, []);
return (
<ConversationControlContext.Provider value={{ allowScroll }}>
<ConversationControlContext.Provider
value={{ allowScroll, spacerHeight, setSpacerHeight }}
>
<StickToBottom
className={clsx("relative h-full w-full overflow-y-auto", className)}
initial="instant"
@ -55,6 +61,203 @@ export const Conversation = ({
);
};
// 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,
@ -98,12 +301,36 @@ export type ConversationContentProps = ComponentProps<
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"
/>
);
};
@ -118,13 +345,15 @@ export const ConversationScrollButton = ({
const context = useContext(ConversationControlContext);
const handleScrollToBottom = useCallback(() => {
console.log("scrollToBottom");
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 (
!isAtBottom && (
shouldShowButton && (
<button
className={clsx(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full z-50",