import { AuiIf, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState, useThreadViewportStore, } from "@assistant-ui/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AlertCircle, ArrowDownIcon, ArrowUpIcon, ChevronDown, ChevronUp, Clipboard, Globe, Plus, Settings2, SquareIcon, Unplug, Upload, Wrench, X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; import { useParams } from "next/navigation"; import { type FC, useCallback, 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 { 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 { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; 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 { CONNECTOR_ICON_TO_TYPES, CONNECTOR_TOOL_ICON_PATHS, getToolIcon, } from "@/contracts/enums/toolIcons"; import type { Document } from "@/contracts/types/document.types"; import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsSync } from "@/hooks/use-comments-sync"; 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", ]; export const Thread: FC = () => { return ; }; const ThreadContent: FC = () => { return ( thread.isEmpty}> !thread.isEmpty}>
); }; const ThreadScrollToBottom: FC = () => { return ( ); }; 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 (
{/* Greeting positioned above the composer */}

{greeting}

{/* Composer - top edge fixed, expands downward only */}
); }; 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<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) => { 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 || !isThreadEmpty) return null; const handleDismiss = (e: React.MouseEvent) => { e.stopPropagation(); setDismissed(true); localStorage.setItem(BANNER_DISMISSED_KEY, "true"); }; return (
); }; const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDismiss }) => { const [expanded, setExpanded] = useState(false); const isLong = text.length > 120; const preview = isLong ? `${text.slice(0, 120)}…` : text; return (
From clipboard
{isLong && ( )}

{expanded ? text : preview}

); }; 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 [showPromptPicker, setShowPromptPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [actionQuery, setActionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); const composerBoxRef = useRef(null); const documentPickerRef = useRef(null); const promptPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const aui = useAui(); const threadViewportStore = useThreadViewportStore(); const hasAutoFocusedRef = useRef(false); const submitCleanupRef = useRef<(() => void) | null>(null); useEffect(() => { return () => { submitCleanupRef.current?.(); }; }, []); const [clipboardInitialText, setClipboardInitialText] = useState(); const clipboardLoadedRef = useRef(false); useEffect(() => { if (!window.electronAPI || clipboardLoadedRef.current) return; clipboardLoadedRef.current = true; window.electronAPI.getQuickAskText().then((text) => { if (text) { setClipboardInitialText(text); setShowPromptPicker(true); } }); }, []); const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ 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 Zero (one subscription per thread) useCommentsSync(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 = useAuiState(({ 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) => { aui.composer().setText(text); }, [aui] ); // 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]); // Open action picker when / is triggered const handleActionTrigger = useCallback((query: string) => { setShowPromptPicker(true); setActionQuery(query); }, []); // Close action picker and reset query const handleActionClose = useCallback(() => { if (showPromptPicker) { setShowPromptPicker(false); setActionQuery(""); } }, [showPromptPicker]); const handleActionSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { let userText = editorRef.current?.getText() ?? ""; const trigger = `/${actionQuery}`; if (userText.endsWith(trigger)) { userText = userText.slice(0, -trigger.length).trimEnd(); } const finalPrompt = action.prompt.includes("{selection}") ? action.prompt.replace("{selection}", () => userText) : userText ? `${action.prompt}\n\n${userText}` : action.prompt; aui.composer().setText(finalPrompt); aui.composer().send(); editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); setMentionedDocuments([]); setSidebarDocs([]); }, [actionQuery, aui, setMentionedDocuments, setSidebarDocs] ); const handleQuickAskSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { if (!clipboardInitialText) return; window.electronAPI?.setQuickAskMode(action.mode); const finalPrompt = action.prompt.includes("{selection}") ? action.prompt.replace("{selection}", () => clipboardInitialText) : `${action.prompt}\n\n${clipboardInitialText}`; aui.composer().setText(finalPrompt); aui.composer().send(); editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); setClipboardInitialText(undefined); setMentionedDocuments([]); setSidebarDocs([]); }, [clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs] ); // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (showPromptPicker) { if (e.key === "ArrowDown") { e.preventDefault(); promptPickerRef.current?.moveDown(); return; } if (e.key === "ArrowUp") { e.preventDefault(); promptPickerRef.current?.moveUp(); return; } if (e.key === "Enter") { e.preventDefault(); promptPickerRef.current?.selectHighlighted(); return; } if (e.key === "Escape") { e.preventDefault(); setShowPromptPicker(false); setActionQuery(""); return; } } 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, showPromptPicker] ); // Submit message (blocked during streaming, document picker open, or AI responding to another user) const handleSubmit = useCallback(() => { if (isThreadRunning || isBlockedByOtherUser) { return; } if (!showDocumentPopover && !showPromptPicker) { if (clipboardInitialText) { const userText = editorRef.current?.getText() ?? ""; const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText; aui.composer().setText(combined); setClipboardInitialText(undefined); } aui.composer().send(); editorRef.current?.clear(); setMentionedDocuments([]); setSidebarDocs([]); } if (isThreadRunning || isBlockedByOtherUser) return; if (showDocumentPopover) return; const viewportEl = document.querySelector(".aui-thread-viewport"); const heightBefore = viewportEl?.scrollHeight ?? 0; aui.composer().send(); editorRef.current?.clear(); setMentionedDocuments([]); setSidebarDocs([]); // With turnAnchor="top", ViewportSlack adds min-height to the last // assistant message so that scrolling-to-bottom actually positions the // user message at the TOP of the viewport. That slack height is // calculated asynchronously (ResizeObserver → style → layout). // // We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes // (user msg render → assistant placeholder → ViewportSlack min-height → // first streamed content). Backup setTimeout calls cover cases where // the batcher's 50 ms throttle delays the DOM update past the rAF. const scrollToBottom = () => threadViewportStore.getState().scrollToBottom({ behavior: "instant" }); let lastHeight = heightBefore; let frames = 0; let cancelled = false; const POLL_FRAMES = 120; const pollAndScroll = () => { if (cancelled) return; const el = document.querySelector(".aui-thread-viewport"); if (el) { const h = el.scrollHeight; if (h !== lastHeight) { lastHeight = h; scrollToBottom(); } } if (++frames < POLL_FRAMES) { requestAnimationFrame(pollAndScroll); } }; requestAnimationFrame(pollAndScroll); const t1 = setTimeout(scrollToBottom, 100); const t2 = setTimeout(scrollToBottom, 300); const t3 = setTimeout(scrollToBottom, 600); // Cleanup if component unmounts during the polling window. The ref is // checked inside pollAndScroll; timeouts are cleared in the return below. // Store cleanup fn so it can be called from a useEffect cleanup if needed. submitCleanupRef.current = () => { cancelled = true; clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; }, [ showDocumentPopover, showPromptPicker, isThreadRunning, isBlockedByOtherUser, clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs, threadViewportStore, ]); 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[]) => { 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 (
{clipboardInitialText && ( setClipboardInitialText(undefined)} /> )} {/* Inline editor with @mention support */}
{/* Document picker popover (portal to body for proper z-index stacking) */} {showDocumentPopover && typeof document !== "undefined" && createPortal( { 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 )} {showPromptPicker && typeof document !== "undefined" && createPortal( { setShowPromptPicker(false); setActionQuery(""); }} externalSearch={actionQuery} containerStyle={{ position: "fixed", ...(clipboardInitialText && composerBoxRef.current ? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` } : { bottom: editorContainerRef.current ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` : "200px", }), left: editorContainerRef.current ? `${editorContainerRef.current.getBoundingClientRect().left}px` : "50%", zIndex: 50, }} />, document.body )}
); }; interface ComposerActionProps { isBlockedByOtherUser?: boolean; } const ComposerAction: FC = ({ 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) => { 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 = useAuiState(({ 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 disabledToolsSet = useMemo(() => new Set(disabledTools), [disabledTools]); const toggleTool = useSetAtom(toggleToolAtom); const setDisabledTools = useSetAtom(disabledToolsAtom); const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom); const { data: connectors } = useAtomValue(connectorsAtom); const connectedTypes = useMemo( () => new Set((connectors ?? []).map((c) => c.connector_type)), [connectors] ); const toggleToolGroup = useCallback( (toolNames: string[]) => { const allDisabled = toolNames.every((name) => disabledToolsSet.has(name)); if (allDisabled) { setDisabledTools((prev) => prev.filter((t) => !toolNames.includes(t))); } else { setDisabledTools((prev) => [...new Set([...prev, ...toolNames])]); } }, [disabledToolsSet, setDisabledTools] ); const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false; const isWebSearchEnabled = hasWebSearchTool && !disabledToolsSet.has("web_search"); const filteredTools = useMemo( () => agentTools?.filter((t) => t.name !== "web_search"), [agentTools] ); const groupedTools = useMemo(() => { if (!filteredTools) return []; const toolsByName = new Map(filteredTools.map((t) => [t.name, t])); const result: { label: string; tools: typeof filteredTools; connectorIcon?: string }[] = []; const placed = new Set(); for (const group of TOOL_GROUPS) { if (group.connectorIcon) { const requiredTypes = CONNECTOR_ICON_TO_TYPES[group.connectorIcon]; const isConnected = requiredTypes?.some((t) => connectedTypes.has(t)); if (!isConnected) { for (const name of group.tools) placed.add(name); continue; } } 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, connectorIcon: group.connectorIcon }); } } const ungrouped = filteredTools.filter((t) => !placed.has(t.name)); if (ungrouped.length > 0) { result.push({ label: "Other", tools: ungrouped }); } return result; }, [filteredTools, connectedTypes]); 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 (
{!isDesktop ? ( <> setToolsPopoverOpen(true)}> Manage Tools openUploadDialog()}> Upload Files
Manage Tools
{groupedTools .filter((g) => !g.connectorIcon) .map((group) => (
{group.label}
{group.tools.map((tool) => { const isDisabled = disabledToolsSet.has(tool.name); const ToolIcon = getToolIcon(tool.name); return (
{formatToolName(tool.name)} toggleTool(tool.name)} className="shrink-0" />
); })}
))} {groupedTools.some((g) => g.connectorIcon) && (
Connector Actions
{groupedTools .filter((g) => g.connectorIcon) .map((group) => { const iconKey = group.connectorIcon ?? ""; const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey]; const toolNames = group.tools.map((t) => t.name); const allDisabled = toolNames.every((n) => disabledToolsSet.has(n)); return (
{iconInfo ? ( {iconInfo.alt} ) : ( )} {group.label} toggleToolGroup(toolNames)} className="shrink-0" />
); })}
)} {!filteredTools?.length && (
Loading tools...
)}
) : ( e.preventDefault()} >
Manage Tools
{groupedTools .filter((g) => !g.connectorIcon) .map((group) => (
{group.label}
{group.tools.map((tool) => { const isDisabled = disabledToolsSet.has(tool.name); const ToolIcon = getToolIcon(tool.name); const row = (
{formatToolName(tool.name)} toggleTool(tool.name)} className="shrink-0 scale-[0.6] sm:scale-75" />
); return ( {row} {tool.description} ); })}
))} {groupedTools.some((g) => g.connectorIcon) && (
Connector Actions
{groupedTools .filter((g) => g.connectorIcon) .map((group) => { const iconKey = group.connectorIcon ?? ""; const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey]; const toolNames = group.tools.map((t) => t.name); const allDisabled = toolNames.every((n) => disabledToolsSet.has(n)); const groupDef = TOOL_GROUPS.find((g) => g.label === group.label); const row = (
{iconInfo ? ( {iconInfo.alt} ) : ( )} {group.label} toggleToolGroup(toolNames)} className="shrink-0 scale-[0.6] sm:scale-75" />
); return ( {row} {groupDef?.tooltip ?? group.tools.map((t) => t.description).join(" · ")} ); })}
)} {!filteredTools?.length && (
Loading tools...
)}
)} {hasWebSearchTool && ( )} {sidebarDocs.length > 0 && ( )}
{!hasModelConfigured && (
Select a model
)}
!thread.isRunning}> thread.isRunning}>
); }; /** 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(" "); } interface ToolGroup { label: string; tools: string[]; connectorIcon?: string; tooltip?: string; } const TOOL_GROUPS: ToolGroup[] = [ { label: "Research", tools: ["search_surfsense_docs", "scrape_webpage"], }, { label: "Generate", tools: ["generate_podcast", "generate_video_presentation", "generate_report", "generate_image"], }, { label: "Memory", tools: ["save_memory", "recall_memory"], }, { label: "Gmail", tools: ["create_gmail_draft", "update_gmail_draft", "send_gmail_email", "trash_gmail_email"], connectorIcon: "gmail", tooltip: "Create drafts, update drafts, send emails, and trash emails in Gmail", }, { label: "Google Calendar", tools: ["create_calendar_event", "update_calendar_event", "delete_calendar_event"], connectorIcon: "google_calendar", tooltip: "Create, update, and delete events in Google Calendar", }, { label: "Google Drive", tools: ["create_google_drive_file", "delete_google_drive_file"], connectorIcon: "google_drive", tooltip: "Create and delete files in Google Drive", }, { label: "OneDrive", tools: ["create_onedrive_file", "delete_onedrive_file"], connectorIcon: "onedrive", tooltip: "Create and delete files in OneDrive", }, { label: "Dropbox", tools: ["create_dropbox_file", "delete_dropbox_file"], connectorIcon: "dropbox", tooltip: "Create and delete files in Dropbox", }, { label: "Notion", tools: ["create_notion_page", "update_notion_page", "delete_notion_page"], connectorIcon: "notion", tooltip: "Create, update, and delete pages in Notion", }, { label: "Linear", tools: ["create_linear_issue", "update_linear_issue", "delete_linear_issue"], connectorIcon: "linear", tooltip: "Create, update, and delete issues in Linear", }, { label: "Jira", tools: ["create_jira_issue", "update_jira_issue", "delete_jira_issue"], connectorIcon: "jira", tooltip: "Create, update, and delete issues in Jira", }, { label: "Confluence", tools: ["create_confluence_page", "update_confluence_page", "delete_confluence_page"], connectorIcon: "confluence", tooltip: "Create, update, and delete pages in Confluence", }, ]; const EditComposer: FC = () => { return (
); };