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 && (
)}
{effectiveWorkflowStatus && effectiveWorkflowStatus !== "RUNNING" ? (
This Workflow is {effectiveWorkflowStatus}
) : showInput && (
)}
)}
);
// Main content - wrapped with FusionFragmentProvider when fusionData is provided
const mainContent = (
{/* Drag overlay for full-panel file drop */}
{isDragOver && (
Drop files to upload
)}
{/* Conversation Area — hidden when conversationTab moves it into the right panel */}
{!conversationTab && conversationAreaJsx}
{/* Unified Right Panel — Plan | Workstreams | Documents | Uploads */}
{isRightPanelVisible && (
<>
{!conversationTab && (
setIsRightPanelResizing(true)}
role="separator"
aria-orientation="vertical"
aria-label="Resize right panel"
/>
)}
>
)}
setIsPdfModalOpen(false)}>
Export conversation as PDF
This will open your browser's print dialog with the current conversation.
To save a PDF, choose "Save as PDF" or a similar option in the print dialog.
);
// Wrap with FusionFragmentProvider when fusionData is provided
// This enables fusion-fragment code blocks to display data and supports
// agent-mode interactions where clicking editable fields sends messages
if (fusionData) {
return (
{mainContent}
);
}
return mainContent;
}