mirror of
https://github.com/ollama/ollama.git
synced 2026-01-02 12:38:15 -05:00
Compare commits
3 Commits
implement-
...
hoyyeva/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def9651b55 | ||
|
|
dd63aac6bd | ||
|
|
78830f6e9d |
12
app/ui/app/package-lock.json
generated
12
app/ui/app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
385
app/ui/app/src/components/ai-elements/conversation.tsx
Normal file
385
app/ui/app/src/components/ai-elements/conversation.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
||||
8
app/ui/app/src/components/ai-elements/index.ts
Normal file
8
app/ui/app/src/components/ai-elements/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
type ConversationProps,
|
||||
type ConversationContentProps,
|
||||
type ConversationScrollButtonProps,
|
||||
} from "./conversation";
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
5
app/ui/app/src/lib/utils.ts
Normal file
5
app/ui/app/src/lib/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
Reference in New Issue
Block a user