import { ActionBarMorePrimitive, ActionBarPrimitive, AuiIf, ErrorPrimitive, MessagePrimitive, useAui, useAuiState, } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, ExternalLink, Globe, MessageSquare, MoreHorizontalIcon, RefreshCwIcon, } from "lucide-react"; import dynamic from "next/dynamic"; import type { FC } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { globalNewLLMConfigsAtom, newLLMConfigsAtom, } from "@/atoms/new-llm-config/new-llm-config-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { CitationMetadataProvider, useAllCitationMetadata, } from "@/components/assistant-ui/citation-metadata-context"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { useTokenUsage } from "@/components/assistant-ui/token-usage-context"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import type { SerializableCitation } from "@/components/tool-ui/citation"; import { openSafeNavigationHref, resolveSafeNavigationHref, } from "@/components/tool-ui/shared/media"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { DropdownMenuLabel } from "@/components/ui/dropdown-menu"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; // Captured once at module load — survives client-side navigations that strip the query param. const IS_QUICK_ASSIST_WINDOW = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("quickAssist") === "true"; // Dynamically import tool UI components to avoid loading them in main bundle const GenerateReportToolUI = dynamic( () => import("@/components/tool-ui/generate-report").then((m) => ({ default: m.GenerateReportToolUI, })), { ssr: false } ); const GenerateResumeToolUI = dynamic( () => import("@/components/tool-ui/generate-resume").then((m) => ({ default: m.GenerateResumeToolUI, })), { ssr: false } ); const GeneratePodcastToolUI = dynamic( () => import("@/components/tool-ui/generate-podcast").then((m) => ({ default: m.GeneratePodcastToolUI, })), { ssr: false } ); const GenerateVideoPresentationToolUI = dynamic( () => import("@/components/tool-ui/video-presentation").then((m) => ({ default: m.GenerateVideoPresentationToolUI, })), { ssr: false } ); const GenerateImageToolUI = dynamic( () => import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })), { ssr: false } ); const UpdateMemoryToolUI = dynamic( () => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })), { ssr: false } ); const SandboxExecuteToolUI = dynamic( () => import("@/components/tool-ui/sandbox-execute").then((m) => ({ default: m.SandboxExecuteToolUI, })), { ssr: false } ); const CreateNotionPageToolUI = dynamic( () => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })), { ssr: false } ); const UpdateNotionPageToolUI = dynamic( () => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })), { ssr: false } ); const DeleteNotionPageToolUI = dynamic( () => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })), { ssr: false } ); const CreateLinearIssueToolUI = dynamic( () => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })), { ssr: false } ); const UpdateLinearIssueToolUI = dynamic( () => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })), { ssr: false } ); const DeleteLinearIssueToolUI = dynamic( () => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })), { ssr: false } ); const CreateGoogleDriveFileToolUI = dynamic( () => import("@/components/tool-ui/google-drive").then((m) => ({ default: m.CreateGoogleDriveFileToolUI, })), { ssr: false } ); const DeleteGoogleDriveFileToolUI = dynamic( () => import("@/components/tool-ui/google-drive").then((m) => ({ default: m.DeleteGoogleDriveFileToolUI, })), { ssr: false } ); const CreateOneDriveFileToolUI = dynamic( () => import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })), { ssr: false } ); const DeleteOneDriveFileToolUI = dynamic( () => import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })), { ssr: false } ); const CreateDropboxFileToolUI = dynamic( () => import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })), { ssr: false } ); const DeleteDropboxFileToolUI = dynamic( () => import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })), { ssr: false } ); const CreateCalendarEventToolUI = dynamic( () => import("@/components/tool-ui/google-calendar").then((m) => ({ default: m.CreateCalendarEventToolUI, })), { ssr: false } ); const UpdateCalendarEventToolUI = dynamic( () => import("@/components/tool-ui/google-calendar").then((m) => ({ default: m.UpdateCalendarEventToolUI, })), { ssr: false } ); const DeleteCalendarEventToolUI = dynamic( () => import("@/components/tool-ui/google-calendar").then((m) => ({ default: m.DeleteCalendarEventToolUI, })), { ssr: false } ); const CreateGmailDraftToolUI = dynamic( () => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })), { ssr: false } ); const UpdateGmailDraftToolUI = dynamic( () => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })), { ssr: false } ); const SendGmailEmailToolUI = dynamic( () => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })), { ssr: false } ); const TrashGmailEmailToolUI = dynamic( () => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })), { ssr: false } ); const CreateJiraIssueToolUI = dynamic( () => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })), { ssr: false } ); const UpdateJiraIssueToolUI = dynamic( () => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })), { ssr: false } ); const DeleteJiraIssueToolUI = dynamic( () => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })), { ssr: false } ); const CreateConfluencePageToolUI = dynamic( () => import("@/components/tool-ui/confluence").then((m) => ({ default: m.CreateConfluencePageToolUI, })), { ssr: false } ); const UpdateConfluencePageToolUI = dynamic( () => import("@/components/tool-ui/confluence").then((m) => ({ default: m.UpdateConfluencePageToolUI, })), { ssr: false } ); const DeleteConfluencePageToolUI = dynamic( () => import("@/components/tool-ui/confluence").then((m) => ({ default: m.DeleteConfluencePageToolUI, })), { ssr: false } ); function extractDomain(url: string): string | undefined { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return undefined; } } function useCitationsFromMetadata(): SerializableCitation[] { const allCitations = useAllCitationMetadata(); return useMemo(() => { const result: SerializableCitation[] = []; for (const [url, meta] of allCitations) { const domain = extractDomain(url); result.push({ id: `url-cite-${url}`, href: url, title: meta.title, snippet: meta.snippet, domain, favicon: domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : undefined, type: "webpage", }); } return result; }, [allCitations]); } const MobileCitationDrawer: FC = () => { const [open, setOpen] = useState(false); const citations = useCitationsFromMetadata(); if (citations.length === 0) return null; const maxIcons = 4; const visible = citations.slice(0, maxIcons); const remainingCount = Math.max(0, citations.length - maxIcons); const handleNavigate = (citation: SerializableCitation) => { const href = resolveSafeNavigationHref(citation.href); if (href) openSafeNavigationHref(href); }; return ( <> Sources
{citations.map((citation) => ( ))}
); }; export const MessageError: FC = () => { return ( ); }; function formatMessageDate(date: Date): string { return date.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true, }); } const MessageInfoDropdown: FC = () => { const messageId = useAuiState(({ message }) => message?.id); const createdAt = useAuiState(({ message }) => message?.createdAt); const usage = useTokenUsage(messageId); const { data: localConfigs } = useAtomValue(newLLMConfigsAtom); const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); const configByModel = useMemo(() => { const map = new Map(); for (const c of [...(globalConfigs ?? []), ...(localConfigs ?? [])]) { map.set(c.model_name, { name: c.name, provider: c.provider }); } return map; }, [localConfigs, globalConfigs]); const resolveModel = (modelKey: string) => { const parts = modelKey.split("/"); const bare = parts[parts.length - 1] ?? modelKey; const config = configByModel.get(modelKey) ?? configByModel.get(bare); return config ? { name: config.name, icon: getProviderIcon(config.provider, { className: "size-3.5" }) } : { name: modelKey, icon: null }; }; const modelBreakdown = usage ? (usage.usage ?? usage.model_breakdown) : undefined; const models = modelBreakdown ? Object.entries(modelBreakdown) : []; const hasUsage = usage && usage.total_tokens > 0; return ( {createdAt && ( {formatMessageDate(createdAt)} )} {hasUsage && ( <> {models.length > 0 ? ( models.map(([model, counts]) => { const { name, icon } = resolveModel(model); return ( e.preventDefault()} > {icon} {name} {counts.total_tokens.toLocaleString()} tokens ); }) ) : ( e.preventDefault()} > {usage.total_tokens.toLocaleString()} tokens )} )} ); }; const AssistantMessageInner: FC = () => { const isMobile = !useMediaQuery("(min-width: 768px)"); return (
null, link_preview: () => null, multi_link_preview: () => null, scrape_webpage: () => null, }, Fallback: ToolFallback, }, }} />
{isMobile && (
)}
); }; function parseMessageId(assistantUiMessageId: string | undefined): number | null { if (!assistantUiMessageId) return null; const match = assistantUiMessageId.match(/^msg-(\d+)$/); return match ? Number.parseInt(match[1], 10) : null; } export const AssistantMessage: FC = () => { const [isSheetOpen, setIsSheetOpen] = useState(false); const [isInlineOpen, setIsInlineOpen] = useState(false); const messageRef = useRef(null); const commentPanelRef = useRef(null); const commentTriggerRef = useRef(null); const messageId = useAuiState(({ message }) => message?.id); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const dbMessageId = parseMessageId(messageId); const commentsEnabled = useAtomValue(commentsEnabledAtom); // Desktop: >= 1024px (inline expandable), Medium: 768px-1023px (right sheet), Mobile: <768px (bottom sheet) const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)"); const isDesktop = useMediaQuery("(min-width: 1024px)"); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; const { data: commentsData, isSuccess: commentsLoaded } = useComments({ messageId: dbMessageId ?? 0, enabled: !!dbMessageId, }); const targetCommentId = useAtomValue(targetCommentIdAtom); const hasTargetComment = useMemo(() => { if (!targetCommentId || !commentsData?.comments) return false; return commentsData.comments.some( (c) => c.id === targetCommentId || c.replies?.some((r) => r.id === targetCommentId) ); }, [targetCommentId, commentsData]); const commentCount = commentsData?.total_count ?? 0; const hasComments = commentCount > 0; const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId; // Close floating panel when clicking outside (but not on portaled popover/dropdown content) useEffect(() => { if (!isInlineOpen) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Element; if ( commentPanelRef.current?.contains(target) || commentTriggerRef.current?.contains(target) || target.closest?.("[data-radix-popper-content-wrapper]") ) return; setIsInlineOpen(false); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isInlineOpen]); // Auto-open floating panel on desktop when this message has the target comment useEffect(() => { if (hasTargetComment && isDesktop && commentsLoaded) { setIsInlineOpen(true); const timeoutId = setTimeout(() => { messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); }, 100); return () => clearTimeout(timeoutId); } }, [hasTargetComment, isDesktop, commentsLoaded]); // Auto-open sheet on mobile/tablet when this message has the target comment useEffect(() => { if (hasTargetComment && !isDesktop && commentsLoaded) { setIsSheetOpen(true); } }, [hasTargetComment, isDesktop, commentsLoaded]); const sheetSide = isMediumScreen ? "right" : "bottom"; return ( {/* Comment trigger — right-aligned, just below user query on all screen sizes */} {showCommentTrigger && (
)} {/* Desktop floating comment panel — overlays on top of chat content */} {showCommentTrigger && isDesktop && isInlineOpen && dbMessageId && (
)} {/* Comment sheet — bottom for mobile, right for medium screens */} {showCommentTrigger && !isDesktop && ( )}
); }; const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); const aui = useAui(); const api = useElectronAPI(); const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW; return ( message.isCopied}> !message.isCopied}> {isLast && ( )} {isQuickAssist && ( { const text = aui.message().getCopyText(); api?.replaceText(text); }} > )} ); };