import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Bot, Cpu, FileTextIcon, SendIcon, UploadIcon, XIcon } from "lucide-react"; import { useUserSession } from "@vertesia/ui/session"; import { ActiveWorkstreamEntry, AgentMessage, AgentMessageType, AgentRun, ConversationFile, ConversationFileRef, Plan, UserInputSignal, } from "@vertesia/common"; import { FusionFragmentProvider } from "@vertesia/fusion-ux"; import { Button, cn, MessageBox, Spinner, useToast, Modal, ModalBody, ModalFooter, ModalTitle } from "@vertesia/ui/core"; import { AnimatedThinkingDots, PulsatingCircle } from "./AnimatedThinkingDots"; import { type AgentConversationViewMode } from "./ModernAgentOutput/AllMessagesMixed"; import { type BatchProgressPanelClassNames } from "./ModernAgentOutput/BatchProgressPanel"; import { type MessageItemClassNames } from "./ModernAgentOutput/MessageItem"; import { type StreamingMessageClassNames } from "./ModernAgentOutput/StreamingMessage"; import { type ToolCallGroupClassNames } from "./ModernAgentOutput/ToolCallGroup"; import { ImageLightboxProvider } from "./ImageLightbox"; import AllMessagesMixed from "./ModernAgentOutput/AllMessagesMixed"; import Header from "./ModernAgentOutput/Header"; import MessageInput, { UploadedFile, SelectedDocument } from "./ModernAgentOutput/MessageInput"; import { getConversationUrl, getWorkstreamId } from "./ModernAgentOutput/utils"; import { ThinkingMessages } from "./WaitingMessages"; import { SkillWidgetProvider } from "./SkillWidgetProvider"; import { ArtifactUrlCacheProvider } from "./useArtifactUrlCache.js"; import { useUITranslation } from "../../../i18n/index.js"; import { VegaLiteChart } from "./VegaLiteChart"; import { AgentRightPanel, type WorkstreamInfo } from "./AgentRightPanel.js"; import { useAgentStream } from "./hooks/useAgentStream.js"; import { useAgentPlans } from "./hooks/useAgentPlans.js"; import { useDocumentPanel } from "./hooks/useDocumentPanel.js"; import { useFileProcessing } from "./hooks/useFileProcessing.js"; export type StartWorkflowFn = ( initialMessage?: string, ) => Promise<{ agent_run_id: string } | undefined>; function printElementToPdf(sourceElement: HTMLElement, title: string): boolean { if (typeof window === "undefined" || typeof document === "undefined") { return false; } // Use a hidden iframe to avoid opening a new window const iframe = document.createElement("iframe"); iframe.style.position = "fixed"; iframe.style.right = "0"; iframe.style.bottom = "0"; iframe.style.width = "0"; iframe.style.height = "0"; iframe.style.border = "0"; iframe.style.visibility = "hidden"; document.body.appendChild(iframe); const iframeWindow = iframe.contentWindow; if (!iframeWindow) { iframe.parentNode?.removeChild(iframe); return false; } const doc = iframeWindow.document; doc.open(); doc.write(`${title}`); doc.close(); doc.title = title; const styles = document.querySelectorAll("link[rel=\"stylesheet\"], style"); styles.forEach((node) => { doc.head.appendChild(node.cloneNode(true)); }); doc.body.innerHTML = sourceElement.innerHTML; iframeWindow.focus(); iframeWindow.print(); setTimeout(() => { iframe.parentNode?.removeChild(iframe); }, 1000); return true; } interface ModernAgentConversationProps { /** Stable AgentRun ID — the primary identifier for all runtime operations. */ agentRunId?: string; title?: string; interactive?: boolean; onClose?: () => void; isModal?: boolean; fullWidth?: boolean; initialMessage?: string; startWorkflow?: StartWorkflowFn; startButtonText?: string; placeholder?: string; hideUserInput?: boolean; resetWorkflow?: () => void; /** Called after a restart succeeds — receives the new AgentRun for navigation */ onRestart?: (newRun: AgentRun) => void; /** Called after a clone succeeds — receives the new AgentRun for navigation */ onClone?: (newRun: AgentRun) => void; /** Called to show run details/internals modal */ onShowDetails?: () => void; // File upload props - passed through to MessageInput /** Called when files are dropped/pasted/selected */ onFilesSelected?: (files: File[]) => void; /** Currently uploaded files to display */ uploadedFiles?: UploadedFile[]; /** Called when user removes an uploaded file */ onRemoveFile?: (fileId: string) => void; /** Accepted file types (e.g., ".pdf,.doc,.png") */ acceptedFileTypes?: string; /** Max number of files allowed */ maxFiles?: number; /** Ref populated with the internal file upload handler for external triggering */ fileUploadRef?: React.MutableRefObject<((files: File[]) => void) | null>; /** Called when processingFiles state changes (for external progress display) */ onProcessingFilesChange?: (files: Map) => void; /** Processing files to display in the right panel Uploads tab */ processingFiles?: Map; /** Called when plans change (for external plan panel) */ onPlansChange?: (plans: Array<{ plan: Plan; timestamp: number }>, activePlanIndex: number) => void; /** Called when workstream status changes (for external plan panel) */ onWorkstreamStatusChange?: (statusMap: Map>) => void; /** Controlled view mode — when provided, overrides internal state */ viewMode?: AgentConversationViewMode; /** Called when view mode changes (for external control) */ onViewModeChange?: (mode: AgentConversationViewMode) => void; /** Called when follow-up input availability is determined (after messages load) */ onShowInputChange?: (canSendFollowUp: boolean) => void; /** Ref populated with the stop handler — call to interrupt the active agent. null when stop unavailable. */ stopRef?: React.MutableRefObject<(() => void) | null>; /** Called when the stopping (in-progress) state changes */ onStoppingChange?: (isStopping: boolean) => void; // Document search props (render prop for custom search UI) /** Render custom document search UI - if provided, shows search button */ renderDocumentSearch?: (props: { isOpen: boolean; onClose: () => void; onSelect: (doc: SelectedDocument) => void; }) => React.ReactNode; /** Currently selected documents from search */ selectedDocuments?: SelectedDocument[]; /** Called when user removes a selected document */ onRemoveDocument?: (docId: string) => void; // Hide the default object linking (for apps that don't use it) hideObjectLinking?: boolean; /** Hide the internal header (for apps that render their own) */ hideHeader?: boolean; /** Hide the internal message input (for apps that render their own) */ hideMessageInput?: boolean; /** Hide the internal plan panel (for apps that render their own) */ hidePlanPanel?: boolean; /** Hide workstream tabs */ hideWorkstreamTabs?: boolean; /** Enable or disable the internal right panel (plan/workstreams/documents/uploads) */ showRightPanel?: boolean; /** Hide the default file upload */ hideFileUpload?: boolean; /** Show the Artifacts tab in the right panel (default false) */ showArtifacts?: boolean; /** Hide the document preview panel that auto-opens on create_document */ hideDocumentPanel?: boolean; /** Message types to exclude from the conversation view */ hiddenMessageTypes?: AgentMessageType[]; // Callback to get attached documents when sending messages // Returns array of { id, name } to include in message metadata and display getAttachedDocs?: () => SelectedDocument[]; // Called after attachments are sent to allow clearing them onAttachmentsSent?: () => void; // Whether files are currently being uploaded - disables send/start buttons isUploading?: boolean; // Callback to get additional context metadata to include in every message // Returns object with context like { fundId, fundName } to include in signal metadata getMessageContext?: () => Record | undefined; // Styling props for Tailwind customization - passed through to MessageInput /** Additional className for the MessageInput container */ inputContainerClassName?: string; /** Additional className for the input field */ inputClassName?: string; /** Additional className for the root container */ className?: string; messageItemClassNames?: MessageItemClassNames; /** Sparse MESSAGE_STYLES overrides passed to every MessageItem */ messageStyleOverrides?: import("./ModernAgentOutput/MessageItem").MessageItemProps['messageStyleOverrides']; toolCallGroupClassNames?: ToolCallGroupClassNames; /** Hide ToolCallGroup in this view mode */ hideToolCallsInViewMode?: AgentConversationViewMode[]; streamingMessageClassNames?: StreamingMessageClassNames; batchProgressPanelClassNames?: BatchProgressPanelClassNames; /** className override for the working indicator container */ workingIndicatorClassName?: string; /** className override for the message list container */ messageListClassName?: string; /** Custom component to render store/document links instead of default NavLink navigation */ StoreLinkComponent?: React.ComponentType<{ href: string; documentId: string; children: React.ReactNode }>; /** Custom component to render store/collection links instead of default NavLink navigation */ CollectionLinkComponent?: React.ComponentType<{ href: string; collectionId: string; children: React.ReactNode }>; /** Optional message to display as the first user message in the conversation. * Purely visual/UI — not sent to temporal. Renders as a QUESTION MessageItem before real messages. */ prependFriendlyMessage?: string; // Fusion fragment props /** * Data to provide to fusion-fragment code blocks for rendering. * When provided, fusion-fragments in agent responses will display * this data according to their template structure. * @example { fundName: "Tech Growth IV", vintage: 2024, totalCommitments: 500000000 } */ fusionData?: Record; /** Optional payload content to show as a "Payload" tab in the right panel */ payloadContent?: React.ReactNode; /** Optional conversation content to show as a "Conversation" tab in the right panel */ conversationContent?: React.ReactNode; /** When true, renders the conversation inside the right panel as a "Conversation" tab */ conversationTab?: boolean; } export function ModernAgentConversation( props: ModernAgentConversationProps, ) { const { agentRunId, startWorkflow } = props; if (agentRunId) { return ( ); } else if (startWorkflow) { // If we have startWorkflow capability but no agentRunId yet return ; } else { // Empty state return ; } } // Empty state when no agent is running function EmptyState() { const { t } = useUITranslation(); return ( } >
{t('agent.noAgentRunning')}
{t('agent.selectInteraction')}
); } // Start workflow view - allows initiating a new agent conversation // Files can be staged locally before workflow starts, then uploaded when the workflow is created function StartWorkflowView({ initialMessage, startWorkflow, onClose, isModal = false, fullWidth = false, placeholder, startButtonText, title, // Attachment callback - used to include existing document attachments in the first message getAttachedDocs, onAttachmentsSent, // File upload props acceptedFileTypes, maxFiles = 5, }: ModernAgentConversationProps) { const { t } = useUITranslation(); const resolvedPlaceholder = placeholder ?? t('agent.typeYourMessage'); const resolvedStartButtonText = startButtonText ?? t('agent.startAgent'); const resolvedTitle = title ?? t('agent.startNewConversation'); const { client } = useUserSession(); const [inputValue, setInputValue] = useState(""); const [isSending, setIsSending] = useState(false); const [startedAgentRunId, setStartedAgentRunId] = useState(null); const toast = useToast(); const inputRef = useRef(null); const fileInputRef = useRef(null); // Staged files - stored locally until workflow starts const [stagedFiles, setStagedFiles] = useState([]); // Drag and drop state const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); // Drag and drop handlers for file staging const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (e.dataTransfer?.types?.includes('Files')) { setIsDragOver(true); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragOver(false); } }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; setIsDragOver(false); if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { const filesArray = Array.from(e.dataTransfer.files); setStagedFiles(prev => { const newFiles = [...prev, ...filesArray].slice(0, maxFiles); return newFiles; }); } }, [maxFiles]); const handleFileInputChange = useCallback((e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { const filesArray = Array.from(e.target.files); setStagedFiles(prev => { const newFiles = [...prev, ...filesArray].slice(0, maxFiles); return newFiles; }); } // Reset input so the same file can be selected again e.target.value = ''; }, [maxFiles]); const removeStagedFile = useCallback((index: number) => { setStagedFiles(prev => prev.filter((_, i) => i !== index)); }, []); useEffect(() => { // Focus the input field when component mounts if (inputRef.current) { inputRef.current.focus(); } }, []); // Start a new workflow with the message const startWorkflowWithMessage = async () => { if (!startWorkflow) return; const message = inputValue.trim(); if (!message || isSending) return; setIsSending(true); try { // Reset plan panel state when starting a new agent sessionStorage.removeItem("plan-panel-shown"); toast({ title: stagedFiles.length > 0 ? t('agent.startingAgentUploading') : t('agent.startingAgent'), status: "info", duration: 3000, }); // Get attached documents if callback provided const attachedDocs = getAttachedDocs?.() || []; // Build message content with attachment references as markdown links let messageContent = message; if (attachedDocs.length > 0 && !/store:\S+/.test(message)) { const lines = attachedDocs.map((doc) => `[${doc.name}](/store/objects/${doc.id})`); messageContent = [message, '', 'Attachments:', ...lines].join('\n'); } // If files are staged, add a note to the message so the agent knows files are coming if (stagedFiles.length > 0) { const fileNames = stagedFiles.map(f => f.name).join(', '); messageContent = [ messageContent, '', `[System: ${stagedFiles.length} file(s) are being uploaded: ${fileNames}. Please wait for the "Files Ready" notification before processing them.]` ].join('\n'); } const newRun = await startWorkflow(messageContent); if (newRun) { const agentId = newRun.agent_run_id; // Upload staged files to the new run's artifact space and signal agent const uploadedFiles: string[] = []; if (stagedFiles.length > 0) { for (const file of stagedFiles) { try { const artifactPath = `files/${file.name}`; await client.agents.uploadArtifact(agentId, artifactPath, file); // Signal agent that file was uploaded await client.agents.sendSignal( agentId, "FileUploaded", { id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, name: file.name, content_type: file.type || 'application/octet-stream', reference: `artifact:${artifactPath}`, artifact_path: artifactPath, } as ConversationFileRef ); uploadedFiles.push(file.name); } catch (uploadErr) { console.error(`Failed to upload staged file ${file.name}:`, uploadErr); // Continue with other files } } // Send a follow-up message to notify the agent that all files are ready if (uploadedFiles.length > 0) { try { await client.agents.sendSignal( agentId, "UserInput", { message: `[Files Ready] All ${uploadedFiles.length} file(s) have been uploaded and are now available: ${uploadedFiles.join(', ')}. You can now process them.`, metadata: { type: 'files_ready', files: uploadedFiles, }, } as UserInputSignal ); } catch (signalErr) { console.error('Failed to send files ready signal:', signalErr); } } setStagedFiles([]); } // Clear attachments after successful start onAttachmentsSent?.(); setStartedAgentRunId(agentId); setInputValue(""); toast({ title: t('agent.agentStarted'), status: "success", duration: 3000, }); } } catch (err: any) { toast({ title: t('agent.errorStarting'), status: "error", duration: 3000, description: err instanceof Error ? err.message : t('agent.unknownError'), }); } finally { setIsSending(false); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); startWorkflowWithMessage(); } // Shift+Enter allows newline (default textarea behavior) }; // Auto-resize textarea as content grows const adjustTextareaHeight = useCallback(() => { const textarea = inputRef.current; if (textarea) { textarea.style.height = 'auto'; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } }, []); useEffect(() => { adjustTextareaHeight(); }, [inputValue, adjustTextareaHeight]); // If a run has been started, show the conversation if (startedAgentRunId) { return ( ); } return (
{/* Drag overlay for full-panel file drop */} {isDragOver && (
Drop files to stage for upload
)} {/* Hidden file input */} {/* Header */}
{resolvedTitle}
{/* Close button if needed */} {onClose && !isModal && ( )}
{/* Empty conversation area with instructions */}
{initialMessage && (
{initialMessage}
)}
{t('agent.enterMessage')}
{t('agent.typeQuestionBelow', { buttonText: resolvedStartButtonText })}
{/* Input Area */}
{/* Staged files display */} {stagedFiles.length > 0 && (
{stagedFiles.map((file, index) => (
{file.name} {t('agent.staged')}
))}
)} {/* Upload button row */}