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, Dot, 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 { 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 { 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 { Skeleton } from "@/components/ui/skeleton"; 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 { useElectronAPI } from "@/hooks/use-platform"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; import { cn } from "@/lib/utils"; const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; type ComposerFilesystemSettings = { mode: "cloud" | "desktop_local_folder"; localRootPath: string | null; updatedAt: string; }; export const Thread: FC = () => { return ; }; const ThreadContent: FC = () => { return ( thread.isEmpty}> !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 documentPickerRef = useRef(null); const promptPickerRef = useRef(null); const viewportRef = 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?.(); }; }, []); // Store viewport element reference on mount useEffect(() => { viewportRef.current = document.querySelector(".aui-thread-viewport"); }, []); const electronAPI = useElectronAPI(); const [filesystemSettings, setFilesystemSettings] = useState( null ); const [clipboardInitialText, setClipboardInitialText] = useState(); const clipboardLoadedRef = useRef(false); useEffect(() => { if (!electronAPI || clipboardLoadedRef.current) return; clipboardLoadedRef.current = true; electronAPI.getQuickAskText().then((text: string) => { if (text) { setClipboardInitialText(text); } }); }, [electronAPI]); useEffect(() => { if (!electronAPI?.getAgentFilesystemSettings) return; let mounted = true; electronAPI .getAgentFilesystemSettings() .then((settings) => { if (!mounted) return; setFilesystemSettings(settings); }) .catch(() => { if (!mounted) return; setFilesystemSettings({ mode: "cloud", localRootPath: null, updatedAt: new Date().toISOString(), }); }); return () => { mounted = false; }; }, [electronAPI]); const handleFilesystemModeChange = useCallback( async (mode: "cloud" | "desktop_local_folder") => { if (!electronAPI?.setAgentFilesystemSettings) return; const updated = await electronAPI.setAgentFilesystemSettings({ mode }); setFilesystemSettings(updated); }, [electronAPI] ); const handlePickFilesystemRoot = useCallback(async () => { if (!electronAPI?.pickAgentFilesystemRoot || !electronAPI?.setAgentFilesystemSettings) return; const picked = await electronAPI.pickAgentFilesystemRoot(); if (!picked) return; const updated = await electronAPI.setAgentFilesystemSettings({ mode: "desktop_local_folder", localRootPath: picked, }); setFilesystemSettings(updated); }, [electronAPI]); const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); const currentPlaceholder = COMPOSER_PLACEHOLDER; // 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; editorRef.current?.setText(finalPrompt); aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); }, [actionQuery, aui] ); const handleQuickAskSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { if (!clipboardInitialText) return; electronAPI?.setQuickAskMode(action.mode); const finalPrompt = action.prompt.includes("{selection}") ? action.prompt.replace("{selection}", () => clipboardInitialText) : `${action.prompt}\n\n${clipboardInitialText}`; editorRef.current?.setText(finalPrompt); aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); setClipboardInitialText(undefined); }, [clipboardInitialText, electronAPI, aui] ); // 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) return; if (clipboardInitialText) { const userText = editorRef.current?.getText() ?? ""; const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText; aui.composer().setText(combined); setClipboardInitialText(undefined); } const viewportEl = viewportRef.current; 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). // Poll via rAF for ~500ms, re-scrolling whenever scrollHeight changes. const scrollToBottom = () => threadViewportStore.getState().scrollToBottom({ behavior: "instant" }); let lastHeight = heightBefore; let frames = 0; let cancelled = false; const POLL_FRAMES = 30; const pollAndScroll = () => { if (cancelled) return; const el = viewportRef.current; 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); submitCleanupRef.current = () => { cancelled = true; clearTimeout(t1); clearTimeout(t2); }; }, [ 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 ( {electronAPI && filesystemSettings ? (
) : null} {showDocumentPopover && (
{ setShowDocumentPopover(false); setMentionQuery(""); }} initialSelectedDocuments={mentionedDocuments} externalSearch={mentionQuery} />
)} {showPromptPicker && (
{ setShowPromptPicker(false); setActionQuery(""); }} externalSearch={actionQuery} />
)}
{clipboardInitialText && ( setClipboardInitialText(undefined)} /> )}
); }; 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 toolsRafRef = useRef(undefined); const handleToolsScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; if (toolsRafRef.current) return; toolsRafRef.current = requestAnimationFrame(() => { const atTop = el.scrollTop <= 2; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); toolsRafRef.current = undefined; }); }, []); useEffect( () => () => { if (toolsRafRef.current) cancelAnimationFrame(toolsRafRef.current); }, [] ); 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 && (
{["t1", "t2", "t3", "t4"].map((k) => (
))} {["c1", "c2", "c3"].map((k) => (
))}
)}
) : ( 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-50 sm:scale-[0.6]" />
); 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-50 sm:scale-[0.6]" />
); return ( {row} {groupDef?.tooltip ?? group.tools.flatMap((t, i) => i === 0 ? [t.description] : [ , t.description, ] )} ); })}
)} {!filteredTools?.length && (
{["dt1", "dt2", "dt3", "dt4"].map((k) => (
))} {["dc1", "dc2", "dc3"].map((k) => (
))}
)}
)} {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: ["update_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 (
); };