Python: DevUI fixes : Add multimodal input support for workflows and refactor chat input (#2593)

* show app version in devui .NET: Python: Improved Versioning for DevUI
Fixes #2059

* feat: Add multimodal input support for workflows and refactor chat input

This PR adds support for multimodal content (images, files) in workflow
inputs and refactors the chat input into a reusable component.

## Multimodal Workflow Support
- Add `isChatMessageSchema()` to detect ChatMessage input schemas
- Update `RunWorkflowButton` to use `ChatMessageInput` for ChatMessage workflows
- Wrap multimodal content in OpenAI message format for backend processing
- Add `_is_openai_multimodal_format()` to detect OpenAI ResponseInputParam
- Update `_parse_workflow_input()` to route multimodal input through
  existing `_convert_input_to_chat_message()` converter

## Reusable ChatMessageInput Component
- Extract chat input logic from agent-view into `ChatMessageInput` component
- Support file upload, drag & drop, paste handling, and attachments
- Add `useDragDrop` hook for parent-level drag handling with full-area
  drop zones
- Refactor agent-view to use the new shared component

## Other Improvements
- Add `isStreaming` prop to executor nodes for animation control
- Clean up unused imports and state variables in agent-view
- Add tests for multimodal workflow input handling

Fixes workflow input not receiving images when using AgentExecutor nodes.

* add self loop edge, fix #2470

* fix test
This commit is contained in:
Victor Dibia
2025-12-03 12:15:51 -08:00
committed by GitHub
Unverified
parent 6835161f2d
commit 411ee7a60f
38 changed files with 3488 additions and 1493 deletions
@@ -4,16 +4,11 @@
*/
import { useState, useCallback, useRef, useEffect } from "react";
import { useCancellableRequest, isAbortError, useDragDrop } from "@/hooks";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { OpenAIMessageRenderer } from "./message-renderers/OpenAIMessageRenderer";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
Select,
SelectContent,
@@ -23,21 +18,19 @@ import {
} from "@/components/ui/select";
import { AgentDetailsModal } from "./agent-details-modal";
import {
SendHorizontal,
User,
Bot,
Plus,
AlertCircle,
Paperclip,
Info,
Trash2,
FileText,
Check,
X,
Copy,
CheckCheck,
RefreshCw,
Wrench,
Square,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -273,8 +266,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const isStreaming = useDevUIStore((state) => state.isStreaming);
const isSubmitting = useDevUIStore((state) => state.isSubmitting);
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const uiMode = useDevUIStore((state) => state.uiMode);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
@@ -287,15 +278,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const setIsStreaming = useDevUIStore((state) => state.setIsStreaming);
const setIsSubmitting = useDevUIStore((state) => state.setIsSubmitting);
const setLoadingConversations = useDevUIStore((state) => state.setLoadingConversations);
const setInputValue = useDevUIStore((state) => state.setInputValue);
const setAttachments = useDevUIStore((state) => state.setAttachments);
const updateConversationUsage = useDevUIStore((state) => state.updateConversationUsage);
const setPendingApprovals = useDevUIStore((state) => state.setPendingApprovals);
// Local UI state (not in Zustand - component-specific)
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationError, setConversationError] = useState<{
message: string;
@@ -303,10 +289,18 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type?: string;
} | null>(null);
const [isReloading, setIsReloading] = useState(false);
const [wasCancelled, setWasCancelled] = useState(false);
// Use the cancellation hook
const { isCancelling, createAbortSignal, handleCancel, resetCancelling } = useCancellableRequest();
// Use the drag/drop hook for parent-level file dropping
const { isDragOver, droppedFiles, clearDroppedFiles, dragHandlers } = useDragDrop({
disabled: isSubmitting || isStreaming,
});
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const currentMessageUsage = useRef<{
total_tokens: number;
input_tokens: number;
@@ -350,10 +344,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}, [chatItems, isStreaming]);
// Return focus to input after streaming completes
// Note: Focus handling is now managed by ChatMessageInput component
useEffect(() => {
if (!isStreaming && !isSubmitting) {
textareaRef.current?.focus();
}
// ChatMessageInput will handle its own focus
}, [isStreaming, isSubmitting]);
// Load conversations when agent changes
@@ -385,6 +378,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
agent.id,
openAIRequest,
conversation.id,
undefined, // No abort signal for resume
storedState.responseId // Pass response ID for resume
);
@@ -711,225 +705,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAgent, onDebugEvent, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
// Handle file uploads
const handleFilesSelected = async (files: File[]) => {
const newAttachments: AttachmentItem[] = [];
for (const file of files) {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const type = getFileType(file);
let preview: string | undefined;
if (type === "image") {
preview = await readFileAsDataURL(file);
}
newAttachments.push({
id,
file,
preview,
type,
});
}
setAttachments([...useDevUIStore.getState().attachments, ...newAttachments]);
};
const handleRemoveAttachment = (id: string) => {
setAttachments(useDevUIStore.getState().attachments.filter((att) => att.id !== id));
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragCounter((prev) => prev + 1);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const newCounter = dragCounter - 1;
setDragCounter(newCounter);
if (newCounter === 0) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
setDragCounter(0);
if (isSubmitting || isStreaming) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Paste handler
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
const TEXT_THRESHOLD = 8000; // Convert to file if text is larger than this
for (const item of items) {
// Handle pasted images (screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue =
currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split("\n");
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter((line) => line.includes(","));
const semicolonLines = lines.filter((line) => line.includes(";"));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas =
commaLines.reduce(
(sum, line) => sum + (line.match(/,/g) || []).length,
0
) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons =
semicolonLines.reduce(
(sum, line) => sum + (line.match(/;/g) || []).length,
0
) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Removed old input handling functions - now handled by ChatMessageInput component
// Handle new conversation creation
const handleNewConversation = useCallback(async () => {
@@ -1299,10 +1075,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Clear text accumulator for new response
accumulatedTextRef.current = "";
// Create new AbortController for this request
const signal = createAbortSignal();
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
selectedAgent.id,
apiRequest
apiRequest,
signal
);
for await (const openAIEvent of streamGenerator) {
@@ -1589,152 +1369,100 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Reset usage for next message
currentMessageUsage.current = null;
} catch (error) {
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
// Handle abort separately - don't show error message
if (isAbortError(error)) {
// User cancelled - mark as cancelled for UI feedback
setWasCancelled(true);
// Mark the message as completed with what we have
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: accumulatedTextRef.current ? "completed" as const : "incomplete" as const,
// Keep whatever text we have accumulated
content: item.content,
}
: item
));
} else {
// Other errors - show error message
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
}
setIsStreaming(false);
resetCancelling();
}
},
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage]
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
!selectedAgent
)
return;
// Handle message submission from ChatMessageInput
const handleChatInputSubmit = async (content: import("@/types/agent-framework").ResponseInputContent[]) => {
if (!selectedAgent || content.length === 0) return;
// Set flag to force scroll when user sends message
userJustSentMessage.current = true;
setWasCancelled(false); // Reset cancelled state for new message
setIsSubmitting(true);
const messageText = inputValue.trim();
setInputValue("");
try {
// Create OpenAI Responses API format
if (attachments.length > 0 || messageText) {
const content: import("@/types/agent-framework").ResponseInputContent[] =
[];
const openaiInput: import("@/types/agent-framework").ResponseInputParam = [
{
type: "message",
role: "user",
content,
},
];
// Add text content if present - EXACT OpenAI ResponseInputTextParam
if (messageText) {
content.push({
text: messageText,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam);
}
// Add attachments using EXACT OpenAI types
for (const attachment of attachments) {
const dataUri = await readFileAsDataURL(attachment.file);
if (attachment.file.type.startsWith("image/")) {
// EXACT OpenAI ResponseInputImageParam
content.push({
detail: "auto",
type: "input_image",
image_url: dataUri,
} as import("@/types/agent-framework").ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert all text files (from pasted large text) back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam);
} else {
// EXACT OpenAI ResponseInputFileParam for other files
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
file_data: base64Data,
file_url: dataUri, // Use data URI as the URL
filename: attachment.file.name,
} as import("@/types/agent-framework").ResponseInputFileParam);
}
}
const openaiInput: import("@/types/agent-framework").ResponseInputParam =
[
{
type: "message",
role: "user",
content,
},
];
// Use pure OpenAI format
await handleSendMessage({
input: openaiInput,
conversation_id: currentConversation?.id,
});
} else {
// Simple text message using OpenAI format
const openaiInput: import("@/types/agent-framework").ResponseInputParam =
[
{
type: "message",
role: "user",
content: [
{
text: messageText,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam,
],
},
];
await handleSendMessage({
input: openaiInput,
conversation_id: currentConversation?.id,
});
}
// Clear attachments after sending
setAttachments([]);
// Use pure OpenAI format
await handleSendMessage({
input: openaiInput,
conversation_id: currentConversation?.id,
});
} finally {
setIsSubmitting(false);
}
};
const canSendMessage =
selectedAgent &&
!isSubmitting &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
// Old handleSubmit and canSendMessage removed - replaced by handleChatInputSubmit
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
<div className="flex h-[calc(100vh-3.5rem)] flex-col relative" {...dragHandlers}>
{/* Full-area drop overlay */}
{isDragOver && (
<div className="absolute inset-0 z-50 bg-blue-50/95 dark:bg-blue-950/95 backdrop-blur-sm flex items-center justify-center border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg m-2">
<div className="text-center p-8">
<div className="text-blue-600 dark:text-blue-400 text-lg font-medium mb-2">
Drop files here
</div>
<div className="text-blue-500/80 dark:text-blue-400/70 text-sm">
Images, PDFs, audio, and other files
</div>
</div>
</div>
)}
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
@@ -1927,29 +1655,70 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
) : (
(() => {
// Group tool calls and results with their assistant messages
// Bidirectional association:
// - Loading mode: tools come BEFORE assistant message (associate forward)
// - Streaming mode: tools come AFTER assistant message placeholder (associate backward)
const processedItems: React.ReactElement[] = [];
const toolCallsByMessage = new Map<string, import("@/types/openai").ConversationFunctionCall[]>();
const toolResultsByMessage = new Map<string, import("@/types/openai").ConversationFunctionCallOutput[]>();
// First pass: collect tool calls and results, associate with preceding assistant message
// Track the last assistant message for backward association (streaming)
let lastAssistantMessageId: string | null = null;
// Track orphaned tools for forward association (loading)
const orphanedToolCalls: import("@/types/openai").ConversationFunctionCall[] = [];
const orphanedToolResults: import("@/types/openai").ConversationFunctionCallOutput[] = [];
for (let i = 0; i < chatItems.length; i++) {
const item = chatItems[i];
for (const item of chatItems) {
if (item.type === "message" && item.role === "assistant") {
lastAssistantMessageId = item.id;
// Initialize arrays for this message if they don't exist
// Initialize arrays for this message
if (!toolCallsByMessage.has(item.id)) {
toolCallsByMessage.set(item.id, []);
toolResultsByMessage.set(item.id, []);
}
} else if (item.type === "function_call" && lastAssistantMessageId) {
const calls = toolCallsByMessage.get(lastAssistantMessageId) || [];
calls.push(item);
toolCallsByMessage.set(lastAssistantMessageId, calls);
} else if (item.type === "function_call_output" && lastAssistantMessageId) {
const results = toolResultsByMessage.get(lastAssistantMessageId) || [];
results.push(item);
toolResultsByMessage.set(lastAssistantMessageId, results);
// Forward association: if we have orphaned tools, associate with this message
if (orphanedToolCalls.length > 0) {
const calls = toolCallsByMessage.get(item.id) || [];
calls.push(...orphanedToolCalls);
toolCallsByMessage.set(item.id, calls);
orphanedToolCalls.length = 0;
}
if (orphanedToolResults.length > 0) {
const results = toolResultsByMessage.get(item.id) || [];
results.push(...orphanedToolResults);
toolResultsByMessage.set(item.id, results);
orphanedToolResults.length = 0;
}
// Track this as the last assistant message for backward association
lastAssistantMessageId = item.id;
} else if (item.type === "function_call") {
// Try backward association first (streaming mode)
if (lastAssistantMessageId) {
const calls = toolCallsByMessage.get(lastAssistantMessageId) || [];
calls.push(item);
toolCallsByMessage.set(lastAssistantMessageId, calls);
} else {
// No previous assistant message, store for forward association
orphanedToolCalls.push(item);
}
} else if (item.type === "function_call_output") {
// Try backward association first (streaming mode)
if (lastAssistantMessageId) {
const results = toolResultsByMessage.get(lastAssistantMessageId) || [];
results.push(item);
toolResultsByMessage.set(lastAssistantMessageId, results);
} else {
// No previous assistant message, store for forward association
orphanedToolResults.push(item);
}
} else if (item.type === "message" && item.role === "user") {
// User message resets the backward association context
// Tools after a user message belong to the next assistant response
lastAssistantMessageId = null;
}
}
@@ -1967,13 +1736,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
/>
);
}
// Tool calls and results are now rendered within messages, so skip them here
// Tool calls and results are rendered within messages, skip standalone
}
return processedItems;
})()
)}
{/* Response cancelled card */}
{wasCancelled && !isStreaming && (
<div className="px-4 py-2">
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
<div className="px-4 py-3 flex items-center gap-2">
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Response stopped by user</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
@@ -2047,94 +1828,20 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{/* Input */}
<div className="border-t flex-shrink-0">
<div
className={`p-4 relative transition-all duration-300 ease-in-out ${
isDragOver ? "bg-blue-50 dark:bg-blue-950/20" : ""
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}... (Shift+Enter for new line)`}
disabled={isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={isSubmitting || isStreaming}
/>
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0 h-10"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</Button>
</form>
<div className="p-4">
<ChatMessageInput
onSubmit={handleChatInputSubmit}
isSubmitting={isSubmitting}
isStreaming={isStreaming}
onCancel={handleCancel}
isCancelling={isCancelling}
placeholder={`Message ${selectedAgent.name || selectedAgent.id}... (Shift+Enter for new line)`}
showFileUpload={true}
entityName={selectedAgent.name || selectedAgent.id}
disabled={!selectedAgent}
externalFiles={droppedFiles}
onExternalFilesProcessed={clearDroppedFiles}
/>
</div>
</div>
@@ -0,0 +1,395 @@
/**
* CheckpointInfoModal - Timeline view of workflow checkpoints
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Clock,
MessageSquare,
AlertCircle,
Loader2,
Package,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/services/api";
import type { CheckpointItem, WorkflowSession } from "@/types";
interface CheckpointInfoModalProps {
session: WorkflowSession | null;
checkpoints: CheckpointItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CheckpointInfoModal({
session,
checkpoints,
open,
onOpenChange,
}: CheckpointInfoModalProps) {
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
const [fullCheckpoint, setFullCheckpoint] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [jsonExpanded, setJsonExpanded] = useState(true);
// Select first checkpoint when modal opens or checkpoints change
useEffect(() => {
if (open && checkpoints.length > 0) {
// Only reset selection if current selection is invalid
const currentSelectionValid = checkpoints.some(
cp => cp.checkpoint_id === selectedCheckpointId
);
if (!currentSelectionValid) {
setSelectedCheckpointId(checkpoints[0].checkpoint_id);
}
}
}, [open, checkpoints]);
// Load full checkpoint details
useEffect(() => {
if (!selectedCheckpointId || !session) return;
const loadDetails = async () => {
// Don't clear the previous checkpoint to avoid UI flash
setLoading(true);
try {
const item = await apiClient.getConversationItem(
session.conversation_id,
`checkpoint_${selectedCheckpointId}`
);
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint);
} catch (error) {
console.error("Failed to load checkpoint:", error);
setFullCheckpoint(null);
} finally {
setLoading(false);
}
};
loadDetails();
}, [selectedCheckpointId, session]);
if (!session) return null;
const selectedCheckpoint = checkpoints.find(
(cp) => cp.checkpoint_id === selectedCheckpointId
);
const executorIds = fullCheckpoint?.shared_state?._executor_state
? Object.keys(fullCheckpoint.shared_state._executor_state)
: [];
const messageExecutors = fullCheckpoint?.messages
? Object.keys(fullCheckpoint.messages)
: [];
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>{session.metadata.name}</DialogTitle>
<div className="text-sm text-muted-foreground mt-1">
{checkpoints.length} checkpoint{checkpoints.length !== 1 ? "s" : ""}
</div>
<div className="text-xs text-muted-foreground mt-2 max-w-2xl">
This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.
</div>
</div>
<DialogClose onClose={() => onOpenChange(false)} />
</div>
</DialogHeader>
{/* Main Content - Timeline + Details */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Timeline Sidebar */}
<div className="w-80 border-r flex flex-col">
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{checkpoints.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
No checkpoints yet
</div>
) : (
checkpoints.map((checkpoint, index) => {
const isSelected = checkpoint.checkpoint_id === selectedCheckpointId;
const hasHil = checkpoint.metadata.has_pending_hil;
return (
<div key={checkpoint.checkpoint_id} className="relative">
<button
onClick={() => setSelectedCheckpointId(checkpoint.checkpoint_id)}
className={cn(
"relative w-full text-left p-3 rounded-lg border transition-colors",
isSelected
? "bg-primary/10 border-primary"
: "hover:bg-muted/50 border-transparent"
)}
>
<div className="flex items-start gap-3">
{/* Timeline Dot */}
<div className="flex flex-col items-center pt-1">
<div
className={cn(
"w-2 h-2 rounded-full z-10",
hasHil
? "bg-blue-500 ring-2 ring-blue-500/20"
: "bg-muted-foreground/30"
)}
/>
</div>
{/* Checkpoint Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{checkpoint.metadata.iteration_count === 0 ? "Initial State" : `Step ${checkpoint.metadata.iteration_count}`}
</span>
<span className="text-[10px] font-mono text-muted-foreground/70" title={checkpoint.checkpoint_id}>
{checkpoint.checkpoint_id.slice(0, 8)}
</span>
{index === 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
Latest
</Badge>
)}
{hasHil && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
{checkpoint.metadata.pending_hil_count} HIL
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{new Date(checkpoint.timestamp).toLocaleTimeString()}</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>{formatSize(checkpoint.metadata.size_bytes)}</span>
</>
)}
</div>
</div>
</div>
</button>
{/* Connecting Line - positioned absolutely */}
{index < checkpoints.length - 1 && (
<div className="absolute left-[18px] top-[30px] w-px h-[calc(100%+8px)] bg-border" />
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Details Panel */}
<div className="flex-1 flex flex-col overflow-hidden">
{!fullCheckpoint && !loading ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a checkpoint to view details
</div>
) : (
<ScrollArea className="flex-1">
<div className="p-6 space-y-6 relative">
{/* Loading overlay */}
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Header */}
<div className="flex items-start justify-between pb-4 border-b">
<div>
<div className="flex items-center gap-2 mb-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{selectedCheckpoint?.metadata.iteration_count === 0
? "Initial State"
: `Step ${selectedCheckpoint?.metadata.iteration_count}`}
</span>
{selectedCheckpoint?.metadata.size_bytes && (
<span className="text-xs text-muted-foreground">
{formatSize(selectedCheckpoint.metadata.size_bytes)}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{selectedCheckpoint &&
new Date(selectedCheckpoint.timestamp).toLocaleString()}
</div>
{selectedCheckpoint && (
<div className="text-xs font-mono text-muted-foreground/70 mt-1">
ID: {selectedCheckpoint.checkpoint_id}
</div>
)}
</div>
{selectedCheckpoint?.metadata.has_pending_hil && (
<Badge variant="secondary">
{selectedCheckpoint.metadata.pending_hil_count} HIL Pending
</Badge>
)}
</div>
{/* Executors */}
{executorIds.length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
Active Executors ({executorIds.length})
</div>
<div className="flex flex-wrap gap-2">
{executorIds.map((execId) => (
<Badge key={execId} variant="outline" className="font-mono text-xs">
{execId}
</Badge>
))}
</div>
</div>
)}
{/* Messages */}
{messageExecutors.length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Messages
</div>
<div className="grid grid-cols-2 gap-3">
{messageExecutors.map((execId) => {
const count = (fullCheckpoint.messages[execId] as unknown[])?.length;
return (
<div key={execId} className="bg-muted/50 p-3 rounded-lg">
<div className="text-xs font-mono text-muted-foreground mb-1">
{execId}
</div>
<div className="font-medium">
{count} message{count !== 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
{/* HIL Requests */}
{fullCheckpoint?.pending_request_info_events &&
Object.keys(fullCheckpoint.pending_request_info_events).length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Pending HIL Requests (
{Object.keys(fullCheckpoint.pending_request_info_events).length})
</div>
<div className="space-y-2">
{Object.entries(fullCheckpoint.pending_request_info_events).map(
([reqId, reqData]: [string, any]) => (
<div
key={reqId}
className="bg-muted/50 border border-border p-3 rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<code className="text-xs bg-background px-2 py-1 rounded">
{reqId.slice(0, 24)}...
</code>
<Badge variant="outline" className="text-xs">
{reqData.source_executor_id}
</Badge>
</div>
<div className="text-xs space-y-1">
<div>
<span className="text-muted-foreground">Request:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.request_type?.split(".").pop() || reqData.request_type}
</code>
</div>
<div>
<span className="text-muted-foreground">Response:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.response_type?.split(".").pop() || reqData.response_type}
</code>
</div>
</div>
</div>
)
)}
</div>
</div>
)}
{/* Shared State */}
<div>
<div className="text-sm font-medium mb-3">Shared State</div>
{fullCheckpoint?.shared_state && Object.keys(fullCheckpoint.shared_state).filter(
(k) => k !== "_executor_state"
).length > 0 ? (
<div className="flex flex-wrap gap-2">
{Object.keys(fullCheckpoint.shared_state)
.filter((k) => k !== "_executor_state")
.map((key) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">No custom state</div>
)}
</div>
{/* Raw JSON (Collapsible) */}
<div className="border-t pt-6">
<button
onClick={() => setJsonExpanded(!jsonExpanded)}
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full"
>
{jsonExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Raw JSON
</button>
{jsonExpanded && (
<pre className="mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto">
{JSON.stringify(fullCheckpoint, null, 2)}
</pre>
)}
</div>
</div>
</ScrollArea>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -7,6 +7,8 @@ import { useState, useEffect, useMemo, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { HilTimelineItem } from "./hil-timeline-item";
import { RunWorkflowButton } from "./run-workflow-button";
import {
Loader2,
CheckCircle,
@@ -16,8 +18,9 @@ import {
ChevronRight,
Copy,
Check,
Square,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
@@ -40,12 +43,30 @@ interface ExecutionTimelineProps {
onExecutorClick?: (executorId: string) => void;
selectedExecutorId?: string | null;
workflowResult?: string;
// HIL support
pendingHilRequests?: Array<{
request_id: string;
request_data: Record<string, unknown>;
request_schema: import("@/types").JSONSchemaProperty;
}>;
hilResponses?: Record<string, Record<string, unknown>>;
onHilResponseChange?: (requestId: string, values: Record<string, unknown>) => void;
onHilSubmit?: () => void;
isSubmittingHil?: boolean;
// Workflow control props for bottom bar
inputSchema?: JSONSchemaProperty;
onRun?: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isCancelling?: boolean;
workflowState?: "ready" | "running" | "completed" | "error" | "cancelled";
wasCancelled?: boolean;
checkpoints?: import("@/types").CheckpointItem[];
}
function getStateIcon(state: ExecutorState) {
function getStateIcon(state: ExecutorState, isStreaming: boolean = true) {
switch (state) {
case "running":
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
return <Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${isStreaming ? 'animate-spin' : ''}`} />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
case "failed":
@@ -78,12 +99,14 @@ function ExecutorRunItem({
onToggle,
onClick,
isSelected,
isStreaming,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
isStreaming: boolean;
}) {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const hasOutput = run.output.trim().length > 0;
@@ -125,7 +148,7 @@ function ExecutorRunItem({
</>
)}
</div>
<div>{getStateIcon(run.state)}</div>
<div>{getStateIcon(run.state, isStreaming)}</div>
<span className="font-medium text-sm truncate overflow-hidden">
{run.executorName}
</span>
@@ -187,12 +210,26 @@ export function ExecutionTimeline({
onExecutorClick,
selectedExecutorId,
workflowResult,
pendingHilRequests = [],
hilResponses = {},
onHilResponseChange,
onHilSubmit,
isSubmittingHil = false,
// New props
inputSchema,
onRun,
onCancel,
isCancelling = false,
workflowState = "ready",
wasCancelled = false,
checkpoints = [],
}: ExecutionTimelineProps) {
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
const [updateTrigger, setUpdateTrigger] = useState(0);
const [copied, setCopied] = useState(false);
const lastScrolledRunRef = useRef<string | null>(null);
const timelineEndRef = useRef<HTMLDivElement>(null);
const hilFormRef = useRef<HTMLDivElement>(null);
// Force re-render when streaming to show updated outputs from itemOutputs ref
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
@@ -413,6 +450,39 @@ export function ExecutionTimeline({
}
}, [workflowResult, isStreaming]);
// Auto-scroll when streaming ends to show final executor state
// (Ensures last executor's completion is visible, even if no workflow result)
useEffect(() => {
// Only scroll when streaming ends AND no HIL requests are pending
// (HIL has its own scroll handler below)
if (!isStreaming && executorRuns.length > 0 && pendingHilRequests.length === 0) {
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [isStreaming, pendingHilRequests.length, executorRuns.length]);
// Auto-scroll to HIL form when it appears
useEffect(() => {
if (pendingHilRequests.length > 0 && hilFormRef.current) {
// Small delay to ensure the form is rendered
setTimeout(() => {
hilFormRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
// Add highlight animation
hilFormRef.current?.classList.add('highlight-attention');
setTimeout(() => {
hilFormRef.current?.classList.remove('highlight-attention');
}, 1000);
}, 100);
}
}, [pendingHilRequests.length]);
const handleCopyAll = () => {
const text = executorRuns
.map((run) => {
@@ -471,35 +541,54 @@ export function ExecutionTimeline({
<div className="p-3 space-y-2">
{executorRuns.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No executor runs yet. Start the workflow to see execution timeline.
{isStreaming ? "Workflow is running..." : "Ready to run workflow"}
</div>
) : (
executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
/>
);
})
<>
{executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
isStreaming={isStreaming}
/>
);
})}
{/* HIL Request Items */}
{pendingHilRequests.length > 0 && (
<div ref={hilFormRef} data-hil-form className="transition-all duration-300">
{pendingHilRequests.map((request) => (
<HilTimelineItem
key={request.request_id}
request={request}
response={hilResponses[request.request_id] || {}}
onResponseChange={(values) => onHilResponseChange?.(request.request_id, values)}
onSubmit={() => onHilSubmit?.()}
isSubmitting={isSubmittingHil}
/>
))}
</div>
)}
</>
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && !wasCancelled && (
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
<div className="flex items-center gap-2 mb-1">
@@ -519,10 +608,36 @@ export function ExecutionTimeline({
</div>
</div>
)}
{/* Workflow cancelled card */}
{wasCancelled && !isStreaming && (
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
<div className="px-4 py-3 flex items-center gap-2">
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Execution stopped by user</span>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
<div className="border-t p-3 bg-background flex-shrink-0">
<RunWorkflowButton
inputSchema={inputSchema}
onRun={onRun || (() => {})}
onCancel={onCancel}
isSubmitting={workflowState === "running"}
isCancelling={isCancelling}
workflowState={workflowState}
checkpoints={checkpoints}
showCheckpoints={false}
/>
</div>
)}
</div>
);
}
@@ -28,6 +28,7 @@ export interface ExecutorNodeData extends Record<string, unknown> {
isEndNode?: boolean;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
isStreaming?: boolean;
}
const getExecutorStateConfig = (state: ExecutorState) => {
@@ -72,6 +73,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
// Determine handle positions based on layout direction
const isVertical = nodeData.layoutDirection === "TB";
@@ -205,7 +207,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
{nodeData.name || nodeData.executorId}
</h3>
{isRunning && (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
<Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${shouldAnimate ? 'animate-spin' : ''} flex-shrink-0`} />
)}
</div>
{nodeData.executorType && (
@@ -1,150 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: HilRequest[];
responses: Record<string, Record<string, unknown>>;
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilInputModal({
open,
onOpenChange,
requests,
responses,
onResponseChange,
onSubmit,
isSubmitting,
}: HilInputModalProps) {
// Check if all required fields are filled
const areAllRequiredFieldsFilled = () => {
return requests.every((req) => {
const response = responses[req.request_id] || {};
return validateSchemaForm(req.request_schema, response);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Workflow Requires Input ({requests.length} request
{requests.length > 1 ? "s" : ""})
</DialogTitle>
<DialogDescription>
The workflow is paused and needs your input to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{requests.map((req, index) => (
<Card key={req.request_id}>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Request {index + 1}
<Badge variant="outline" className="ml-2 font-mono text-xs">
{req.request_id.slice(0, 8)}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{/* Show request data as readonly context */}
{Object.keys(req.request_data).length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-muted-foreground mb-2">
Request Context:
</p>
<div className="space-y-1">
{Object.entries(req.request_data)
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
.map(([key, value]) => (
<div key={key} className="text-xs">
<span className="font-medium">{key}:</span>{" "}
<span className="text-muted-foreground break-all">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Show expected response hint if available */}
{req.request_schema?.description && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
Expected Response:
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{req.request_schema.description}
</p>
</div>
)}
{/* Use schema-based form renderer for RESPONSE (not request) */}
<SchemaFormRenderer
schema={req.request_schema}
values={responses[req.request_id] || {}}
onChange={(values) => onResponseChange(req.request_id, values)}
disabled={isSubmitting}
/>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<div className="flex gap-2 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Submitting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Submit & Continue
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,153 @@
/**
* HilTimelineItem - Inline HIL request form for the ExecutionTimeline
* Shows HIL requests as part of the workflow execution flow
*/
import { useState } from "react";
import { Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
export interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilTimelineItemProps {
request: HilRequest;
response: Record<string, unknown>;
onResponseChange: (values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilTimelineItem({
request,
response,
onResponseChange,
onSubmit,
isSubmitting,
}: HilTimelineItemProps) {
const [isExpanded, setIsExpanded] = useState(true);
const handleResponseChange = (values: Record<string, unknown>) => {
onResponseChange(values);
};
const isValid = validateSchemaForm(request.request_schema, response);
return (
<div className="relative group">
{/* Main content - removed icon and adjusted layout */}
<div>
{/* Content area - removed pb-4 padding */}
<div className="flex-1">
<div className="border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg">
{/* Header */}
<div
className="px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-orange-900 dark:text-orange-100">
Workflow needs your input
</span>
<Badge
variant="outline"
className="text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300"
>
{request.request_id.slice(0, 8)}
</Badge>
{!isExpanded && (
<span className="text-xs text-orange-600 dark:text-orange-400 animate-pulse">
Click to respond
</span>
)}
</div>
{isSubmitting && (
<Badge variant="secondary" className="animate-pulse">
Submitting...
</Badge>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div className="p-4 space-y-4">
{/* Request context - scrollable */}
{Object.keys(request.request_data).length > 0 && (
<div className="bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Context
</p>
<div className="max-h-48 overflow-y-auto space-y-1 pr-2">
{Object.entries(request.request_data)
.filter(
([key]) =>
!["request_id", "source_executor_id"].includes(key)
)
.map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-medium text-muted-foreground">
{key}:
</span>{" "}
<span className="text-foreground break-all">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Description hint */}
{request.request_schema?.description && (
<div className="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
What's needed:
</p>
<p className="text-blue-800 dark:text-blue-200">
{request.request_schema.description}
</p>
</div>
)}
{/* Input form */}
<div className="space-y-3">
<SchemaFormRenderer
schema={request.request_schema}
values={response}
onChange={handleResponseChange}
/>
</div>
{/* Actions */}
<div className="space-y-2 pt-2">
<Button
size="default"
onClick={onSubmit}
disabled={!isValid || isSubmitting}
className="w-full gap-2"
>
<Send className="w-4 h-4" />
Submit Response
</Button>
{!isValid && (
<div className="text-xs text-muted-foreground text-center">
Please fill in all required fields
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -8,4 +8,6 @@ export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
export { HilInputModal } from "./hil-input-modal";
export { CheckpointInfoModal } from "./checkpoint-info-modal";
export { RunWorkflowButton } from "./run-workflow-button";
export type { RunWorkflowButtonProps } from "./run-workflow-button";
@@ -0,0 +1,427 @@
/**
* RunWorkflowButton - Shared component for running workflows with checkpoint support
* Features: Split button with dropdown for checkpoint selection, input validation, modal dialog
*/
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { WorkflowInputForm } from "./workflow-input-form";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
ChevronDown,
Clock,
Loader2,
Play,
RotateCcw,
Settings,
Square,
RefreshCw,
} from "lucide-react";
import type { JSONSchemaProperty, CheckpointItem } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
export interface RunWorkflowButtonProps {
inputSchema?: JSONSchemaProperty;
onRun: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isSubmitting: boolean;
isCancelling?: boolean;
workflowState: "ready" | "running" | "completed" | "error" | "cancelled";
checkpoints?: CheckpointItem[];
// Optional prop to control whether to show checkpoints dropdown
showCheckpoints?: boolean;
}
export function RunWorkflowButton({
inputSchema,
onRun,
onCancel,
isSubmitting,
isCancelling = false,
workflowState,
checkpoints = [],
showCheckpoints = true,
}: RunWorkflowButtonProps) {
const [showModal, setShowModal] = useState(false);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showModal) {
setShowModal(false);
}
};
if (showModal) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [showModal]);
// Analyze input requirements
const inputAnalysis = useMemo(() => {
// Check if this is a ChatMessage schema (for AgentExecutor workflows)
const isChatMessage = isChatMessageSchema(inputSchema);
if (!inputSchema)
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
isChatMessage: false,
};
}
if (inputSchema.type === "object" && inputSchema.properties) {
const properties = inputSchema.properties;
const fields = Object.entries(properties);
const fieldsWithDefaults = fields.filter(
([, schema]: [string, JSONSchemaProperty]) =>
schema.default !== undefined ||
(schema.enum && schema.enum.length > 0)
);
return {
needsInput: fields.length > 0,
hasDefaults: fieldsWithDefaults.length > 0,
fieldCount: fields.length,
canRunDirectly:
fields.length === 0 || fieldsWithDefaults.length === fields.length,
isChatMessage,
};
}
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
}, [inputSchema]);
const handleDirectRun = () => {
if (workflowState === "running" && onCancel) {
onCancel();
} else if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData);
} else {
setShowModal(true);
}
};
const handleRunFromCheckpoint = (checkpointId: string) => {
if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData, checkpointId);
} else {
// TODO: Pass checkpoint ID to modal for custom inputs
setShowModal(true);
}
};
const hasCheckpoints = showCheckpoints && checkpoints.length > 0;
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
// Build the button content based on state
const getButtonContent = () => {
const icon = isCancelling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "running" && onCancel ? (
<Square className="w-4 h-4 fill-current" />
) : workflowState === "running" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "error" ? (
<RotateCcw className="w-4 h-4" />
) : inputAnalysis.needsInput && !inputAnalysis.canRunDirectly ? (
<Settings className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
);
const text = isCancelling
? "Stopping..."
: workflowState === "running" && onCancel
? "Stop"
: workflowState === "running"
? "Running..."
: workflowState === "completed"
? "Run Again"
: workflowState === "error"
? "Retry"
: inputAnalysis.fieldCount === 0
? "Run Workflow"
: inputAnalysis.canRunDirectly
? "Run Workflow"
: "Configure & Run";
return { icon, text };
};
const { icon, text } = getButtonContent();
const isDisabled = (workflowState === "running" && !onCancel) || isCancelling;
const buttonVariant = workflowState === "error" ? "destructive" : "default";
// Unified layout for both variants
const renderButton = () => {
// Always show split button if there are checkpoints OR if inputs need configuration
const showDropdown = hasCheckpoints || inputAnalysis.needsInput;
if (!showDropdown) {
// Simple button - no dropdown needed
return (
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 w-full"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
);
}
// Split button with dropdown
return (
<DropdownMenu>
<div className="flex w-full">
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 rounded-r-none flex-1"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
<DropdownMenuTrigger asChild>
<Button
disabled={isDisabled}
variant={buttonVariant}
className="rounded-l-none border-l-0 px-2"
title="More options"
>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="w-80 max-h-[400px] overflow-y-auto"
>
{/* Run Fresh option - only show when checkpoints are enabled */}
{hasCheckpoints && (
<DropdownMenuItem onClick={handleDirectRun}>
<Play className="w-4 h-4 mr-2" />
Run Fresh
</DropdownMenuItem>
)}
{/* Configure inputs option */}
{inputAnalysis.needsInput && (
<DropdownMenuItem onClick={() => setShowModal(true)}>
<Settings className="w-4 h-4 mr-2" />
Configure Inputs
</DropdownMenuItem>
)}
{/* Checkpoint options */}
{hasCheckpoints && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
Resume from checkpoint
</div>
{checkpoints.map((checkpoint, index) => (
<DropdownMenuItem
key={checkpoint.checkpoint_id}
onClick={() =>
handleRunFromCheckpoint(checkpoint.checkpoint_id)
}
className="flex flex-col items-start py-2"
>
<div className="flex items-center gap-2 w-full">
<RefreshCw className="w-4 h-4 flex-shrink-0" />
<span className="font-medium">
{checkpoint.metadata.iteration_count === 0
? "Initial State"
: `Step ${checkpoint.metadata.iteration_count}`}
</span>
{index === 0 && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1 ml-auto"
>
Latest
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5">
<Clock className="w-3 h-3" />
<span>
{new Date(checkpoint.timestamp).toLocaleTimeString()}
</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>
{formatSize(checkpoint.metadata.size_bytes)}
</span>
</>
)}
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<>
{renderButton()}
{/* Modal for input configuration */}
{inputSchema && (
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<Badge variant="secondary">
{inputAnalysis.isChatMessage
? "Chat Message"
: inputSchema.type === "string"
? "Simple Text"
: "Structured Data"}
</Badge>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4 px-8">
{inputAnalysis.isChatMessage ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same structure as agent-view)
// This preserves multimodal content (images, files) for the backend
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun(openaiInput as unknown as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
placeholder="Enter your message..."
entityName="workflow"
showFileUpload={true}
/>
) : (
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
onSubmit={(values) => {
onRun(values as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
className="embedded"
/>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -429,7 +429,7 @@ export function SchemaFormRenderer({
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
<div className="space-y-3">
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
@@ -445,7 +445,7 @@ export function SchemaFormRenderer({
{/* Separator between required and optional */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div>
<div className="border-t border-border"></div>
</div>
)}
@@ -0,0 +1,56 @@
import { memo, type CSSProperties } from "react";
import { BaseEdge, useInternalNode } from "@xyflow/react";
interface SelfLoopEdgeProps {
id: string;
source: string;
markerEnd?: string;
style?: CSSProperties;
}
/**
* Custom edge for self-referencing nodes. Renders a bezier loop from output back to input.
*/
export const SelfLoopEdge = memo(function SelfLoopEdge({
id,
source,
markerEnd,
style,
}: SelfLoopEdgeProps) {
const sourceNode = useInternalNode(source);
if (!sourceNode) return null;
const { width, height } = sourceNode.measured;
const { x, y } = sourceNode.internals.positionAbsolute;
if (!width || !height) return null;
const nodeData = sourceNode.data as Record<string, unknown> | undefined;
const isVertical = nodeData?.layoutDirection === "TB";
const loopOffset = 100;
const riseOffset = 40;
let edgePath: string;
if (isVertical) {
// TB: bottom center → curves right → top center
const startX = x + width / 2;
const startY = y + height;
const endX = x + width / 2;
const endY = y;
const cpX = x + width + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX} ${startY + riseOffset}, ${cpX} ${startY + riseOffset}, ${cpX} ${y + height / 2} C ${cpX} ${endY - riseOffset}, ${endX} ${endY - riseOffset}, ${endX} ${endY}`;
} else {
// LR: right center → curves down → left center
const startX = x + width;
const startY = y + height / 2;
const endX = x;
const endY = y + height / 2;
const cpY = y + height + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX + riseOffset} ${startY}, ${startX + riseOffset} ${cpY}, ${x + width / 2} ${cpY} C ${endX - riseOffset} ${cpY}, ${endX - riseOffset} ${endY}, ${endX} ${endY}`;
}
return <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />;
});
@@ -33,6 +33,7 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
import { SelfLoopEdge } from "./self-loop-edge";
import {
convertWorkflowDumpToNodes,
convertWorkflowDumpToEdges,
@@ -50,6 +51,10 @@ const nodeTypes: NodeTypes = {
executor: ExecutorNode,
};
const edgeTypes = {
selfLoop: SelfLoopEdge,
};
// ViewOptions panel component that renders inside ReactFlow
function ViewOptionsPanel({
workflowDump,
@@ -61,7 +66,12 @@ function ViewOptionsPanel({
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
viewOptions: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
@@ -138,13 +148,18 @@ function ViewOptionsPanel({
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
onClick={() =>
onToggleViewOption?.("consolidateBidirectionalEdges")
}
>
<div className="flex items-center">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Merge Bidirectional Edges
</div>
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
<Checkbox
checked={viewOptions.consolidateBidirectionalEdges}
onChange={() => {}}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -263,22 +278,24 @@ function WorkflowAnimationHandler({
}
// Timeline resize handler component that runs inside ReactFlow context
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
const TimelineResizeHandler = memo(
({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return null; // This component doesn't render anything
});
return null; // This component doesn't render anything
}
);
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
@@ -286,9 +303,14 @@ export const WorkflowFlow = memo(function WorkflowFlow({
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
viewOptions = {
showMinimap: false,
showGrid: true,
animateRun: true,
consolidateBidirectionalEdges: true,
},
onToggleViewOption,
layoutDirection = "LR",
layoutDirection = "TB",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
@@ -320,7 +342,12 @@ export const WorkflowFlow = memo(function WorkflowFlow({
initialNodes: layoutedNodes,
initialEdges: finalEdges,
};
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
}, [
workflowDump,
onNodeSelect,
layoutDirection,
viewOptions.consolidateBidirectionalEdges,
]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -335,7 +362,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
useMemo(() => {
if (Object.keys(nodeUpdates).length > 0) {
setNodes((currentNodes) =>
updateNodesWithEvents(currentNodes, nodeUpdates)
updateNodesWithEvents(currentNodes, nodeUpdates, isStreaming)
);
} else if (events.length === 0) {
// Reset all nodes to pending state when events are cleared
@@ -347,11 +374,12 @@ export const WorkflowFlow = memo(function WorkflowFlow({
state: "pending" as const,
outputData: undefined,
error: undefined,
isStreaming: false,
},
}))
);
}
}, [nodeUpdates, setNodes, events.length]);
}, [nodeUpdates, setNodes, events.length, isStreaming]);
// Update edges with sequence-based analysis (separate from nodeUpdates)
useMemo(() => {
@@ -445,6 +473,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
@@ -34,29 +34,47 @@ interface FormFieldProps {
// Helper: Determine if field is likely a short metadata field (not multiline text)
function isShortField(fieldName: string): boolean {
const shortFieldNames = ['name', 'title', 'id', 'key', 'label', 'type', 'status', 'tag', 'category', 'code', 'username', 'password'];
const shortFieldNames = [
"name",
"title",
"id",
"key",
"label",
"type",
"status",
"tag",
"category",
"code",
"username",
"password",
];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
function FormField({
name,
schema,
value,
onChange,
isRequired = false,
}: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// Determine if this should be a textarea based on JSON Schema format field
// or heuristics (long descriptions, specific field types)
const shouldBeTextarea =
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
// Determine if this field should span full width
const shouldSpanFullWidth =
shouldBeTextarea ||
(description && description.length > 150);
shouldBeTextarea || (description && description.length > 150);
const shouldSpanTwoColumns =
shouldBeTextarea ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
type === "array"; // Arrays might need more space for comma-separated values
const fieldContent = (() => {
// Handle different field types based on JSON Schema
@@ -296,7 +314,7 @@ export function WorkflowInputForm({
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes('embedded');
const isEmbedded = className?.includes("embedded");
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
@@ -307,17 +325,22 @@ export function WorkflowInputForm({
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
// Plan D: Separate required and optional fields first
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
const allOptionalFieldNames = fieldNames.filter(
(name) => !requiredFields.includes(name)
);
// Detect ChatMessage-like pattern
const isChatMessageLike =
requiredFields.includes('role') &&
allOptionalFieldNames.some(f => ['text', 'message', 'content'].includes(f)) &&
properties['role']?.type === 'string';
requiredFields.includes("role") &&
allOptionalFieldNames.some((f) =>
["text", "message", "content"].includes(f)
) &&
properties["role"]?.type === "string";
// For ChatMessage: hide 'role' field (will be auto-filled)
const requiredFieldNames = fieldNames.filter(name =>
requiredFields.includes(name) && !(isChatMessageLike && name === 'role')
const requiredFieldNames = fieldNames.filter(
(name) =>
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
);
const optionalFieldNames = allOptionalFieldNames;
@@ -325,7 +348,7 @@ export function WorkflowInputForm({
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
['text', 'message', 'content'].includes(name) ? 1 : 0;
["text", "message", "content"].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
@@ -333,9 +356,16 @@ export function WorkflowInputForm({
// Always show ALL required fields + fill to minimum visible with optional fields
// For ChatMessage: show only 1 optional field (text)
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
const visibleOptionalCount = Math.max(0, MIN_VISIBLE_FIELDS - requiredFieldNames.length);
const visibleOptionalFields = sortedOptionalFields.slice(0, visibleOptionalCount);
const collapsedOptionalFields = sortedOptionalFields.slice(visibleOptionalCount);
const visibleOptionalCount = Math.max(
0,
MIN_VISIBLE_FIELDS - requiredFieldNames.length
);
const visibleOptionalFields = sortedOptionalFields.slice(
0,
visibleOptionalCount
);
const collapsedOptionalFields =
sortedOptionalFields.slice(visibleOptionalCount);
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;
@@ -345,9 +375,13 @@ export function WorkflowInputForm({
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
: requiredFields.length > 0
? requiredFields.every(fieldName => {
? requiredFields.every((fieldName) => {
// Auto-filled fields are always valid
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
if (
isChatMessageLike &&
fieldName === "role" &&
formData["role"] === "user"
) {
return true;
}
return formData[fieldName] !== undefined && formData[fieldName] !== "";
@@ -369,8 +403,8 @@ export function WorkflowInputForm({
});
// Auto-fill role="user" for ChatMessage-like inputs
if (isChatMessageLike && !initialData['role']) {
initialData['role'] = 'user';
if (isChatMessageLike && !initialData["role"]) {
initialData["role"] = "user";
}
setFormData(initialData);
@@ -394,10 +428,13 @@ export function WorkflowInputForm({
} else {
// Filter out empty optional fields before submission
const filteredData: Record<string, unknown> = {};
Object.keys(formData).forEach(key => {
Object.keys(formData).forEach((key) => {
const value = formData[key];
// Include if: 1) required field, OR 2) has non-empty value
if (requiredFields.includes(key) || (value !== undefined && value !== "" && value !== null)) {
if (
requiredFields.includes(key) ||
(value !== undefined && value !== "" && value !== null)
) {
filteredData[key] = value;
}
});
@@ -479,42 +516,41 @@ export function WorkflowInputForm({
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
</>
)}
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
)}
</div>
<div className="flex gap-2 mt-4 justify-end">
<Button
type="submit"
disabled={loading || !canSubmit}
size="default"
>
<Button type="submit" disabled={loading || !canSubmit} size="default">
<Send className="h-4 w-4" />
{loading ? "Running..." : "Run Workflow"}
</Button>
@@ -652,18 +688,24 @@ export function WorkflowInputForm({
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
onClick={() =>
setShowAdvancedFields(!showAdvancedFields)
}
className="w-full justify-center gap-2"
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
Hide {collapsedOptionalFields.length} optional
field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
Show {collapsedOptionalFields.length} optional
field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
@@ -671,16 +713,17 @@ export function WorkflowInputForm({
)}
{/* Collapsed optional fields - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
)}
</div>
@@ -42,13 +42,13 @@ export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
if (response.data.length === 0) {
console.log("No workflow conversations found, creating default conversation");
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
name: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default conversation created",
message: "Default checkpoint storage created",
type: "success",
});
} else {
@@ -87,19 +87,19 @@ export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
setCreatingSession(true);
try {
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
name: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New conversation created",
message: "New checkpoint storage created",
type: "success",
});
} catch (error) {
console.error("Failed to create conversation:", error);
console.error("Failed to create checkpoint storage:", error);
addToast({
message: "Failed to create conversation",
message: "Failed to create checkpoint storage",
type: "error",
});
} finally {
File diff suppressed because it is too large Load Diff
@@ -1,55 +0,0 @@
/**
* About DevUI Modal - Shows information about the DevUI sample app
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
interface AboutModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AboutModal({ open, onOpenChange }: AboutModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader className="p-6 pb-4">
<DialogTitle>About DevUI</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
<div className="px-6 pb-6 space-y-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -32,7 +32,7 @@ export function AppHeader({
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode } = useDevUIStore();
const { oaiMode, serverVersion } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
@@ -64,6 +64,11 @@ export function AppHeader({
</defs>
</svg>
Dev UI
{serverVersion && (
<span className="text-xs text-muted-foreground ml-1">
v{serverVersion}
</span>
)}
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
@@ -90,7 +95,14 @@ export function AppHeader({
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
<Button variant="ghost" size="sm" onClick={(e: React.MouseEvent) => { e.stopPropagation(); onSettingsClick?.(); }}>
<Button
variant="ghost"
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onSettingsClick?.();
}}
>
<Settings className="h-4 w-4" />
</Button>
</div>
@@ -6,5 +6,4 @@ export { AppHeader } from "./app-header";
export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { AboutModal } from "./about-modal";
export { DeploymentModal } from "./deployment-modal";
@@ -41,8 +41,8 @@ export function SettingsModal({
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, auth status, and server capabilities from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, and version from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -608,6 +608,62 @@ export function SettingsModal({
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-mono">{serverVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Runtime:</span>
<span className="font-mono capitalize">{runtime || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">UI Mode:</span>
<span className="font-mono capitalize">{uiMode || 'Unknown'}</span>
</div>
</div>
{/* Capabilities section - only show if we have capability data */}
{(serverCapabilities || authRequired !== undefined) && (
<div className="space-y-2 pt-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
<div className="space-y-1 text-sm">
{serverCapabilities?.tracing !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tracing:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.tracing ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.tracing ? 'Enabled' : 'Disabled'}
</span>
</div>
)}
{serverCapabilities?.openai_proxy !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">OpenAI Proxy:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.openai_proxy ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.openai_proxy ? 'Available' : 'Not Configured'}
</span>
</div>
)}
{serverCapabilities?.deployment !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Deployment:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.deployment ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.deployment ? 'Available' : 'Disabled'}
</span>
</div>
)}
{authRequired !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Authentication:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${authRequired ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-muted text-muted-foreground'}`}>
{authRequired ? 'Required' : 'Not Required'}
</span>
</div>
)}
</div>
</div>
)}
<div className="flex justify-center pt-2">
<Button
variant="outline"
@@ -0,0 +1,471 @@
/**
* ChatMessageInput - Reusable chat input component with file upload and rich text support
* Features: Text input, file upload, drag & drop, paste handling, attachments
*/
import { useState, useRef, useCallback, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import {
SendHorizontal,
Square,
FileText,
Paperclip,
} from "lucide-react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { ResponseInputContent, ResponseInputTextParam, ResponseInputImageParam, ResponseInputFileParam } from "@/types/agent-framework";
export interface ChatMessageInputProps {
onSubmit: (content: ResponseInputContent[]) => Promise<void>;
isSubmitting?: boolean;
isStreaming?: boolean;
onCancel?: () => void;
isCancelling?: boolean;
placeholder?: string;
showFileUpload?: boolean;
maxAttachments?: number;
className?: string;
disabled?: boolean;
entityName?: string; // For placeholder text
/** Files dropped from parent (via useDragDrop hook) */
externalFiles?: File[];
/** Called after external files have been processed */
onExternalFilesProcessed?: () => void;
}
export function ChatMessageInput({
onSubmit,
isSubmitting = false,
isStreaming = false,
onCancel,
isCancelling = false,
placeholder,
showFileUpload = true,
maxAttachments = 10,
className = "",
disabled = false,
entityName = "assistant",
externalFiles,
onExternalFilesProcessed,
}: ChatMessageInputProps) {
const [inputValue, setInputValue] = useState("");
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Process external files from parent (via useDragDrop hook)
useEffect(() => {
if (externalFiles && externalFiles.length > 0) {
handleFilesSelected(externalFiles);
onExternalFilesProcessed?.();
}
}, [externalFiles, onExternalFilesProcessed]);
// Constants for text-to-file conversion
const TEXT_THRESHOLD = 10000; // 10KB threshold for converting to file
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split("\n");
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter((line) => line.includes(","));
const semicolonLines = lines.filter((line) => line.includes(";"));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas =
commaLines.reduce(
(sum, line) => sum + (line.match(/,/g) || []).length,
0
) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons =
semicolonLines.reduce(
(sum, line) => sum + (line.match(/;/g) || []).length,
0
) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Handle file selection
const handleFilesSelected = useCallback(
async (files: File[]) => {
if (attachments.length + files.length > maxAttachments) {
console.warn(`Cannot add more than ${maxAttachments} attachments`);
return;
}
const newAttachments: AttachmentItem[] = [];
for (const file of files) {
const attachment: AttachmentItem = {
id: `${Date.now()}-${Math.random()}`,
file,
type: getFileType(file),
};
// Generate preview for images
if (file.type.startsWith("image/")) {
try {
attachment.preview = await readFileAsDataURL(file);
} catch (error) {
console.error("Failed to generate preview:", error);
}
}
newAttachments.push(attachment);
}
setAttachments((prev) => [...prev, ...newAttachments]);
},
[attachments.length, maxAttachments]
);
// Handle attachment removal
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Handle paste events
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
for (const item of items) {
// Handle images (including screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue =
currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
disabled
)
return;
const messageText = inputValue.trim();
const content: ResponseInputContent[] = [];
// Add text content if present
if (messageText) {
content.push({
text: messageText,
type: "input_text",
} as ResponseInputTextParam);
}
// Add attachments
for (const attachment of attachments) {
const dataUri = await readFileAsDataURL(attachment.file);
if (attachment.file.type.startsWith("image/")) {
// Image attachment
content.push({
detail: "auto",
type: "input_image",
image_url: dataUri,
} as ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert text files back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as ResponseInputTextParam);
} else {
// Other file types
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
file_data: base64Data,
file_url: dataUri,
filename: attachment.file.name,
} as ResponseInputFileParam);
}
}
// Call the onSubmit callback
await onSubmit(content);
// Clear input and attachments after successful submission
setInputValue("");
setAttachments([]);
};
const canSendMessage =
!disabled &&
!isSubmitting &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
<div
className={`relative ${className}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={placeholder || `Message ${entityName}... (Shift+Enter for new line)`}
disabled={disabled || isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
{showFileUpload && (
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={disabled || isSubmitting || isStreaming}
/>
)}
{isStreaming && onCancel ? (
<Button
type="button"
size="icon"
onClick={onCancel}
disabled={isCancelling}
className="shrink-0 h-10 transition-all"
title="Stop generating"
aria-label="Stop generating response"
>
{isCancelling ? (
<LoadingSpinner size="sm" />
) : (
<Square className="h-4 w-4 fill-current" />
)}
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0 h-10 transition-all"
title="Send message"
aria-label="Send message"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</Button>
)}
</form>
</div>
);
}