adding space at the end to push the content up when new message
This commit is contained in:
parent
dd63aac6bd
commit
def9651b55
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue