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 ? (
) : (
)}
{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 ? (
) : (
)}
{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 && (
)}
!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 (
);
};