/* 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 && (
)}
{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;