/* Copyright 2026 Marimo. All rights reserved. */ import { useAtom } from "jotai"; import { BotMessageSquareIcon, RefreshCwIcon, StopCircleIcon, } from "lucide-react"; import React, { memo, useEffect, useMemo, useRef, useState } from "react"; import useEvent from "react-use-event-hook"; import { JsonRpcError, useAcpClient } from "use-acp"; import { ConnectionStatus, PermissionRequest, } from "@/components/chat/acp/common"; import { type AdditionalCompletions, PromptInput, } from "@/components/editor/ai/add-cell-with-ai"; import { PanelEmptyState } from "@/components/editor/chrome/panels/empty-state"; import { Spinner } from "@/components/icons/spinner"; import { Button } from "@/components/ui/button"; import { TooltipProvider } from "@/components/ui/tooltip"; import { cn } from "@/utils/cn"; import { Logger } from "@/utils/Logger"; import { capitalize } from "@/utils/strings"; import { AgentDocs } from "./agent-docs"; import { AgentSelector } from "./agent-selector"; import { ModelSelector } from "./model-selector"; import ScrollToBottomButton from "./scroll-to-bottom-button"; import { SessionTabs } from "./session-tabs"; import { agentSessionStateAtom, type ExternalAgentId, getAgentWebSocketUrl, selectedTabAtom, updateSessionExternalAgentSessionId, updateSessionTitle, } from "./state"; import { AgentThread } from "./thread"; import "./agent-panel.css"; import type { Completion } from "@codemirror/autocomplete"; import type { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import type { ContentBlock, RequestPermissionResponse, } from "@zed-industries/agent-client-protocol"; import { addContextCompletion, CONTEXT_TRIGGER, } from "@/components/editor/ai/completion-utils"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, } from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { DelayMount } from "@/components/utils/delay-mount"; import { useRequestClient } from "@/core/network/requests"; import { cwdAtom, filenameAtom } from "@/core/saving/file-state"; import { store } from "@/core/state/jotai"; import { ErrorBanner } from "@/plugins/impl/common/error-banner"; import { Functions } from "@/utils/functions"; import { PathBuilder, Paths } from "@/utils/paths"; import { AddContextButton, AttachFileButton, FileAttachmentPill, SendButton, } from "../chat-components"; import { useFileState } from "../chat-utils"; import { ReadyToChatBlock } from "./blocks"; import { convertFilesToResourceLinks, parseContextFromPrompt, } from "./context-utils"; import { getAgentPrompt } from "./prompt"; import type { AgentConnectionState, AgentPendingPermission, AvailableCommands, ExternalAgentSessionId, NotificationEvent, SessionMode, SessionModelState, } from "./types"; const logger = Logger.get("agents"); interface AgentTitleProps { currentAgentId?: ExternalAgentId; } const AgentTitle = memo(({ currentAgentId }) => ( {capitalize(currentAgentId ?? "")} )); AgentTitle.displayName = "AgentTitle"; interface ConnectionControlProps { connectionState: AgentConnectionState; onConnect: () => void; onDisconnect: () => void; } const ConnectionControl = memo( ({ connectionState, onConnect, onDisconnect }) => { const isConnected = connectionState.status === "connected"; return ( ); }, ); ConnectionControl.displayName = "ConnectionControl"; interface HeaderInfoProps { currentAgentId?: ExternalAgentId; connectionStatus: string; shouldShowConnectionControl?: boolean; } const HeaderInfo = memo( ({ currentAgentId, connectionStatus, shouldShowConnectionControl }) => (
{currentAgentId && } {shouldShowConnectionControl && ( )}
), ); HeaderInfo.displayName = "HeaderInfo"; interface AgentPanelHeaderProps { connectionState: AgentConnectionState; currentAgentId?: ExternalAgentId; onConnect: () => void; onDisconnect: () => void; onRestartThread?: () => void; hasActiveSession?: boolean; shouldShowConnectionControl?: boolean; } const AgentPanelHeader = memo( ({ connectionState, currentAgentId, onConnect, onDisconnect, onRestartThread, hasActiveSession, shouldShowConnectionControl, }) => (
{hasActiveSession && connectionState.status === "connected" && onRestartThread && ( )} {shouldShowConnectionControl && ( )}
), ); AgentPanelHeader.displayName = "AgentPanelHeader"; interface EmptyStateProps { currentAgentId?: ExternalAgentId; connectionState: AgentConnectionState; onConnect: () => void; onDisconnect: () => void; } const EmptyState = memo( ({ currentAgentId, connectionState, onConnect, onDisconnect }) => { return (
} icon={} /> {connectionState.status === "disconnected" && ( Start agents by running these commands in your terminal.
Authenticate with the agent before starting a session. } /> )}
); }, ); EmptyState.displayName = "EmptyState"; interface LoadingIndicatorProps { isLoading: boolean; isRequestingPermission: boolean; onStop: () => void; } const LoadingIndicator = memo( ({ isLoading, isRequestingPermission, onStop }) => { if (!isLoading) { return null; } return (
{isRequestingPermission ? ( Waiting for permission to continue... ) : ( Agent is working... )}
); }, ); LoadingIndicator.displayName = "LoadingIndicator"; interface PromptAreaProps { isLoading: boolean; activeSessionId: ExternalAgentSessionId | null; promptValue: string; commands: AvailableCommands | undefined; onPromptValueChange: (value: string) => void; onPromptSubmit: (e: KeyboardEvent | undefined, prompt: string) => void; onAddFiles: (files: File[]) => void; onStop: () => void; fileInputRef: React.RefObject; sessionMode?: SessionMode; onModeChange?: (mode: string) => void; sessionModels?: SessionModelState | null; onModelChange?: (modelId: string) => void; } const PromptArea = memo( ({ isLoading, activeSessionId, promptValue, commands, onPromptValueChange, onPromptSubmit, onAddFiles, onStop, fileInputRef, sessionMode, onModeChange, sessionModels, onModelChange, }) => { const inputRef = useRef(null); const promptCompletions: AdditionalCompletions | undefined = useMemo(() => { if (!commands) { return undefined; } // sentence has to begin with '/' to trigger autocomplete return { triggerCompletionRegex: /^\/(\w+)?/, completions: commands.map( (prompt): Completion => ({ label: `/${prompt.name}`, info: prompt.description, }), ), }; }, [commands]); const handleSendClick = useEvent(() => { if (promptValue.trim()) { onPromptSubmit(undefined, promptValue); } }); const handleAddContext = useEvent(() => { // For now, just append @ to the current value addContextCompletion(inputRef); }); return (
{sessionMode && onModeChange && ( )} {sessionModels && onModelChange && activeSessionId && ( )}
); }, ); PromptArea.displayName = "PromptArea"; interface ModeSelectorProps { sessionMode: SessionMode; onModeChange: (mode: string) => void; } const ModeSelector = memo( ({ sessionMode, onModeChange }) => { const availableModes = sessionMode?.availableModes || []; const currentModeId = sessionMode?.currentModeId; if (availableModes.length === 0) { return null; } const modeOptions = availableModes.map((mode) => ({ value: mode.id, label: mode.name, subtitle: mode.description ?? "", })); const currentMode = modeOptions.find((opt) => opt.value === currentModeId); return ( ); }, ); ModeSelector.displayName = "ModeSelector"; interface ChatContentProps { hasNotifications: boolean; agentId: ExternalAgentId | undefined; connectionState: AgentConnectionState; sessionId: ExternalAgentSessionId | null; notifications: NotificationEvent[]; pendingPermission: AgentPendingPermission; onResolvePermission: (option: RequestPermissionResponse) => void; onRetryConnection?: () => void; onRetryLastAction?: () => void; onDismissError?: (errorId: string) => void; } const ChatContent = memo( ({ hasNotifications, agentId, connectionState, notifications, pendingPermission, onResolvePermission, onRetryConnection, onRetryLastAction, onDismissError: _onDismissError, sessionId, }) => { const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); const scrollContainerRef = useRef(null); const isDisconnected = connectionState.status === "disconnected"; // Scroll handler to determine if we're at the bottom of the chat 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; // 5px threshold setIsScrolledToBottom(isAtBottom); }); const scrollToBottom = useEvent(() => { const container = scrollContainerRef.current; if (!container) { return; } container.scrollTo({ top: container.scrollHeight, behavior: "smooth", }); }); // Auto-scroll to bottom when new notifications arrive (if already at bottom) useEffect(() => { if (isScrolledToBottom && notifications.length > 0) { // Use setTimeout to ensure DOM is updated before scrolling const timeout = setTimeout(scrollToBottom, 100); return () => clearTimeout(timeout); } }, [notifications.length, isScrolledToBottom, scrollToBottom]); const renderThread = () => { if (hasNotifications) { return ( ); } const isConnected = connectionState.status === "connected"; if (isConnected) { return ; } return (
} /> {isDisconnected && agentId && ( )} {isDisconnected && ( )}
); }; return (
{pendingPermission && (
)}
{sessionId && (
Session ID: {sessionId}
)} {renderThread()}
); }, ); ChatContent.displayName = "ChatContent"; const NO_WS_SET = "_skip_auto_connect_"; const AUTH_REQUIRED_CODE = -32_000; function getDataMessage(data: unknown): string | undefined { if (data != null && typeof data === "object" && "message" in data) { const msg = (data as Record).message; return typeof msg === "string" ? msg : undefined; } return undefined; } function getCwd(): string { const cwd = store.get(cwdAtom); if (cwd) { return cwd; } const filename = store.get(filenameAtom); if (!filename) { throw new Error( "Please save the notebook and refresh the browser to use the agent", ); } return Paths.dirname(filename); } function getAbsoluteFilename(): string { const filename = store.get(filenameAtom); if (!filename) { throw new Error( "Please save the notebook and refresh the browser to use the agent", ); } const cwd = store.get(cwdAtom); if (cwd) { const builder = PathBuilder.guessDeliminator(cwd); return builder.join(cwd, String(Paths.basename(filename))); } return filename; } const AgentPanel: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [promptValue, setPromptValue] = useState(""); const { files, addFiles, clearFiles, removeFile } = useFileState(); const [sessionModels, setSessionModels] = useState( null, ); const fileInputRef = useRef(null); const [selectedTab] = useAtom(selectedTabAtom); const [sessionState, setSessionState] = useAtom(agentSessionStateAtom); const wsUrl = selectedTab ? getAgentWebSocketUrl(selectedTab.agentId) : NO_WS_SET; const { sendUpdateFile, sendFileDetails } = useRequestClient(); const creatingOrResumingSession = useRef(false); const acpClient = useAcpClient({ wsUrl, clientOptions: { readTextFile: (request) => { logger.debug("Agent requesting file read", { path: request.path, }); return sendFileDetails({ path: request.path }).then((response) => ({ content: response.contents || "", })); }, writeTextFile: (request) => { logger.debug("Agent requesting file write", { path: request.path, contentLength: request.content.length, }); return sendUpdateFile({ path: request.path, contents: request.content, }).then(() => ({})); }, }, autoConnect: false, // We'll manage connection manually based on active session }); const { connect, disconnect, setActiveSessionId, connectionState, notifications, pendingPermission, availableCommands, resolvePermission, sessionMode, activeSessionId, agent, clearNotifications, } = acpClient; useEffect(() => { if (!agent) { return; } const initAndAuth = async () => { const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true, }, }, }); // We try to authenticate with the agent if it supports it. // The user must then restart the session const authMethods = response?.authMethods; if (authMethods && authMethods.length > 0) { await agent.authenticate({ methodId: authMethods[0].id }); } }; initAndAuth().catch((error) => { logger.error("Failed to initialize/authenticate agent", { error }); }); }, [agent]); // Auto-connect to agent when we have an active session, but only once per session useEffect(() => { setActiveSessionId(null); if (wsUrl === NO_WS_SET) { return; } logger.debug("Auto-connecting to agent", { sessionId: activeSessionId, }); void connect().catch((error) => { logger.error("Failed to connect to agent", { error }); }); return () => { // We don't want to disconnect so users can switch between different // panels without losing their session }; // oxlint-disable-next-line react-hooks/exhaustive-deps }, [wsUrl]); const handleNewSession = useEvent(async () => { if (!agent) { return; } creatingOrResumingSession.current = true; try { // If there is an active session, we should stop it if (activeSessionId) { await agent.cancel({ sessionId: activeSessionId }).catch((error) => { logger.error("Failed to cancel active session", { error }); }); clearNotifications(activeSessionId); setActiveSessionId(null); } // Get the selected model from the current session state const currentModel = selectedTab?.selectedModel ?? null; logger.debug("Creating new agent session", { model: currentModel }); const newSession = await agent.newSession({ cwd: getCwd(), mcpServers: [], _meta: currentModel ? { model: currentModel } : undefined, }); // Capture models from the response if (newSession.models) { logger.debug("Session models received", { models: newSession.models }); setSessionModels(newSession.models); } setSessionState((prev) => updateSessionExternalAgentSessionId( prev, newSession.sessionId as ExternalAgentSessionId, ), ); } finally { creatingOrResumingSession.current = false; } }); const handleResumeSession = useEvent( async (previousSessionId: ExternalAgentSessionId) => { if (!agent) { return; } logger.debug("Resuming agent session", { sessionId: previousSessionId, }); if (!agent.loadSession) { throw new Error("Agent does not support loading sessions"); } creatingOrResumingSession.current = true; try { const loadedSession = await agent.loadSession({ sessionId: previousSessionId, cwd: getCwd(), mcpServers: [], }); // Capture models from the response if available if (loadedSession?.models) { logger.debug("Session models received", { models: loadedSession.models, }); setSessionModels(loadedSession.models); } setSessionState((prev) => updateSessionExternalAgentSessionId(prev, previousSessionId), ); } finally { creatingOrResumingSession.current = false; } }, ); // Create or resume a session when successfully connected const isConnected = connectionState.status === "connected"; const tabLastActiveSessionId = selectedTab?.externalAgentSessionId; useEffect(() => { // No need to do anything if we're not connected, don't have an agent, or don't have a selected tab if (!isConnected || !selectedTab || !agent) { return; } // Already have an active session if (activeSessionId && tabLastActiveSessionId) { return; } // Prevent race conditions if (creatingOrResumingSession.current) { return; } // If there is an available session, resume it, otherwise create a new one const createOrResumeSession = async () => { const availableSession = tabLastActiveSessionId ?? activeSessionId; try { if (availableSession) { try { await handleResumeSession(availableSession); } catch (error) { logger.error("Failed to resume session", { sessionId: availableSession, error, }); await handleNewSession(); } } else { await handleNewSession(); } setError(null); } catch (error) { logger.error("Failed to create or resume session:", error); setError(error instanceof Error ? error : String(error)); } }; createOrResumeSession(); // oxlint-disable-next-line react-hooks/exhaustive-deps }, [isConnected, agent, tabLastActiveSessionId, activeSessionId]); // Handler for prompt submission const handlePromptSubmit = useEvent( async (_e: KeyboardEvent | undefined, prompt: string) => { if (!activeSessionId || !agent || isLoading) { return; } logger.debug("Submitting prompt to agent", { sessionId: activeSessionId, }); setIsLoading(true); setPromptValue(""); clearFiles(); // Update session title with first message if it's still the default if (selectedTab?.title.startsWith("New ")) { setSessionState((prev) => updateSessionTitle(prev, prompt)); } let absoluteFilename: string; try { absoluteFilename = getAbsoluteFilename(); } catch { toast({ title: "Notebook must be named", description: "Please name the notebook to use the agent", variant: "danger", }); return; } const promptBlocks: ContentBlock[] = [{ type: "text", text: prompt }]; // Parse context from the prompt const { contextBlocks, attachmentBlocks } = await parseContextFromPrompt(prompt); promptBlocks.push(...contextBlocks, ...attachmentBlocks); // Add manually uploaded files as resource links if (files && files.length > 0) { const fileResourceLinks = await convertFilesToResourceLinks(files); promptBlocks.push(...fileResourceLinks); } const hasGivenRules = notifications.some( (notification) => notification.type === "session_notification" && notification.data.update.sessionUpdate === "user_message_chunk", ); if (!hasGivenRules) { promptBlocks.push( { type: "resource_link", uri: absoluteFilename, mimeType: "text/x-python", name: absoluteFilename, }, { type: "resource", resource: { uri: "marimo_rules.md", mimeType: "text/plain", text: getAgentPrompt(absoluteFilename), }, }, ); } try { await agent.prompt({ sessionId: activeSessionId, prompt: promptBlocks, }); } catch (error) { logger.error("Failed to send prompt", { error }); } finally { setIsLoading(false); } }, ); // Handler for stopping the current operation const handleStop = useEvent(async () => { if (!activeSessionId || !agent) { return; } await agent.cancel({ sessionId: activeSessionId }); setIsLoading(false); }); // Handler for manual connect const handleManualConnect = useEvent(() => { logger.debug("Manual connect requested", { currentStatus: connectionState.status, }); connect(); }); // Handler for manual disconnect const handleManualDisconnect = useEvent(() => { logger.debug("Manual disconnect requested", { sessionId: activeSessionId, currentStatus: connectionState.status, }); disconnect(); }); const handleModelChange = useEvent((modelId: string) => { logger.debug("Model change requested", { modelId, sessionId: activeSessionId, }); if (!agent || !activeSessionId) { toast({ title: "Cannot change model", description: "Please connect to an agent with an active session first", variant: "danger", }); return; } // Call agent.setSessionModel to notify the agent void agent.setSessionModel?.({ sessionId: activeSessionId, modelId, }); // Update local state setSessionModels((prev) => prev ? { ...prev, currentModelId: modelId } : null, ); }); const handleModeChange = useEvent((mode: string) => { logger.debug("Mode change requested", { sessionId: activeSessionId, mode, }); if (!agent) { toast({ title: "Agent not connected", description: "Please connect to an agent to change the mode", variant: "danger", }); return; } if (!agent.setSessionMode) { toast({ title: "Mode change not supported", description: "The agent does not support mode changes", variant: "danger", }); return; } void agent.setSessionMode?.({ sessionId: activeSessionId as string, modeId: mode, }); }); const hasNotifications = notifications.length > 0; const hasActiveSessions = sessionState.sessions.length > 0; if (!hasActiveSessions) { return ( ); } const renderBody = () => { if (error) { const isAuthError = error instanceof JsonRpcError && error.code === AUTH_REQUIRED_CODE; const dataMessage = error instanceof JsonRpcError ? getDataMessage(error.data) : undefined; const displayError = dataMessage ? new Error(dataMessage) : error; return ( { setError(null); handleNewSession(); }} > Restart session ) : ( ) } /> ); } const isConnecting = connectionState.status === "connecting"; const delay = 200; // ms if (isConnecting) { return (
Connecting to the agent...
); } const isLoadingSession = tabLastActiveSessionId == null && connectionState.status === "connected"; if (isLoadingSession) { return (
Creating a new session...
); } return ( <> { logger.debug("Resolving permission request", { sessionId: activeSessionId, option, }); resolvePermission(option); }} onRetryConnection={handleManualConnect} /> {files && files.length > 0 && (
{files.map((file) => ( removeFile(file)} /> ))}
)} ); }; return (
{renderBody()}
); }; export default AgentPanel;