mirror of
https://github.com/ollama/ollama.git
synced 2026-01-21 05:48:35 -05:00
Compare commits
3 Commits
parth/decr
...
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",
|
"remark-math": "^6.0.0",
|
||||||
"streamdown": "^1.4.0",
|
"streamdown": "^1.4.0",
|
||||||
"unist-builder": "^4.0.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": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.0.1",
|
"@chromatic-com/storybook": "^4.0.1",
|
||||||
@@ -12739,6 +12740,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
"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",
|
"remark-math": "^6.0.0",
|
||||||
"streamdown": "^1.4.0",
|
"streamdown": "^1.4.0",
|
||||||
"unist-builder": "^4.0.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": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.0.1",
|
"@chromatic-com/storybook": "^4.0.1",
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { FileUpload } from "./FileUpload";
|
|||||||
import { DisplayUpgrade } from "./DisplayUpgrade";
|
import { DisplayUpgrade } from "./DisplayUpgrade";
|
||||||
import { DisplayStale } from "./DisplayStale";
|
import { DisplayStale } from "./DisplayStale";
|
||||||
import { DisplayLogin } from "./DisplayLogin";
|
import { DisplayLogin } from "./DisplayLogin";
|
||||||
|
import {
|
||||||
|
ConversationContent,
|
||||||
|
ConversationScrollButton,
|
||||||
|
ConversationWithSpacer,
|
||||||
|
} from "./ai-elements/conversation";
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
useSendMessage,
|
useSendMessage,
|
||||||
@@ -15,14 +20,7 @@ import {
|
|||||||
useDismissStaleModel,
|
useDismissStaleModel,
|
||||||
} from "@/hooks/useChats";
|
} from "@/hooks/useChats";
|
||||||
import { useHealth } from "@/hooks/useHealth";
|
import { useHealth } from "@/hooks/useHealth";
|
||||||
import { useMessageAutoscroll } from "@/hooks/useMessageAutoscroll";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useCallback,
|
|
||||||
} from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useSelectedModel } from "@/hooks/useSelectedModel";
|
import { useSelectedModel } from "@/hooks/useSelectedModel";
|
||||||
@@ -47,7 +45,6 @@ export default function Chat({ chatId }: { chatId: string }) {
|
|||||||
index: number;
|
index: number;
|
||||||
originalMessage: Message;
|
originalMessage: Message;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const prevChatIdRef = useRef<string>(chatId);
|
|
||||||
|
|
||||||
const chatFormCallbackRef = useRef<
|
const chatFormCallbackRef = useRef<
|
||||||
| ((
|
| ((
|
||||||
@@ -102,29 +99,8 @@ export default function Chat({ chatId }: { chatId: string }) {
|
|||||||
|
|
||||||
const sendMessageMutation = useSendMessage(chatId);
|
const sendMessageMutation = useSendMessage(chatId);
|
||||||
|
|
||||||
const { containerRef, handleNewUserMessage, spacerHeight } =
|
const latestMessageRef = useRef<HTMLDivElement>(null);
|
||||||
useMessageAutoscroll({
|
|
||||||
messages,
|
|
||||||
isStreaming,
|
|
||||||
chatId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 = (
|
const handleChatFormSubmit = (
|
||||||
message: string,
|
message: string,
|
||||||
options: {
|
options: {
|
||||||
@@ -168,7 +144,6 @@ export default function Chat({ chatId }: { chatId: string }) {
|
|||||||
|
|
||||||
// Clear edit mode after submission
|
// Clear edit mode after submission
|
||||||
setEditingMessage(null);
|
setEditingMessage(null);
|
||||||
handleNewUserMessage();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditMessage = (content: string, index: number) => {
|
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 ? (
|
return chatId === "new" || chatQuery ? (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onFilesAdded={handleFilesProcessed}
|
onFilesAdded={handleFilesProcessed}
|
||||||
@@ -219,25 +192,29 @@ export default function Chat({ chatId }: { chatId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<main className="flex h-screen w-full flex-col relative allow-context-menu select-none">
|
<main className="flex h-screen w-full flex-col relative allow-context-menu select-none">
|
||||||
<section
|
<ConversationWithSpacer
|
||||||
key={chatId} // This key forces React to recreate the element when chatId changes
|
key={chatId}
|
||||||
ref={containerRef}
|
className={`flex-1 overscroll-contain select-none`}
|
||||||
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 select-none ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
|
isStreaming={isStreaming}
|
||||||
|
messageCount={messages.length}
|
||||||
>
|
>
|
||||||
<MessageList
|
<ConversationContent isStreaming={isStreaming}>
|
||||||
messages={messages}
|
<MessageList
|
||||||
spacerHeight={spacerHeight}
|
messages={messages}
|
||||||
isWaitingForLoad={isWaitingForLoad}
|
isWaitingForLoad={isWaitingForLoad}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
downloadProgress={downloadProgress}
|
downloadProgress={downloadProgress}
|
||||||
onEditMessage={(content: string, index: number) => {
|
onEditMessage={(content: string, index: number) => {
|
||||||
handleEditMessage(content, index);
|
handleEditMessage(content, index);
|
||||||
}}
|
}}
|
||||||
editingMessageIndex={editingMessage?.index}
|
editingMessageIndex={editingMessage?.index}
|
||||||
error={chatError}
|
error={chatError}
|
||||||
browserToolResult={browserToolResult}
|
browserToolResult={browserToolResult}
|
||||||
/>
|
latestMessageRef={latestMessageRef}
|
||||||
</section>
|
/>
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton />
|
||||||
|
</ConversationWithSpacer>
|
||||||
|
|
||||||
<div className="flex-shrink-0 sticky bottom-0 z-20">
|
<div className="flex-shrink-0 sticky bottom-0 z-20">
|
||||||
{selectedModel && shouldShowStaleDisplay && (
|
{selectedModel && shouldShowStaleDisplay && (
|
||||||
@@ -248,14 +225,6 @@ export default function Chat({ chatId }: { chatId: string }) {
|
|||||||
dismissStaleModel(selectedModel?.model || "")
|
dismissStaleModel(selectedModel?.model || "")
|
||||||
}
|
}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
onScrollToBottom={() => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
containerRef.current.scrollTo({
|
|
||||||
top: containerRef.current.scrollHeight,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import React from "react";
|
|||||||
import Message from "./Message";
|
import Message from "./Message";
|
||||||
import Downloading from "./Downloading";
|
import Downloading from "./Downloading";
|
||||||
import { ErrorMessage } from "./ErrorMessage";
|
import { ErrorMessage } from "./ErrorMessage";
|
||||||
|
import { ConversationSpacer } from "./ai-elements/conversation";
|
||||||
|
|
||||||
export default function MessageList({
|
export default function MessageList({
|
||||||
messages,
|
messages,
|
||||||
spacerHeight,
|
|
||||||
isWaitingForLoad,
|
isWaitingForLoad,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
@@ -14,9 +14,9 @@ export default function MessageList({
|
|||||||
editingMessageIndex,
|
editingMessageIndex,
|
||||||
error,
|
error,
|
||||||
browserToolResult,
|
browserToolResult,
|
||||||
|
latestMessageRef,
|
||||||
}: {
|
}: {
|
||||||
messages: MessageType[];
|
messages: MessageType[];
|
||||||
spacerHeight: number;
|
|
||||||
isWaitingForLoad?: boolean;
|
isWaitingForLoad?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
downloadProgress?: DownloadEvent;
|
downloadProgress?: DownloadEvent;
|
||||||
@@ -24,6 +24,7 @@ export default function MessageList({
|
|||||||
editingMessageIndex?: number;
|
editingMessageIndex?: number;
|
||||||
error?: ErrorEvent | null;
|
error?: ErrorEvent | null;
|
||||||
browserToolResult?: any;
|
browserToolResult?: any;
|
||||||
|
latestMessageRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const [showDots, setShowDots] = React.useState(false);
|
const [showDots, setShowDots] = React.useState(false);
|
||||||
const isDownloadingModel = downloadProgress && !downloadProgress.done;
|
const isDownloadingModel = downloadProgress && !downloadProgress.done;
|
||||||
@@ -84,13 +85,19 @@ export default function MessageList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
data-role="message-list"
|
||||||
>
|
>
|
||||||
{messages.map((message, idx) => {
|
{messages.map((message, idx) => {
|
||||||
const lastToolQuery = lastToolQueries[idx];
|
const lastToolQuery = lastToolQueries[idx];
|
||||||
|
const isLastMessage = idx === messages.length - 1;
|
||||||
return (
|
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={message}
|
message={message}
|
||||||
onEditMessage={onEditMessage}
|
onEditMessage={onEditMessage}
|
||||||
@@ -161,8 +168,8 @@ export default function MessageList({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dynamic spacer to allow scrolling the last message to the top of the container */}
|
{/* Dynamic spacer */}
|
||||||
<div style={{ height: `${spacerHeight}px` }} aria-hidden="true" />
|
<ConversationSpacer />
|
||||||
</div>
|
</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