/* 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 { type ChatAddToolApproveResponseFunction, type FileUIPart, safeValidateUIMessages, type TextUIPart, } from "ai"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { BotMessageSquareIcon, HatGlasses, ArrowRightIcon, Loader2, type LucideIcon, MessageCircleIcon, NotebookText, PlusIcon, SparklesIcon, SettingsIcon, CodeIcon, } 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 { useAIConfigActions } from "@/core/ai/config"; import { AI_SDK_UI_THROTTLE_MS } from "@/core/ai/constants"; import { AiModelId } from "@/core/ai/ids/ids"; import { useStagedAICellsActions } from "@/core/ai/staged-cells"; import { activeChatAtom, type Chat, type ChatId, chatOptionsAtom, chatStateAtom, pendingAiPromptAtom, } 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, aiModelConfiguredAtom } 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 { isWasm } from "@/core/wasm/utils"; 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 { PairWithAgentBanner, PairWithAgentButton, } from "../editor/actions/pair-with-agent"; import { PairWithAgentModal } from "../editor/actions/pair-with-agent-modal"; import { PromptInput } from "../editor/ai/add-cell-with-ai"; import { addContextCompletion, CONTEXT_TRIGGER, isContextAttachment, resolveChatContext, } from "../editor/ai/completion-utils"; import { StreamingChunkTransport } from "../editor/ai/transport/chat-transport"; import { PanelEmptyState } from "../editor/chrome/panels/empty-state"; import { CopyClipboardIcon } from "../icons/copy-icon"; import { useImperativeModal } from "../modal/ImperativeModal"; import { MCPStatusIndicator } from "../mcp/mcp-status-indicator"; import { Tooltip, TooltipProvider } from "../ui/tooltip"; import { AddContextButton, AttachFileButton, AttachmentRenderer, FileAttachmentPill, SendButton, } from "./chat-components"; import { describeChatAbortReason, getAbortReasonFromChunk, resolveChatAbortReason, } from "./chat-abort"; import { renderUIMessage } from "./chat-display"; import { ChatHistoryPopover } from "./chat-history-popover"; import { CapabilitiesPopover } from "./capabilities-popover"; import { type ChatMessagePart, convertToFileUIPart, generateChatTitle, handleToolCall, hasPendingToolCalls, hasUnresolvedToolCalls, isLastMessageReasoning, PROVIDERS_THAT_SUPPORT_ATTACHMENTS, type QueuedUserMessage, shouldFlushQueue, useFileState, useMessageQueue, } from "./chat-utils"; import { getCodes } from "@/core/codemirror/copilot/getCodes"; import { focusInputAndMoveToEnd } from "@/core/codemirror/utils"; import ScrollToBottomButton from "./acp/scroll-to-bottom-button"; import { useChatControllerId } from "./use-chat-controller-id"; // Default mode for the AI const DEFAULT_MODE = "manual"; const QueuedMessageDisplay = memo( ({ message }: { message: QueuedUserMessage }) => { 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) => ( ))}
{content}
); }, ); QueuedMessageDisplay.displayName = "QueuedMessageDisplay"; 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; isActive: boolean; addToolApprovalResponse?: ChatAddToolApproveResponseFunction; } const ChatMessageDisplay: React.FC = memo( ({ message, index, onEdit, isStreamingReasoning, isLast, isActive, addToolApprovalResponse, }) => { 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, isActive, addToolApprovalResponse, })}
); }; 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 } = useAIConfigActions(); 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: "AI with access to read-only tools like documentation search", Icon: NotebookText, }, { value: "agent", label: "Agent", subtitle: "AI with access to read and write tools", Icon: HatGlasses, }, { value: "code_mode", label: "Code Mode (beta)", subtitle: "AI with access to the notebook's kernel. Potentially destructive.", Icon: CodeIcon, }, ]; const isAttachmentSupported = PROVIDERS_THAT_SUPPORT_ATTACHMENTS.has(currentProvider); const currentModeOption = modeOptions.find((o) => o.value === currentMode); const CurrentModeIcon = currentModeOption?.Icon; const CurrentModeLabel = currentModeOption?.label; 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 PairWithAgentCallout: React.FC<{ onPairWithAgent: () => void; }> = ({ onPairWithAgent }) => { if (isWasm()) { return null; } return ( ); }; const ChatPanel = () => { const aiConfigured = useAtomValue(aiModelConfiguredAtom); const { handleClick } = useOpenSettingsToTab(); if (!aiConfigured) { return ( } icon={} /> ); } return ; }; const ChatPanelBody = () => { const setChatState = useSetAtom(chatStateAtom); const [activeChat, setActiveChat] = useAtom(activeChatAtom); const [pendingPrompt, setPendingPrompt] = useAtom(pendingAiPromptAtom); const [input, setInput] = useState(""); const [newThreadInput, setNewThreadInput] = useState(""); const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); const { files, addFiles, clearFiles, removeFile } = useFileState(); const { messages: queuedMessages, enqueue: enqueueUserMessage, flushNext: flushNextQueuedMessage, clear: clearQueuedMessages, hasQueuedRef, } = useMessageQueue(); const newThreadInputRef = useRef(null); const newMessageInputRef = useRef(null); const scrollContainerRef = useRef(null); const fileInputRef = useRef(null); const lastAbortReasonRef = useRef(null); const [abortNotice, setAbortNotice] = useState(null); const clearAbortState = useEvent(() => { lastAbortReasonRef.current = null; setAbortNotice(null); }); const runtimeManager = useRuntimeManager(); const { invokeAiTool, sendRun } = useRequestClient(); const { openModal, closeModal } = useImperativeModal(); const activeChatId = activeChat?.id; const { chatControllerId, renewDraftChatId } = useChatControllerId(activeChatId); 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, addToolApprovalResponse, id: chatId, } = useChat({ id: chatControllerId, throttle: AI_SDK_UI_THROTTLE_MS, sendAutomaticallyWhen: ({ messages }) => hasPendingToolCalls(messages), messages: activeChat?.messages || [], // initial messages transport: new StreamingChunkTransport( { api: runtimeManager.getAiURL("chat").toString(), headers: () => runtimeManager.headers(), prepareSendMessagesRequest: async (options) => { // Canary: flag outgoing messages that don't match the AI SDK's own // schema. The server-side sanitizer in `_pydantic_ai_utils.py` corrects these before validation; // this log surfaces drift early without affecting the request. const validation = await safeValidateUIMessages({ messages: options.messages, }); if (!validation.success) { Logger.debug( "Outgoing chat messages failed AI SDK schema validation", validation.error, ); } const completionBody = { uiMessages: options.messages, includeOtherCode: getCodes(""), options: store.get(chatOptionsAtom), }; // 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, }, }; }, }, (chunk) => { const abortReason = getAbortReasonFromChunk(chunk); if (abortReason !== undefined) { lastAbortReasonRef.current = abortReason; } }, ), onFinish: ({ messages, isError, isAbort, finishReason }) => { setChatState((prev) => { return replaceMessagesInChat({ chatState: prev, chatId: prev.activeChatId, messages: messages, }); }); if (isAbort) { const reason = resolveChatAbortReason({ isAbort: true, streamReason: lastAbortReasonRef.current, }); clearAbortState(); if (reason != null) { const description = describeChatAbortReason(reason); Logger.debug("Chat stream aborted", { reason, finishReason }); setAbortNotice(description); } } else { clearAbortState(); } tryFlushQueuedMessages(messages, { isError, isAbort }); }, onToolCall: async ({ toolCall }) => { 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 sendUserMessage = useEvent((parts: ChatMessagePart[]) => { clearAbortState(); sendMessage({ role: "user", parts }); }); const tryFlushQueuedMessages = useEvent( ( chatMessages: typeof messages, opts: { isError: boolean; isAbort: boolean }, ) => { if (!hasQueuedRef.current) { return; } if ( shouldFlushQueue({ isError: opts.isError, isAbort: opts.isAbort, hasPendingToolCalls: hasPendingToolCalls(chatMessages), hasUnresolvedToolCalls: hasUnresolvedToolCalls(chatMessages), }) ) { flushNextQueuedMessage(sendUserMessage); } }, ); const isLoading = status === "submitted" || status === "streaming"; // Read via a ref so the queue-vs-send decision stays correct even when it is // made after an `await` (e.g. resolving @-context), by which point the render // closure's `isLoading` may be stale. const isLoadingRef = useRef(isLoading); isLoadingRef.current = isLoading; const submitOrQueue = useEvent((parts: ChatMessagePart[]) => { if (isLoadingRef.current || hasQueuedRef.current) { enqueueUserMessage(parts); } else { sendUserMessage(parts); } }); const handleScroll = useEvent(() => { const container = scrollContainerRef.current; if (!container) { return; } const { scrollTop, scrollHeight, clientHeight } = container; const hasOverflow = scrollHeight > clientHeight; const isAtBottom = hasOverflow ? Math.abs(scrollHeight - clientHeight - scrollTop) < 5 : true; setIsScrolledToBottom(isAtBottom); }); const scrollToBottom = useEvent((smooth = false) => { const container = scrollContainerRef.current; if (!container) { return; } container.scrollTo({ top: container.scrollHeight, behavior: smooth ? "smooth" : "auto", }); }); // Check if we're currently streaming reasoning in the latest message const isStreamingReasoning = isLoading && messages.length > 0 && isLastMessageReasoning(messages); // Pin to the bottom while the user is already there. useEffect(() => { setIsScrolledToBottom(true); clearQueuedMessages(); clearAbortState(); }, [activeChatId, clearQueuedMessages, clearAbortState]); useEffect(() => { if (!isScrolledToBottom) { return; } const frame = requestAnimationFrame(() => { scrollToBottom(); }); return () => cancelAnimationFrame(frame); }, [messages, queuedMessages, isLoading, isScrolledToBottom, scrollToBottom]); // Retry when tool parts resolve after the stream ends (e.g. assistant text // trails a still-running tool call, so `onFinish` alone cannot flush). useEffect(() => { if (isLoading) { return; } tryFlushQueuedMessages(messages, { isError: error != null, isAbort: false, }); }, [messages, isLoading, error, tryFlushQueuedMessages]); const startNewChatState = useEvent((initialMessage: string) => { const now = Date.now(); const newChat: Chat = { id: chatId as ChatId, title: generateChatTitle(initialMessage), messages: [], createdAt: now, updatedAt: now, }; setChatState((prev) => { const newChats = new Map(prev.chats); newChats.set(newChat.id, newChat); return { ...prev, chats: newChats, activeChatId: newChat.id, }; }); }); const createNewThread = useEvent( async (initialMessage: string, initialAttachments?: File[]) => { startNewChatState(initialMessage); const fileParts = initialAttachments && initialAttachments.length > 0 ? await convertToFileUIPart(initialAttachments) : undefined; const { contextPart, attachments } = await resolveChatContext(initialMessage); sendMessage({ role: "user", parts: [ { type: "text" as const, text: initialMessage }, ...(contextPart ? [contextPart] : []), ...(fileParts ?? []), ...attachments, ], }); clearFiles(); setInput(""); }, ); const handleNewChat = useEvent(() => { renewDraftChatId(); setActiveChat(null); setInput(""); setNewThreadInput(""); clearQueuedMessages(); clearFiles(); }); const handlePairWithAgent = useEvent(() => { openModal(); }); const handleMessageEdit = useEvent( async (index: number, newValue: string) => { const editedMessage = messages[index]; // Keep the user's own uploaded files, but drop the previous @-context // snapshot (data part + its attachments) so we can re-resolve a fresh, // point-in-time snapshot from the edited text below. const userFileParts = editedMessage.parts?.filter( (p) => p.type === "file" && !isContextAttachment(p), ) ?? []; const { contextPart, attachments } = await resolveChatContext(newValue); const messageId = editedMessage.id; sendMessage({ messageId: messageId, // replace the message role: "user", parts: [ { type: "text", text: newValue }, ...(contextPart ? [contextPart] : []), ...userFileParts, ...attachments, ], }); }, ); 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; const { contextPart, attachments } = await resolveChatContext(newValue); e?.preventDefault(); submitOrQueue([ { type: "text", text: newValue }, ...(contextPart ? [contextPart] : []), ...(fileParts ?? []), ...attachments, ]); setInput(""); clearFiles(); }, ); const handleReload = () => { clearAbortState(); 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 submitPendingPrompt = useEvent(async (prompt: string) => { if (activeChatId == null) { startNewChatState(prompt); // Starting a chat swaps the new-thread input for the regular input; // carry over any draft the user had typed so it isn't lost. setInput(newThreadInput); } const { contextPart, attachments } = await resolveChatContext(prompt); submitOrQueue([ { type: "text", text: prompt }, ...(contextPart ? [contextPart] : []), ...attachments, ]); }); const isNewThread = messages.length === 0; // Deliver a prompt queued elsewhere (e.g. error auto-fix) to the chat, // appending to the active thread or starting one if none exists. useEffect(() => { if (!pendingPrompt) { return; } setPendingPrompt(null); const { prompt, submit } = pendingPrompt; if (submit) { void submitPendingPrompt(prompt); } else if (isNewThread) { setNewThreadInput(prompt); focusInputAndMoveToEnd(newThreadInputRef); } else { setInput(prompt); focusInputAndMoveToEnd(newMessageInputRef); } }, [pendingPrompt, setPendingPrompt, isNewThread, submitPendingPrompt]); 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) => ( ))} {queuedMessages.map((message) => ( ))} {isLoading && (
)} {error && (
)} {abortNotice && !isLoading && !error && (
{abortNotice}
)}
0 || queuedMessages.length > 0) } onScrollToBottom={() => scrollToBottom(true)} />
{isLoading && (
)} {/* For existing threads, we place the chat input at the bottom */} {!isNewThread && ( <> {filesPills} {chatInput} )}
); }; export default ChatPanel;