mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 08:46:22 +02:00
1088 lines
38 KiB
TypeScript
1088 lines
38 KiB
TypeScript
import {
|
|
ActionBarPrimitive,
|
|
AssistantIf,
|
|
BranchPickerPrimitive,
|
|
ComposerPrimitive,
|
|
ErrorPrimitive,
|
|
MessagePrimitive,
|
|
ThreadPrimitive,
|
|
useAssistantState,
|
|
useComposerRuntime,
|
|
} from "@assistant-ui/react";
|
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
import {
|
|
AlertCircle,
|
|
ArrowDownIcon,
|
|
ArrowUpIcon,
|
|
CheckIcon,
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
CopyIcon,
|
|
DownloadIcon,
|
|
Globe,
|
|
Plus,
|
|
RefreshCwIcon,
|
|
Settings2,
|
|
SquareIcon,
|
|
Unplug,
|
|
Upload,
|
|
X,
|
|
} from "lucide-react";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import { useParams } from "next/navigation";
|
|
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
agentToolsAtom,
|
|
disabledToolsAtom,
|
|
hydrateDisabledToolsAtom,
|
|
toggleToolAtom,
|
|
} from "@/atoms/agent-tools/agent-tools.atoms";
|
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
|
import {
|
|
mentionedDocumentsAtom,
|
|
sidebarSelectedDocumentsAtom,
|
|
} from "@/atoms/chat/mentioned-documents.atom";
|
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
|
import {
|
|
globalNewLLMConfigsAtom,
|
|
llmPreferencesAtom,
|
|
newLLMConfigsAtom,
|
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
|
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
|
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
|
import {
|
|
InlineMentionEditor,
|
|
type InlineMentionEditorRef,
|
|
} from "@/components/assistant-ui/inline-mention-editor";
|
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
|
import {
|
|
ThinkingStepsContext,
|
|
ThinkingStepsDisplay,
|
|
} from "@/components/assistant-ui/thinking-steps";
|
|
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
|
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
|
|
import {
|
|
DocumentMentionPicker,
|
|
type DocumentMentionPickerRef,
|
|
} from "@/components/new-chat/document-mention-picker";
|
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
|
import type { Document } from "@/contracts/types/document.types";
|
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/** Placeholder texts that cycle in new chats when input is empty */
|
|
const CYCLING_PLACEHOLDERS = [
|
|
"Ask SurfSense anything or @mention docs.",
|
|
"Generate a podcast from my vacation ideas in Notion.",
|
|
"Sum up last week's meeting notes from Drive in a bulleted list.",
|
|
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
|
|
"Briefly, what are today's top ten important emails and calendar events?",
|
|
"Check if this week's Slack messages reference any GitHub issues.",
|
|
];
|
|
|
|
interface ThreadProps {
|
|
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
|
}
|
|
|
|
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
|
return (
|
|
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
|
<ThreadContent />
|
|
</ThinkingStepsContext.Provider>
|
|
);
|
|
};
|
|
|
|
const ThreadContent: FC = () => {
|
|
return (
|
|
<ThreadPrimitive.Root
|
|
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
|
|
style={{
|
|
["--thread-max-width" as string]: "44rem",
|
|
}}
|
|
>
|
|
<ThreadPrimitive.Viewport
|
|
turnAnchor="top"
|
|
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
|
>
|
|
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
|
<ThreadWelcome />
|
|
</AssistantIf>
|
|
|
|
<ThreadPrimitive.Messages
|
|
components={{
|
|
UserMessage,
|
|
EditComposer,
|
|
AssistantMessage,
|
|
}}
|
|
/>
|
|
|
|
<ThreadPrimitive.ViewportFooter
|
|
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
|
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
|
>
|
|
<ThreadScrollToBottom />
|
|
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
|
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
|
<Composer />
|
|
</div>
|
|
</AssistantIf>
|
|
</ThreadPrimitive.ViewportFooter>
|
|
</ThreadPrimitive.Viewport>
|
|
</ThreadPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const ThreadScrollToBottom: FC = () => {
|
|
return (
|
|
<ThreadPrimitive.ScrollToBottom asChild>
|
|
<TooltipIconButton
|
|
tooltip="Scroll to bottom"
|
|
variant="outline"
|
|
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
|
>
|
|
<ArrowDownIcon />
|
|
</TooltipIconButton>
|
|
</ThreadPrimitive.ScrollToBottom>
|
|
);
|
|
};
|
|
|
|
const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => {
|
|
const hour = new Date().getHours();
|
|
|
|
// Extract first name: prefer display_name, fall back to email extraction
|
|
let firstName: string | null = null;
|
|
|
|
if (user?.display_name?.trim()) {
|
|
// Use display_name if available and not empty
|
|
// Extract first name from display_name (take first word)
|
|
const nameParts = user.display_name.trim().split(/\s+/);
|
|
firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase();
|
|
} else if (user?.email) {
|
|
// Fall back to email extraction if display_name is not available
|
|
firstName =
|
|
user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
|
user.email.split("@")[0].split(".")[0].slice(1);
|
|
}
|
|
|
|
// Array of greeting variations for each time period
|
|
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
|
|
|
|
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
|
|
|
|
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
|
|
|
|
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
|
|
|
|
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
|
|
|
|
// Select a random greeting based on time
|
|
let greeting: string;
|
|
if (hour < 5) {
|
|
// Late night: midnight to 5 AM
|
|
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
|
} else if (hour < 12) {
|
|
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
|
} else if (hour < 18) {
|
|
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
|
} else if (hour < 22) {
|
|
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
|
} else {
|
|
// Night: 10 PM to midnight
|
|
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
|
}
|
|
|
|
// Add personalization with first name if available
|
|
if (firstName) {
|
|
return `${greeting}, ${firstName}!`;
|
|
}
|
|
|
|
return `${greeting}!`;
|
|
};
|
|
|
|
const ThreadWelcome: FC = () => {
|
|
const { data: user } = useAtomValue(currentUserAtom);
|
|
|
|
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
|
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
|
|
|
|
return (
|
|
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
|
{/* Greeting positioned above the composer */}
|
|
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
|
|
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl select-none">
|
|
{greeting}
|
|
</h1>
|
|
</div>
|
|
{/* Composer - top edge fixed, expands downward only */}
|
|
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
|
<Composer />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const BANNER_CONNECTORS = [
|
|
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
|
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
|
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
|
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
|
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
|
] as const;
|
|
|
|
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
|
|
|
|
const ConnectToolsBanner: FC = () => {
|
|
const { data: connectors } = useAtomValue(connectorsAtom);
|
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
|
const [dismissed, setDismissed] = useState(() => {
|
|
if (typeof window === "undefined") return false;
|
|
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
|
|
});
|
|
|
|
const hasConnectors = (connectors?.length ?? 0) > 0;
|
|
|
|
if (dismissed || hasConnectors) return null;
|
|
|
|
const handleDismiss = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setDismissed(true);
|
|
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
|
|
};
|
|
|
|
return (
|
|
<div className="border-t border-border/50">
|
|
<div className="flex w-full items-center gap-2.5 px-4 py-2.5">
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 items-center gap-2.5 text-left cursor-pointer"
|
|
onClick={() => setConnectorDialogOpen(true)}
|
|
>
|
|
<Unplug className="size-4 text-muted-foreground shrink-0" />
|
|
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
|
|
<AvatarGroup className="shrink-0">
|
|
{BANNER_CONNECTORS.map(({ type }, i) => (
|
|
<Avatar
|
|
key={type}
|
|
className="size-6"
|
|
style={{ zIndex: BANNER_CONNECTORS.length - i }}
|
|
>
|
|
<AvatarFallback className="bg-muted text-[10px]">
|
|
{getConnectorIcon(type, "size-3.5")}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
))}
|
|
</AvatarGroup>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismiss}
|
|
className="shrink-0 ml-0.5 p-1.5 -mr-1 text-muted-foreground/40 hover:text-foreground transition-colors cursor-pointer"
|
|
aria-label="Dismiss"
|
|
>
|
|
<X className="size-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Composer: FC = () => {
|
|
// Document mention state (atoms persist across component remounts)
|
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
|
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
|
|
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
|
const [mentionQuery, setMentionQuery] = useState("");
|
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
|
const { search_space_id, chat_id } = useParams();
|
|
const composerRuntime = useComposerRuntime();
|
|
const hasAutoFocusedRef = useRef(false);
|
|
|
|
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
|
|
|
// Cycling placeholder state - only cycles in new chats
|
|
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
|
|
|
// Cycle through placeholders every 4 seconds when thread is empty (new chat)
|
|
useEffect(() => {
|
|
// Only cycle when thread is empty (new chat)
|
|
if (!isThreadEmpty) {
|
|
// Reset to first placeholder when chat becomes active
|
|
setPlaceholderIndex(0);
|
|
return;
|
|
}
|
|
|
|
const intervalId = setInterval(() => {
|
|
setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length);
|
|
}, 6000);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [isThreadEmpty]);
|
|
|
|
// Compute current placeholder - only cycle in new chats
|
|
const currentPlaceholder = isThreadEmpty
|
|
? CYCLING_PLACEHOLDERS[placeholderIndex]
|
|
: CYCLING_PLACEHOLDERS[0];
|
|
|
|
// Live collaboration state
|
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
|
const { data: members } = useAtomValue(membersAtom);
|
|
const threadId = useMemo(() => {
|
|
if (Array.isArray(chat_id) && chat_id.length > 0) {
|
|
return Number.parseInt(chat_id[0], 10) || null;
|
|
}
|
|
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
|
|
}, [chat_id]);
|
|
const sessionState = useAtomValue(chatSessionStateAtom);
|
|
const isAiResponding = sessionState?.isAiResponding ?? false;
|
|
const respondingToUserId = sessionState?.respondingToUserId ?? null;
|
|
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
|
|
|
|
// Sync comments for the entire thread via Electric SQL (one subscription per thread)
|
|
useCommentsElectric(threadId);
|
|
|
|
// Batch-prefetch comments for all assistant messages so individual useComments
|
|
// hooks never fire their own network requests (eliminates N+1 API calls).
|
|
// Return a primitive string from the selector so useSyncExternalStore can
|
|
// compare snapshots by value and avoid infinite re-render loops.
|
|
const assistantIdsKey = useAssistantState(({ thread }) =>
|
|
thread.messages
|
|
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
|
.map((m) => m.id?.replace("msg-", ""))
|
|
.join(",")
|
|
);
|
|
const assistantDbMessageIds = useMemo(
|
|
() => (assistantIdsKey ? assistantIdsKey.split(",").map(Number) : []),
|
|
[assistantIdsKey]
|
|
);
|
|
useBatchCommentsPreload(assistantDbMessageIds);
|
|
|
|
// Auto-focus editor on new chat page after mount
|
|
useEffect(() => {
|
|
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
|
const timeoutId = setTimeout(() => {
|
|
editorRef.current?.focus();
|
|
hasAutoFocusedRef.current = true;
|
|
}, 100);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [isThreadEmpty]);
|
|
|
|
// Close document picker when a slide-out panel (inbox, shared/private chats) opens
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
setShowDocumentPopover(false);
|
|
setMentionQuery("");
|
|
};
|
|
window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
|
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
|
}, []);
|
|
|
|
// Sync editor text with assistant-ui composer runtime
|
|
const handleEditorChange = useCallback(
|
|
(text: string) => {
|
|
composerRuntime.setText(text);
|
|
},
|
|
[composerRuntime]
|
|
);
|
|
|
|
// Open document picker when @ mention is triggered
|
|
const handleMentionTrigger = useCallback((query: string) => {
|
|
setShowDocumentPopover(true);
|
|
setMentionQuery(query);
|
|
}, []);
|
|
|
|
// Close document picker and reset query
|
|
const handleMentionClose = useCallback(() => {
|
|
if (showDocumentPopover) {
|
|
setShowDocumentPopover(false);
|
|
setMentionQuery("");
|
|
}
|
|
}, [showDocumentPopover]);
|
|
|
|
// Keyboard navigation for document picker (arrow keys, Enter, Escape)
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (showDocumentPopover) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
documentPickerRef.current?.moveDown();
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
documentPickerRef.current?.moveUp();
|
|
return;
|
|
}
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
documentPickerRef.current?.selectHighlighted();
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setShowDocumentPopover(false);
|
|
setMentionQuery("");
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
[showDocumentPopover]
|
|
);
|
|
|
|
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
|
const handleSubmit = useCallback(() => {
|
|
if (isThreadRunning || isBlockedByOtherUser) {
|
|
return;
|
|
}
|
|
if (!showDocumentPopover) {
|
|
composerRuntime.send();
|
|
editorRef.current?.clear();
|
|
setMentionedDocuments([]);
|
|
setSidebarDocs([]);
|
|
}
|
|
}, [
|
|
showDocumentPopover,
|
|
isThreadRunning,
|
|
isBlockedByOtherUser,
|
|
composerRuntime,
|
|
setMentionedDocuments,
|
|
setSidebarDocs,
|
|
]);
|
|
|
|
const handleDocumentRemove = useCallback(
|
|
(docId: number, docType?: string) => {
|
|
setMentionedDocuments((prev) =>
|
|
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
|
);
|
|
},
|
|
[setMentionedDocuments]
|
|
);
|
|
|
|
const handleDocumentsMention = useCallback(
|
|
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
|
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
|
const newDocs = documents.filter(
|
|
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
|
);
|
|
|
|
for (const doc of newDocs) {
|
|
editorRef.current?.insertDocumentChip(doc);
|
|
}
|
|
|
|
setMentionedDocuments((prev) => {
|
|
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
|
const uniqueNewDocs = documents.filter(
|
|
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
|
);
|
|
return [...prev, ...uniqueNewDocs];
|
|
});
|
|
|
|
setMentionQuery("");
|
|
},
|
|
[mentionedDocuments, setMentionedDocuments]
|
|
);
|
|
|
|
return (
|
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
|
<ChatSessionStatus
|
|
isAiResponding={isAiResponding}
|
|
respondingToUserId={respondingToUserId}
|
|
currentUserId={currentUser?.id ?? null}
|
|
members={members ?? []}
|
|
/>
|
|
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
|
{/* Inline editor with @mention support */}
|
|
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
|
<InlineMentionEditor
|
|
ref={editorRef}
|
|
placeholder={currentPlaceholder}
|
|
onMentionTrigger={handleMentionTrigger}
|
|
onMentionClose={handleMentionClose}
|
|
onChange={handleEditorChange}
|
|
onDocumentRemove={handleDocumentRemove}
|
|
onSubmit={handleSubmit}
|
|
onKeyDown={handleKeyDown}
|
|
className="min-h-[24px]"
|
|
/>
|
|
</div>
|
|
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
|
{showDocumentPopover &&
|
|
typeof document !== "undefined" &&
|
|
createPortal(
|
|
<DocumentMentionPicker
|
|
ref={documentPickerRef}
|
|
searchSpaceId={Number(search_space_id)}
|
|
onSelectionChange={handleDocumentsMention}
|
|
onDone={() => {
|
|
setShowDocumentPopover(false);
|
|
setMentionQuery("");
|
|
}}
|
|
initialSelectedDocuments={mentionedDocuments}
|
|
externalSearch={mentionQuery}
|
|
containerStyle={{
|
|
bottom: editorContainerRef.current
|
|
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
|
: "200px",
|
|
left: editorContainerRef.current
|
|
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
|
: "50%",
|
|
}}
|
|
/>,
|
|
document.body
|
|
)}
|
|
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
|
<ConnectorIndicator showTrigger={false} />
|
|
<ConnectToolsBanner />
|
|
</div>
|
|
</ComposerPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
interface ComposerActionProps {
|
|
isBlockedByOtherUser?: boolean;
|
|
}
|
|
|
|
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
|
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
|
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
|
|
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
|
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
|
|
const isDesktop = useMediaQuery("(min-width: 640px)");
|
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
|
const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
|
const handleToolsScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
const el = e.currentTarget;
|
|
const atTop = el.scrollTop <= 2;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
|
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
|
}, []);
|
|
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
|
const text = composer.text?.trim() || "";
|
|
return text.length === 0;
|
|
});
|
|
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
|
|
|
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
|
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
|
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
|
|
|
const { data: agentTools } = useAtomValue(agentToolsAtom);
|
|
const disabledTools = useAtomValue(disabledToolsAtom);
|
|
const toggleTool = useSetAtom(toggleToolAtom);
|
|
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
|
|
|
|
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
|
|
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
|
|
const filteredTools = useMemo(
|
|
() => agentTools?.filter((t) => t.name !== "web_search"),
|
|
[agentTools]
|
|
);
|
|
const filteredEnabledCount = useMemo(() => {
|
|
if (!filteredTools) return 0;
|
|
return (
|
|
filteredTools.length -
|
|
disabledTools.filter((d) => filteredTools.some((t) => t.name === d)).length
|
|
);
|
|
}, [filteredTools, disabledTools]);
|
|
|
|
const groupedTools = useMemo(() => {
|
|
if (!filteredTools) return [];
|
|
const toolsByName = new Map(filteredTools.map((t) => [t.name, t]));
|
|
const result: { label: string; tools: typeof filteredTools }[] = [];
|
|
const placed = new Set<string>();
|
|
|
|
for (const group of TOOL_GROUPS) {
|
|
const matched = group.tools.flatMap((name) => {
|
|
const tool = toolsByName.get(name);
|
|
if (!tool) return [];
|
|
placed.add(name);
|
|
return [tool];
|
|
});
|
|
if (matched.length > 0) {
|
|
result.push({ label: group.label, tools: matched });
|
|
}
|
|
}
|
|
|
|
const ungrouped = filteredTools.filter((t) => !placed.has(t.name));
|
|
if (ungrouped.length > 0) {
|
|
result.push({ label: "Other", tools: ungrouped });
|
|
}
|
|
|
|
return result;
|
|
}, [filteredTools]);
|
|
|
|
useEffect(() => {
|
|
hydrateDisabled();
|
|
}, [hydrateDisabled]);
|
|
|
|
const hasModelConfigured = useMemo(() => {
|
|
if (!preferences) return false;
|
|
const agentLlmId = preferences.agent_llm_id;
|
|
if (agentLlmId === null || agentLlmId === undefined) return false;
|
|
|
|
if (agentLlmId <= 0) {
|
|
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
|
}
|
|
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
|
}, [preferences, globalConfigs, userConfigs]);
|
|
|
|
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
|
|
|
return (
|
|
<div className="aui-composer-action-wrapper relative mx-3 mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
{!isDesktop ? (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
|
aria-label="More actions"
|
|
data-joyride="connector-icon"
|
|
>
|
|
<Plus className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="start" sideOffset={8}>
|
|
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
|
|
<Settings2 className="size-4" />
|
|
Manage Tools
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => openUploadDialog()}>
|
|
<Upload className="size-4" />
|
|
Upload Files
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Drawer open={toolsPopoverOpen} onOpenChange={setToolsPopoverOpen}>
|
|
<DrawerContent className="max-h-[60dvh]">
|
|
<DrawerHandle />
|
|
<div className="flex items-center justify-between px-4 py-2">
|
|
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
|
|
<span className="text-xs text-muted-foreground">
|
|
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
|
|
</span>
|
|
</div>
|
|
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
|
|
{groupedTools.map((group) => (
|
|
<div key={group.label}>
|
|
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
|
{group.label}
|
|
</div>
|
|
{group.tools.map((tool) => {
|
|
const isDisabled = disabledTools.includes(tool.name);
|
|
const ToolIcon = getToolIcon(tool.name);
|
|
return (
|
|
<div
|
|
key={tool.name}
|
|
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
|
|
>
|
|
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
|
{formatToolName(tool.name)}
|
|
</span>
|
|
<Switch
|
|
checked={!isDisabled}
|
|
onCheckedChange={() => toggleTool(tool.name)}
|
|
className="shrink-0"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
{!filteredTools?.length && (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
Loading tools...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
|
aria-label="Manage connectors"
|
|
onClick={() => setConnectorDialogOpen(true)}
|
|
>
|
|
<Unplug className="size-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Popover open={toolsPopoverOpen} onOpenChange={setToolsPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<TooltipIconButton
|
|
tooltip="Manage tools"
|
|
side="bottom"
|
|
disableTooltip={toolsPopoverOpen}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
|
aria-label="Manage tools"
|
|
data-joyride="connector-icon"
|
|
>
|
|
<Settings2 className="size-4" />
|
|
</TooltipIconButton>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={12}
|
|
className="w-[calc(100vw-2rem)] max-w-56 sm:max-w-72 sm:w-72 p-0 select-none"
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
|
|
<span className="text-xs sm:text-sm font-medium">Agent Tools</span>
|
|
<span className="text-[10px] sm:text-xs text-muted-foreground">
|
|
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="max-h-48 sm:max-h-64 overflow-y-auto py-0.5 sm:py-1"
|
|
onScroll={handleToolsScroll}
|
|
style={{
|
|
maskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
|
|
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
|
|
}}
|
|
>
|
|
{groupedTools.map((group) => (
|
|
<div key={group.label}>
|
|
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
|
|
{group.label}
|
|
</div>
|
|
{group.tools.map((tool) => {
|
|
const isDisabled = disabledTools.includes(tool.name);
|
|
const ToolIcon = getToolIcon(tool.name);
|
|
const row = (
|
|
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
|
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
|
|
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
|
{formatToolName(tool.name)}
|
|
</span>
|
|
<Switch
|
|
checked={!isDisabled}
|
|
onCheckedChange={() => toggleTool(tool.name)}
|
|
className="shrink-0 scale-[0.6] sm:scale-75"
|
|
/>
|
|
</div>
|
|
);
|
|
return (
|
|
<Tooltip key={tool.name}>
|
|
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-64 text-xs">
|
|
{tool.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
{!filteredTools?.length && (
|
|
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
|
Loading tools...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
{hasWebSearchTool && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleTool("web_search")}
|
|
className={cn(
|
|
"rounded-full transition-all flex items-center gap-1 px-2 py-1 border h-8 select-none",
|
|
isWebSearchEnabled
|
|
? "bg-sky-500/15 border-sky-500/60 text-sky-500"
|
|
: "bg-transparent border-transparent text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<motion.div
|
|
animate={{
|
|
rotate: isWebSearchEnabled ? 360 : 0,
|
|
scale: isWebSearchEnabled ? 1.1 : 1,
|
|
}}
|
|
whileHover={{
|
|
rotate: isWebSearchEnabled ? 360 : 15,
|
|
scale: 1.1,
|
|
transition: { type: "spring", stiffness: 300, damping: 10 },
|
|
}}
|
|
transition={{ type: "spring", stiffness: 260, damping: 25 }}
|
|
>
|
|
<Globe className="size-4" />
|
|
</motion.div>
|
|
<AnimatePresence>
|
|
{isWebSearchEnabled && (
|
|
<motion.span
|
|
initial={{ width: 0, opacity: 0 }}
|
|
animate={{ width: "auto", opacity: 1 }}
|
|
exit={{ width: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="text-xs overflow-hidden whitespace-nowrap"
|
|
>
|
|
Search
|
|
</motion.span>
|
|
)}
|
|
</AnimatePresence>
|
|
</button>
|
|
)}
|
|
{sidebarDocs.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setDocumentsSidebarOpen(true)}
|
|
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
|
>
|
|
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!hasModelConfigured && (
|
|
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
|
<AlertCircle className="size-3" />
|
|
<span>Select a model</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
|
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
|
<TooltipIconButton
|
|
tooltip={
|
|
isBlockedByOtherUser
|
|
? "Wait for AI to finish responding"
|
|
: !hasModelConfigured
|
|
? "Please select a model from the header to start chatting"
|
|
: isComposerEmpty
|
|
? "Enter a message to send"
|
|
: "Send message"
|
|
}
|
|
side="bottom"
|
|
type="submit"
|
|
variant="default"
|
|
size="icon"
|
|
className={cn(
|
|
"aui-composer-send size-8 rounded-full",
|
|
isSendDisabled && "cursor-not-allowed opacity-50"
|
|
)}
|
|
aria-label="Send message"
|
|
disabled={isSendDisabled}
|
|
>
|
|
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
|
</TooltipIconButton>
|
|
</ComposerPrimitive.Send>
|
|
</AssistantIf>
|
|
|
|
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
|
<ComposerPrimitive.Cancel asChild>
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="icon"
|
|
className="aui-composer-cancel size-8 rounded-full"
|
|
aria-label="Stop generating"
|
|
>
|
|
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
|
</Button>
|
|
</ComposerPrimitive.Cancel>
|
|
</AssistantIf>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** Convert snake_case tool names to human-readable labels */
|
|
function formatToolName(name: string): string {
|
|
return name
|
|
.split("_")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
|
{
|
|
label: "Research",
|
|
tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"],
|
|
},
|
|
{
|
|
label: "Generate",
|
|
tools: ["generate_podcast", "generate_report", "generate_image", "display_image"],
|
|
},
|
|
{
|
|
label: "Memory",
|
|
tools: ["save_memory", "recall_memory"],
|
|
},
|
|
];
|
|
|
|
const MessageError: FC = () => {
|
|
return (
|
|
<MessagePrimitive.Error>
|
|
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
|
|
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
|
|
</ErrorPrimitive.Root>
|
|
</MessagePrimitive.Error>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Custom component to render thinking steps from Context
|
|
*/
|
|
const ThinkingStepsPart: FC = () => {
|
|
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
|
|
|
// Get the current message ID to look up thinking steps
|
|
const messageId = useAssistantState(({ message }) => message?.id);
|
|
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
|
|
|
|
// Check if this specific message is currently streaming
|
|
// A message is streaming if: thread is running AND this is the last assistant message
|
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
|
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
|
const isMessageStreaming = isThreadRunning && isLastMessage;
|
|
|
|
if (thinkingSteps.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mb-3">
|
|
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isMessageStreaming} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AssistantMessageInner: FC = () => {
|
|
return (
|
|
<>
|
|
{/* Render thinking steps from message content - this ensures proper scroll tracking */}
|
|
<ThinkingStepsPart />
|
|
|
|
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
|
<MessagePrimitive.Parts
|
|
components={{
|
|
Text: MarkdownText,
|
|
tools: { Fallback: ToolFallback },
|
|
}}
|
|
/>
|
|
<MessageError />
|
|
</div>
|
|
|
|
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
|
<BranchPicker />
|
|
<AssistantActionBar />
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const AssistantActionBar: FC = () => {
|
|
return (
|
|
<ActionBarPrimitive.Root
|
|
hideWhenRunning
|
|
autohide="not-last"
|
|
autohideFloat="single-branch"
|
|
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
|
>
|
|
<ActionBarPrimitive.Copy asChild>
|
|
<TooltipIconButton tooltip="Copy">
|
|
<AssistantIf condition={({ message }) => message.isCopied}>
|
|
<CheckIcon />
|
|
</AssistantIf>
|
|
<AssistantIf condition={({ message }) => !message.isCopied}>
|
|
<CopyIcon />
|
|
</AssistantIf>
|
|
</TooltipIconButton>
|
|
</ActionBarPrimitive.Copy>
|
|
<ActionBarPrimitive.ExportMarkdown asChild>
|
|
<TooltipIconButton tooltip="Export as Markdown">
|
|
<DownloadIcon />
|
|
</TooltipIconButton>
|
|
</ActionBarPrimitive.ExportMarkdown>
|
|
<ActionBarPrimitive.Reload asChild>
|
|
<TooltipIconButton tooltip="Refresh">
|
|
<RefreshCwIcon />
|
|
</TooltipIconButton>
|
|
</ActionBarPrimitive.Reload>
|
|
</ActionBarPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const EditComposer: FC = () => {
|
|
return (
|
|
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
|
|
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
|
|
<ComposerPrimitive.Input
|
|
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
|
|
autoFocus
|
|
/>
|
|
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
|
<ComposerPrimitive.Cancel asChild>
|
|
<Button variant="ghost" size="sm">
|
|
Cancel
|
|
</Button>
|
|
</ComposerPrimitive.Cancel>
|
|
<ComposerPrimitive.Send asChild>
|
|
<Button size="sm">Update</Button>
|
|
</ComposerPrimitive.Send>
|
|
</div>
|
|
</ComposerPrimitive.Root>
|
|
</MessagePrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
|
|
return (
|
|
<BranchPickerPrimitive.Root
|
|
hideWhenSingleBranch
|
|
className={cn(
|
|
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
|
|
className
|
|
)}
|
|
{...rest}
|
|
>
|
|
<BranchPickerPrimitive.Previous asChild>
|
|
<TooltipIconButton tooltip="Previous">
|
|
<ChevronLeftIcon />
|
|
</TooltipIconButton>
|
|
</BranchPickerPrimitive.Previous>
|
|
<span className="aui-branch-picker-state font-medium">
|
|
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
</span>
|
|
<BranchPickerPrimitive.Next asChild>
|
|
<TooltipIconButton tooltip="Next">
|
|
<ChevronRightIcon />
|
|
</TooltipIconButton>
|
|
</BranchPickerPrimitive.Next>
|
|
</BranchPickerPrimitive.Root>
|
|
);
|
|
};
|