/* Copyright 2026 Marimo. All rights reserved. */ import type { UIMessage } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react"; import { storePrompt } from "@marimo-team/codemirror-ai"; import type { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { DefaultChatTransport, type FileUIPart, type TextUIPart } from "ai"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { BotMessageSquareIcon, HatGlasses, Loader2, type LucideIcon, MessageCircleIcon, NotebookText, PlusIcon, SettingsIcon, } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; import useEvent from "react-use-event-hook"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, } from "@/components/ui/select"; import { replaceMessagesInChat } from "@/core/ai/chat-utils"; import { useModelChange } from "@/core/ai/config"; import { AiModelId } from "@/core/ai/ids/ids"; import { useStagedAICellsActions } from "@/core/ai/staged-cells"; import { activeChatAtom, type Chat, type ChatId, chatStateAtom, } from "@/core/ai/state"; import type { ToolNotebookContext } from "@/core/ai/tools/base"; import { type CopilotMode, FRONTEND_TOOL_REGISTRY, } from "@/core/ai/tools/registry"; import { useCellActions } from "@/core/cells/cells"; import { aiAtom, aiEnabledAtom } from "@/core/config/config"; import { DEFAULT_AI_MODEL } from "@/core/config/config-schema"; import { useRequestClient } from "@/core/network/requests"; import { useRuntimeManager } from "@/core/runtime/config"; import { ErrorBanner } from "@/plugins/impl/common/error-banner"; import { cn } from "@/utils/cn"; import { Logger } from "@/utils/Logger"; import { AIModelDropdown } from "../ai/ai-model-dropdown"; import { useOpenSettingsToTab } from "../app-config/state"; import { PromptInput } from "../editor/ai/add-cell-with-ai"; import { addContextCompletion, CONTEXT_TRIGGER, } from "../editor/ai/completion-utils"; import { PanelEmptyState } from "../editor/chrome/panels/empty-state"; import { CopyClipboardIcon } from "../icons/copy-icon"; import { MCPStatusIndicator } from "../mcp/mcp-status-indicator"; import { Tooltip, TooltipProvider } from "../ui/tooltip"; import { AddContextButton, AttachFileButton, AttachmentRenderer, FileAttachmentPill, SendButton, } from "./chat-components"; import { renderUIMessage } from "./chat-display"; import { ChatHistoryPopover } from "./chat-history-popover"; import { buildCompletionRequestBody, convertToFileUIPart, generateChatTitle, handleToolCall, hasPendingToolCalls, isLastMessageReasoning, PROVIDERS_THAT_SUPPORT_ATTACHMENTS, useFileState, } from "./chat-utils"; // Default mode for the AI const DEFAULT_MODE = "manual"; interface ChatHeaderProps { onNewChat: () => void; activeChatId: ChatId | undefined; setActiveChat: (id: ChatId | null) => void; } const ChatHeader: React.FC = ({ onNewChat, activeChatId, setActiveChat, }) => { const { handleClick } = useOpenSettingsToTab(); return (
); }; interface ChatMessageProps { message: UIMessage; index: number; onEdit: (index: number, newValue: string) => void; isStreamingReasoning: boolean; isLast: boolean; } const ChatMessageDisplay: React.FC = memo( ({ message, index, onEdit, isStreamingReasoning, isLast }) => { const renderUserMessage = (message: UIMessage) => { const textParts = message.parts?.filter( (p): p is TextUIPart => p.type === "text", ); const content = textParts?.map((p) => p.text).join("\n"); const fileParts = message.parts?.filter( (p): p is FileUIPart => p.type === "file", ); return (
{fileParts?.map((filePart, idx) => ( ))} { // noop }} onSubmit={(_e, newValue) => { if (!newValue.trim()) { return; } onEdit(index, newValue); }} onClose={() => { // noop }} />
); }; const renderOtherMessage = (message: UIMessage) => { const textParts = message.parts.filter( (p): p is TextUIPart => p.type === "text", ); const content = textParts.map((p) => p.text).join("\n"); return (
{renderUIMessage({ message, isStreamingReasoning, isLast })}
); }; return (
{message.role === "user" ? renderUserMessage(message) : renderOtherMessage(message)}
); }, ); ChatMessageDisplay.displayName = "ChatMessage"; interface ChatInputFooterProps { isEmpty: boolean; onSendClick: () => void; isLoading: boolean; onStop: () => void; onAddFiles: (files: File[]) => void; fileInputRef: React.RefObject; onAddContext: () => void; } const ChatInputFooter: React.FC = memo( ({ isEmpty, onSendClick, isLoading, onStop, fileInputRef, onAddFiles, onAddContext, }) => { const ai = useAtomValue(aiAtom); const currentMode = ai?.mode || DEFAULT_MODE; const currentModel = ai?.models?.chat_model || DEFAULT_AI_MODEL; const currentProvider = AiModelId.parse(currentModel).providerId; const { saveModeChange } = useModelChange(); const modeOptions: { value: CopilotMode; label: string; subtitle: string; Icon: LucideIcon; }[] = [ { value: "manual", label: "Manual", subtitle: "Pure chat, no tool usage", Icon: MessageCircleIcon, }, { value: "ask", label: "Ask", subtitle: "Use AI with access to read-only tools like documentation search", Icon: NotebookText, }, { value: "agent", label: "Agent (beta)", subtitle: "Use AI with access to read and write tools", Icon: HatGlasses, }, ]; const isAttachmentSupported = PROVIDERS_THAT_SUPPORT_ATTACHMENTS.has(currentProvider); const CurrentModeIcon = modeOptions.find( (o) => o.value === currentMode, )?.Icon; return (
{isAttachmentSupported && ( )}
); }, ); ChatInputFooter.displayName = "ChatInputFooter"; interface ChatInputProps { placeholder?: string; input: string; inputClassName?: string; setInput: (value: string) => void; onSubmit: (e: KeyboardEvent | undefined, value: string) => void; inputRef: React.RefObject; isLoading: boolean; onStop: () => void; onClose: () => void; fileInputRef: React.RefObject; onAddFiles: (files: File[]) => void; } const ChatInput: React.FC = memo( ({ placeholder, input, inputClassName, setInput, onSubmit, inputRef, isLoading, onStop, fileInputRef, onAddFiles, onClose, }) => { const handleSendClick = useEvent(() => { if (input.trim()) { onSubmit(undefined, input); } }); return (
addContextCompletion(inputRef)} onSendClick={handleSendClick} isLoading={isLoading} onStop={onStop} fileInputRef={fileInputRef} onAddFiles={onAddFiles} />
); }, ); ChatInput.displayName = "ChatInput"; const ChatPanel = () => { const aiConfigured = useAtomValue(aiEnabledAtom); const { handleClick } = useOpenSettingsToTab(); if (!aiConfigured) { return ( handleClick("ai", "ai-providers")} > Edit AI settings } icon={} /> ); } return ; }; const ChatPanelBody = () => { const setChatState = useSetAtom(chatStateAtom); const [activeChat, setActiveChat] = useAtom(activeChatAtom); const [input, setInput] = useState(""); const [newThreadInput, setNewThreadInput] = useState(""); const { files, addFiles, clearFiles, removeFile } = useFileState(); const newThreadInputRef = useRef(null); const newMessageInputRef = useRef(null); const scrollContainerRef = useRef(null); const fileInputRef = useRef(null); const messagesEndRef = useRef(null); const runtimeManager = useRuntimeManager(); const { invokeAiTool, sendRun } = useRequestClient(); const activeChatId = activeChat?.id; const store = useStore(); const { addStagedCell } = useStagedAICellsActions(); const { createNewCell, prepareForRun } = useCellActions(); const toolContext: ToolNotebookContext = { addStagedCell, createNewCell, prepareForRun, sendRun, store, }; const { messages, sendMessage, error, status, regenerate, stop, addToolOutput, id: chatId, } = useChat({ id: activeChatId, sendAutomaticallyWhen: ({ messages }) => hasPendingToolCalls(messages), messages: activeChat?.messages || [], // initial messages transport: new DefaultChatTransport({ api: runtimeManager.getAiURL("chat").toString(), headers: () => runtimeManager.headers(), prepareSendMessagesRequest: async (options) => { const completionBody = await buildCompletionRequestBody( options.messages, ); // Call this here to ensure the value is not stale const chatMode = store.get(aiAtom)?.mode || DEFAULT_MODE; const tools = FRONTEND_TOOL_REGISTRY.getToolSchemas(chatMode); return { api: runtimeManager.getAiURL("chat").toString(), body: { tools, ...options, ...completionBody, }, }; }, }), onFinish: ({ messages }) => { setChatState((prev) => { return replaceMessagesInChat({ chatState: prev, chatId: prev.activeChatId, messages: messages, }); }); }, onToolCall: async ({ toolCall }) => { // Dynamic tool calls will throw an error for toolName // https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage#client-side-page if (toolCall.dynamic) { Logger.debug("Skipping dynamic tool call", toolCall); return; } await handleToolCall({ invokeAiTool, addToolOutput, toolCall: { toolName: toolCall.toolName, toolCallId: toolCall.toolCallId, input: toolCall.input as Record, }, toolContext, }); }, onError: (error) => { Logger.error("An error occurred:", error); }, }); const isLoading = status === "submitted" || status === "streaming"; // Check if we're currently streaming reasoning in the latest message const isStreamingReasoning = isLoading && messages.length > 0 && isLastMessageReasoning(messages); // Scroll to the latest chat message at the bottom useEffect(() => { const scrollToBottom = () => { if (scrollContainerRef.current) { const container = scrollContainerRef.current; container.scrollTop = container.scrollHeight; } }; requestAnimationFrame(scrollToBottom); }, [activeChatId]); const createNewThread = async ( initialMessage: string, initialAttachments?: File[], ) => { const now = Date.now(); const newChat: Chat = { id: chatId as ChatId, title: generateChatTitle(initialMessage), messages: [], createdAt: now, updatedAt: now, }; // Create new chat and set as active setChatState((prev) => { const newChats = new Map(prev.chats); newChats.set(newChat.id, newChat); const newState = { ...prev, chats: newChats, activeChatId: newChat.id, }; return newState; }); const fileParts = initialAttachments && initialAttachments.length > 0 ? await convertToFileUIPart(initialAttachments) : undefined; // Trigger AI conversation with append sendMessage({ role: "user", parts: [ { type: "text" as const, text: initialMessage, }, ...(fileParts ?? []), ], }); clearFiles(); setInput(""); }; const handleNewChat = useEvent(() => { setActiveChat(null); setInput(""); setNewThreadInput(""); clearFiles(); }); const handleMessageEdit = useEvent((index: number, newValue: string) => { const editedMessage = messages[index]; const fileParts = editedMessage.parts?.filter((p) => p.type === "file"); const messageId = editedMessage.id; sendMessage({ messageId: messageId, // replace the message role: "user", parts: [{ type: "text", text: newValue }, ...fileParts], }); }); const handleChatInputSubmit = useEvent( async (e: KeyboardEvent | undefined, newValue: string): Promise => { if (!newValue.trim()) { return; } if (newMessageInputRef.current?.view) { storePrompt(newMessageInputRef.current.view); } const fileParts = files ? await convertToFileUIPart(files) : undefined; e?.preventDefault(); sendMessage({ text: newValue, files: fileParts, }); setInput(""); clearFiles(); }, ); const handleReload = () => { regenerate(); }; const handleNewThreadSubmit = useEvent(() => { if (!newThreadInput.trim()) { return; } if (newThreadInputRef.current?.view) { storePrompt(newThreadInputRef.current.view); } createNewThread(newThreadInput.trim(), files); }); const handleOnCloseThread = () => newThreadInputRef.current?.editor?.blur(); const isNewThread = messages.length === 0; const chatInput = isNewThread ? ( ) : ( newMessageInputRef.current?.editor?.blur()} fileInputRef={fileInputRef} onAddFiles={addFiles} /> ); const filesPills = files && files.length > 0 && (
{files?.map((file) => ( removeFile(file)} /> ))}
); return (
{isNewThread && (
{filesPills} {chatInput}
)} {messages.map((message, idx) => ( ))} {isLoading && (
)} {error && (
)}
{isLoading && (
)} {/* For existing threads, we place the chat input at the bottom */} {!isNewThread && ( <> {filesPills} {chatInput} )}
); }; export default ChatPanel;