Python: Add DevUI to AgentFramework (#781)

* add initial backend service code for devui

* add tests

* add frontendcode

* ui updates

* update readme

* ui updates and tweaks

* update ui bundle

* improve ui, add react flow base

* add react flow ui, fix background

* update ui, fix introspection bug

* update readme

* update ui build

* add support for multimodal input - both backend and frontend

* update ui build

* refactor as main framework package

* backend and tests refactor

* ui build update

* ui build update and refactor

* update pyproject.toml, update uv.lock

* update ui build

* ui update to fit oai responses types

* add backend updat and readme update

* mypy and other fixes

* add intial dev guide

* update ui and fix workflow bug

* update ui build, add thread support

* type fixes

* update workflow view

* update uv.lock

* fix workflow iport errors

* lint and other fixes

* mypy fixes

* minor update

* update ui build

* refactor to use oai dependencies directly, update examples to samples, improve typing

* readme update

* update ui and ui build

* fix workflow pyright error

* update ui, fix issues with run workflow placement, miniamp menu, etc

* make samples integrate serve

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-09-22 16:30:08 -07:00
committed by GitHub
Unverified
parent adb6dcd2af
commit 1ef24d3e91
98 changed files with 18045 additions and 4 deletions
@@ -0,0 +1,799 @@
/**
* AgentView - Complete agent interaction interface
* Features: Chat interface, message streaming, thread management
*/
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import { MessageRenderer } from "@/components/message_renderer";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Send, User, Bot, Plus, AlertCircle } from "lucide-react";
import { apiClient } from "@/services/api";
import type {
AgentInfo,
ChatMessage,
RunAgentRequest,
ThreadInfo,
ExtendedResponseStreamEvent,
} from "@/types";
interface ChatState {
messages: ChatMessage[];
isStreaming: boolean;
}
type DebugEventHandler = (event: ExtendedResponseStreamEvent | 'clear') => void;
interface AgentViewProps {
selectedAgent: AgentInfo;
onDebugEvent: DebugEventHandler;
}
interface MessageBubbleProps {
message: ChatMessage;
}
function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
const isError = message.error;
const Icon = isUser ? User : isError ? AlertCircle : Bot;
return (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
<div
className={`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
<Icon className="h-4 w-4" />
</div>
<div
className={`flex flex-col space-y-1 ${
isUser ? "items-end" : "items-start"
} max-w-[80%]`}
>
<div
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
{isError && (
<div className="flex items-start gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="font-medium text-sm">
Unable to process request
</span>
</div>
)}
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
<MessageRenderer
contents={message.contents}
isStreaming={message.streaming}
/>
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
);
}
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-muted">
<Bot className="h-4 w-4" />
</div>
<div className="flex items-center space-x-1 rounded bg-muted px-3 py-2">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
</div>
);
}
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [chatState, setChatState] = useState<ChatState>({
messages: [],
isStreaming: false,
});
const [currentThread, setCurrentThread] = useState<ThreadInfo | undefined>(
undefined
);
const [availableThreads, setAvailableThreads] = useState<ThreadInfo[]>([]);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loadingThreads, setLoadingThreads] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const accumulatedText = useRef<string>("");
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatState.messages, chatState.isStreaming]);
// Load threads when agent changes
useEffect(() => {
const loadThreads = async () => {
if (!selectedAgent) return;
setLoadingThreads(true);
try {
const threads = await apiClient.getThreads(selectedAgent.id);
setAvailableThreads(threads);
// Auto-select the most recent thread if available
if (threads.length > 0) {
const mostRecentThread = threads[0]; // Assuming threads are sorted by creation date (newest first)
setCurrentThread(mostRecentThread);
// Load messages for the selected thread
try {
const threadMessages = await apiClient.getThreadMessages(mostRecentThread.id);
setChatState({
messages: threadMessages,
isStreaming: false,
});
} catch (error) {
console.error("Failed to load thread messages:", error);
setChatState({
messages: [],
isStreaming: false,
});
}
}
} catch (error) {
console.error("Failed to load threads:", error);
setAvailableThreads([]);
} finally {
setLoadingThreads(false);
}
};
// Clear chat when agent changes
setChatState({
messages: [],
isStreaming: false,
});
setCurrentThread(undefined);
accumulatedText.current = "";
loadThreads();
}, [selectedAgent]);
// 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((prev) => [...prev, ...newAttachments]);
};
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.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 || chatState.isStreaming) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
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);
});
};
// Handle new thread creation
const handleNewThread = useCallback(async () => {
if (!selectedAgent) return;
try {
const newThread = await apiClient.createThread(selectedAgent.id);
setCurrentThread(newThread);
setAvailableThreads((prev) => [newThread, ...prev]);
setChatState({
messages: [],
isStreaming: false,
});
accumulatedText.current = "";
} catch (error) {
console.error("Failed to create thread:", error);
}
}, [selectedAgent]);
// Handle thread selection
const handleThreadSelect = useCallback(
async (threadId: string) => {
const thread = availableThreads.find((t) => t.id === threadId);
if (!thread) return;
setCurrentThread(thread);
try {
// Load thread messages from backend
const threadMessages = await apiClient.getThreadMessages(threadId);
setChatState({
messages: threadMessages,
isStreaming: false,
});
console.log(
`Restored ${threadMessages.length} messages for thread ${threadId}`
);
} catch (error) {
console.error("Failed to load thread messages:", error);
// Fallback to clearing messages
setChatState({
messages: [],
isStreaming: false,
});
}
accumulatedText.current = "";
},
[availableThreads]
);
// Handle message sending
const handleSendMessage = useCallback(
async (request: RunAgentRequest) => {
if (!selectedAgent) return;
// Extract text and attachments from OpenAI format for UI display
let displayText = "";
const attachmentContents: import("@/types/agent-framework").Contents[] =
[];
// Parse OpenAI ResponseInputParam to extract display content
for (const inputItem of request.input) {
if (inputItem.type === "message" && Array.isArray(inputItem.content)) {
for (const contentItem of inputItem.content) {
if (contentItem.type === "input_text") {
displayText += contentItem.text + " ";
} else if (contentItem.type === "input_image") {
attachmentContents.push({
type: "data",
uri: contentItem.image_url || "",
media_type: "image/png", // Default, should extract from data URI
} as import("@/types/agent-framework").DataContent);
} else if (contentItem.type === "input_file") {
const dataUri = `data:application/octet-stream;base64,${contentItem.file_data}`;
attachmentContents.push({
type: "data",
uri: dataUri,
media_type: "application/pdf", // Should be dynamic based on filename
} as import("@/types/agent-framework").DataContent);
}
}
}
}
const userMessageContents: import("@/types/agent-framework").Contents[] =
[
...(displayText.trim()
? [
{
type: "text",
text: displayText.trim(),
} as import("@/types/agent-framework").TextContent,
]
: []),
...attachmentContents,
];
// Add user message to UI state
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
contents: userMessageContents,
timestamp: new Date().toISOString(),
};
setChatState((prev) => ({
...prev,
messages: [...prev.messages, userMessage],
isStreaming: true,
}));
// Create assistant message placeholder
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
contents: [],
timestamp: new Date().toISOString(),
streaming: true,
};
setChatState((prev) => ({
...prev,
messages: [...prev.messages, assistantMessage],
}));
try {
// If no thread selected, create one automatically
let threadToUse = currentThread;
if (!threadToUse) {
try {
threadToUse = await apiClient.createThread(selectedAgent.id);
setCurrentThread(threadToUse);
setAvailableThreads((prev) => [threadToUse!, ...prev]);
} catch (error) {
console.error("Failed to create thread:", error);
}
}
const apiRequest = {
input: request.input,
thread_id: threadToUse?.id,
};
// Clear text accumulator for new response
accumulatedText.current = "";
// Clear debug panel events for new agent run
onDebugEvent('clear');
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
selectedAgent.id,
apiRequest
);
for await (const openAIEvent of streamGenerator) {
// Pass all events to debug panel
onDebugEvent(openAIEvent);
// Handle error events from the stream
if (openAIEvent.type === "error") {
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & {
message?: string;
};
const errorMessage = errorEvent.message || "An error occurred";
// Update assistant message with error and stop streaming
setChatState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id
? {
...msg,
contents: [
{
type: "text",
text: errorMessage,
},
],
streaming: false,
error: true, // Add error flag for styling
}
: msg
),
}));
return; // Exit stream processing early on error
}
// Handle text delta events for chat
if (
openAIEvent.type === "response.output_text.delta" &&
"delta" in openAIEvent &&
openAIEvent.delta
) {
accumulatedText.current += openAIEvent.delta;
// Update assistant message with accumulated content
setChatState((prev) => ({
...prev,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id
? {
...msg,
contents: [
{
type: "text",
text: accumulatedText.current,
},
],
}
: msg
),
}));
}
// Handle completion/error by detecting when streaming stops
// (Server will close the stream when done, so we'll exit the loop naturally)
}
// Stream ended - mark as complete
setChatState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id ? { ...msg, streaming: false } : msg
),
}));
} catch (error) {
console.error("Streaming error:", error);
setChatState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id
? {
...msg,
contents: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
},
],
streaming: false,
}
: msg
),
}));
}
},
[selectedAgent, currentThread, onDebugEvent]
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
!selectedAgent
)
return;
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[] =
[];
// 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 {
// EXACT OpenAI ResponseInputFileParam (but we need to handle the required fields)
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,
thread_id: currentThread?.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,
thread_id: currentThread?.id,
});
}
// Clear attachments after sending
setAttachments([]);
} finally {
setIsSubmitting(false);
}
};
const canSendMessage =
selectedAgent &&
!isSubmitting &&
!chatState.isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-sm">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Chat with {selectedAgent.name || selectedAgent.id}
</div>
</h2>
{/* Thread Controls */}
<div className="flex items-center gap-2">
<Select
value={currentThread?.id || ""}
onValueChange={handleThreadSelect}
disabled={loadingThreads || isSubmitting}
>
<SelectTrigger className="w-48">
<SelectValue
placeholder={
loadingThreads
? "Loading..."
: availableThreads.length === 0
? "No threads"
: currentThread
? `Thread ${currentThread.id.slice(-8)}`
: "Select thread"
}
/>
</SelectTrigger>
<SelectContent>
{availableThreads.map((thread) => (
<SelectItem key={thread.id} value={thread.id}>
<div className="flex items-center justify-between w-full">
<span>Thread {thread.id.slice(-8)}</span>
{thread.created_at && (
<span className="text-xs text-muted-foreground ml-3">
{new Date(thread.created_at).toLocaleDateString()}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="lg"
onClick={handleNewThread}
disabled={!selectedAgent || isSubmitting}
>
<Plus className="h-4 w-4 mr-2" />
New Thread
</Button>
</div>
</div>
{selectedAgent.description && (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
</p>
)}
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
{chatState.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<div className="text-muted-foreground text-sm">
Start a conversation with{" "}
{selectedAgent.name || selectedAgent.id}
</div>
<div className="text-xs text-muted-foreground mt-1">
Type a message below to begin
</div>
</div>
) : (
chatState.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))
)}
{chatState.isStreaming && !isSubmitting && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* 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>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}...`}
disabled={isSubmitting || chatState.isStreaming}
className="flex-1"
/>
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={isSubmitting || chatState.isStreaming}
/>
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,268 @@
/**
* ContentRenderer - Renders individual content items based on type
*/
import { useState } from "react";
import { Download, FileText, AlertCircle, Code } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { RenderProps } from "./types";
import {
isTextContent,
isFunctionCallContent,
isFunctionResultContent,
} from "@/types/agent-framework";
function TextContentRenderer({ content, isStreaming, className }: RenderProps) {
if (!isTextContent(content)) return null;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
{content.text}
{isStreaming && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
);
}
function DataContentRenderer({ content, className }: RenderProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "data") return null;
// Extract data URI and media type (updated for new field names)
const dataUri = typeof content.uri === "string" ? content.uri : "";
const mediaTypeMatch = dataUri.match(/^data:([^;]+)/);
const mediaType = content.media_type || mediaTypeMatch?.[1] || "unknown";
const isImage = mediaType.startsWith("image/");
const isPdf = mediaType === "application/pdf";
if (isImage && !imageError) {
return (
<div className={`my-2 ${className || ""}`}>
<img
src={dataUri}
alt="Uploaded image"
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
isExpanded ? "max-h-none" : "max-h-64"
}`}
onClick={() => setIsExpanded(!isExpanded)}
onError={() => setImageError(true)}
/>
<div className="text-xs text-muted-foreground mt-1">
{mediaType} Click to {isExpanded ? "collapse" : "expand"}
</div>
</div>
);
}
// Fallback for non-images or failed images
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
{isPdf ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<Download className="h-4 w-4" />
)}
<span className="text-sm font-medium">
{isPdf ? "PDF Document" : "File Attachment"}
</span>
<span className="text-xs text-muted-foreground">({mediaType})</span>
</div>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => {
const link = document.createElement("a");
link.href = dataUri;
link.download = `attachment.${mediaType.split("/")[1] || "bin"}`;
link.click();
}}
>
<Download className="h-3 w-3 mr-1" />
Download
</Button>
</div>
);
}
function FunctionCallRenderer({ content, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isFunctionCallContent(content)) return null;
let parsedArgs;
try {
parsedArgs =
typeof content.arguments === "string"
? JSON.parse(content.arguments)
: content.arguments;
} catch {
parsedArgs = content.arguments;
}
return (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800">
Function Call: {content.name}
</span>
<span className="text-xs text-blue-600">
{isExpanded ? "▼" : "▶"}
</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
<div className="text-blue-600 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
function FunctionResultRenderer({ content, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isFunctionResultContent(content)) return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">
Function Result
</span>
<span className="text-xs text-green-600">
{isExpanded ? "▼" : "▶"}
</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
<pre className="whitespace-pre-wrap">
{typeof content.result === "string"
? content.result
: JSON.stringify(content.result, null, 2)}
</pre>
</div>
)}
</div>
);
}
function ErrorContentRenderer({ content, className }: RenderProps) {
if (content.type !== "error") return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-red-50 ${className || ""}`}>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium text-red-800">Error</span>
{content.error_code && (
<span className="text-xs text-red-600">({content.error_code})</span>
)}
</div>
<div className="mt-1 text-sm text-red-700">{content.error}</div>
</div>
);
}
function UriContentRenderer({ content, className }: RenderProps) {
const [imageError, setImageError] = useState(false);
if (content.type !== "uri") return null;
const isImage = content.media_type?.startsWith("image/");
if (isImage && !imageError) {
return (
<div className={`my-2 ${className || ""}`}>
<img
src={content.uri}
alt="Referenced image"
className="rounded-lg border max-w-full max-h-64"
onError={() => setImageError(true)}
/>
<div className="text-xs text-muted-foreground mt-1">
<a
href={content.uri}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{content.uri}
</a>
</div>
</div>
);
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<a
href={content.uri}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline"
>
{content.media_type || "External Link"}
</a>
</div>
<div className="text-xs text-muted-foreground mt-1 break-all">
{content.uri}
</div>
</div>
);
}
export function ContentRenderer({ content, isStreaming, className }: RenderProps) {
switch (content.type) {
case "text":
return (
<TextContentRenderer
content={content}
isStreaming={isStreaming}
className={className}
/>
);
case "data":
return <DataContentRenderer content={content} className={className} />;
case "uri":
return <UriContentRenderer content={content} className={className} />;
case "function_call":
return (
<FunctionCallRenderer content={content} className={className} />
);
case "function_result":
return (
<FunctionResultRenderer content={content} className={className} />
);
case "error":
return <ErrorContentRenderer content={content} className={className} />;
default:
// Fallback for unsupported content types
return (
<div className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}>
<div>Unsupported content type: {content.type}</div>
<pre className="mt-1 text-xs whitespace-pre-wrap">
{JSON.stringify(content, null, 2)}
</pre>
</div>
);
}
}
@@ -0,0 +1,38 @@
/**
* MessageRenderer - Main orchestrator for rendering message contents
*/
import { StreamingRenderer } from "./StreamingRenderer";
import { ContentRenderer } from "./ContentRenderer";
import type { MessageRendererProps } from "./types";
export function MessageRenderer({
contents,
isStreaming = false,
className,
}: MessageRendererProps) {
// If not streaming, render each content item individually
if (!isStreaming) {
return (
<div className={className}>
{contents.map((content, index) => (
<ContentRenderer
key={index}
content={content}
isStreaming={false}
className={index > 0 ? "mt-2" : ""}
/>
))}
</div>
);
}
// For streaming, use the streaming renderer for smart accumulation
return (
<StreamingRenderer
contents={contents}
isStreaming={isStreaming}
className={className}
/>
);
}
@@ -0,0 +1,114 @@
/**
* StreamingRenderer - Handles accumulation and display of streaming content
*/
import { useState, useEffect } from "react";
import { ContentRenderer } from "./ContentRenderer";
import type { Contents, MessageRenderState } from "./types";
import { isTextContent } from "@/types/agent-framework";
interface StreamingRendererProps {
contents: Contents[];
isStreaming?: boolean;
className?: string;
}
export function StreamingRenderer({
contents,
isStreaming = false,
className,
}: StreamingRendererProps) {
const [renderState, setRenderState] = useState<MessageRenderState>({
textAccumulator: "",
dataContentItems: [],
functionCalls: [],
errors: [],
isComplete: !isStreaming,
});
useEffect(() => {
// Process and accumulate content
let textAccumulator = "";
const dataContentItems: Contents[] = [];
const functionCalls: Contents[] = [];
const errors: Contents[] = [];
contents.forEach((content) => {
if (isTextContent(content)) {
textAccumulator += content.text;
} else if (content.type === "data") {
// Only show data content when streaming is complete or item is complete
if (!isStreaming) {
dataContentItems.push(content);
}
} else if (content.type === "function_call") {
functionCalls.push(content);
} else if (content.type === "error") {
errors.push(content);
} else {
// Other content types (uri, function_result, etc.)
dataContentItems.push(content);
}
});
setRenderState({
textAccumulator,
dataContentItems,
functionCalls,
errors,
isComplete: !isStreaming,
});
}, [contents, isStreaming]);
const hasTextContent = renderState.textAccumulator.length > 0;
const hasOtherContent =
renderState.dataContentItems.length > 0 ||
renderState.functionCalls.length > 0 ||
renderState.errors.length > 0;
return (
<div className={className}>
{/* Render accumulated text with streaming indicator */}
{hasTextContent && (
<div className="whitespace-pre-wrap break-words">
{renderState.textAccumulator}
{isStreaming && hasTextContent && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
)}
{/* Render other content types when complete or non-data items immediately */}
{hasOtherContent && (
<div className="mt-2 space-y-2">
{renderState.errors.map((content, index) => (
<ContentRenderer key={`error-${index}`} content={content} />
))}
{renderState.functionCalls.map((content, index) => (
<ContentRenderer key={`function-${index}`} content={content} />
))}
{renderState.dataContentItems.map((content, index) => (
<ContentRenderer
key={`data-${index}`}
content={content}
isStreaming={isStreaming}
/>
))}
</div>
)}
{/* Show loading indicator when streaming and no text content yet */}
{isStreaming && !hasTextContent && !hasOtherContent && (
<div className="flex items-center space-x-1">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,8 @@
/**
* Message Renderer - Exports
*/
export { MessageRenderer } from "./MessageRenderer";
export { ContentRenderer } from "./ContentRenderer";
export { StreamingRenderer } from "./StreamingRenderer";
export type { MessageRendererProps, RenderProps, MessageRenderState } from "./types";
@@ -0,0 +1,48 @@
/**
* Types for message rendering components
*/
// Re-export and extend types from agent-framework
import type {
Contents,
TextContent,
DataContent,
UriContent,
FunctionCallContent,
FunctionResultContent,
ErrorContent,
AgentRunResponseUpdate,
} from "@/types/agent-framework";
export type {
Contents,
TextContent,
DataContent,
UriContent,
FunctionCallContent,
FunctionResultContent,
ErrorContent,
AgentRunResponseUpdate,
};
// UI-specific types for message rendering
export interface MessageRenderState {
// Track accumulated content during streaming
textAccumulator: string;
dataContentItems: Contents[];
functionCalls: Contents[];
errors: Contents[];
isComplete: boolean;
}
export interface RenderProps {
content: Contents;
isStreaming?: boolean;
className?: string;
}
export interface MessageRendererProps {
contents: Contents[];
isStreaming?: boolean;
className?: string;
}
@@ -0,0 +1,39 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,54 @@
/**
* 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>
<DialogTitle>About DevUI</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="p-4 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>
);
}
@@ -0,0 +1,48 @@
/**
* AppHeader - Global application header
* Features: Entity selection, global settings, theme toggle
*/
import { Button } from "@/components/ui/button";
import { EntitySelector } from "@/components/shared/entity-selector";
import { ModeToggle } from "@/components/mode-toggle";
import { Settings } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface AppHeaderProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
isLoading?: boolean;
onSettingsClick?: () => void;
}
export function AppHeader({
agents,
workflows,
selectedItem,
onSelect,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="font-semibold">Dev UI</div>
<EntitySelector
agents={agents}
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
isLoading={isLoading}
/>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
<Button variant="ghost" size="sm" onClick={onSettingsClick}>
<Settings className="h-4 w-4" />
</Button>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
/**
* EntitySelector - High-quality dropdown for selecting agents/workflows
* Features: Type indicators, tool counts, keyboard navigation, search
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, FolderOpen, Database } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
isLoading?: boolean;
}
const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
const getSourceIcon = (source: "directory" | "in_memory") => {
return source === "directory" ? FolderOpen : Database;
};
export function EntitySelector({
agents,
workflows,
selectedItem,
onSelect,
isLoading = false,
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
const allItems = [...agents, ...workflows].sort(
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
);
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
setOpen(false);
};
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Entity";
const itemCount =
selectedItem?.type === "workflow"
? (selectedItem as WorkflowInfo).executors?.length || 0
: (selectedItem as AgentInfo)?.tools?.length || 0;
const itemLabel = selectedItem?.type === "workflow" ? "executors" : "tools";
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-64 justify-between font-mono text-sm"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner size="sm" />
<span className="text-muted-foreground">Loading...</span>
</div>
) : (
<>
<div className="flex items-center gap-2 min-w-0">
<TypeIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{displayName}</span>
{selectedItem && (
<Badge variant="secondary" className="ml-auto flex-shrink-0">
{itemCount} {itemLabel}
</Badge>
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{agents.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const SourceIcon = getSourceIcon(agent.source);
return (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSelect(agent)}
className="cursor-pointer"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
{agent.name || agent.id}
</div>
{agent.description && (
<div className="text-xs text-muted-foreground truncate">
{agent.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{agent.tools.length}
</Badge>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{workflows.length > 0 && (
<>
{agents.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const SourceIcon = getSourceIcon(workflow.source);
return (
<DropdownMenuItem
key={workflow.id}
onClick={() => handleSelect(workflow)}
className="cursor-pointer"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
{workflow.name || workflow.id}
</div>
{workflow.description && (
<div className="text-xs text-muted-foreground truncate">
{workflow.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{workflow.executors.length}
</Badge>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
<div className="text-center text-muted-foreground py-2">
{isLoading ? "Loading entities..." : "No entities found"}
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
interface ThemeProviderProps {
children: React.ReactNode
attribute?: "class" | "data-theme" | "data-mode"
defaultTheme?: string
enableSystem?: boolean
disableTransitionOnChange?: boolean
}
export function ThemeProvider({
children,
attribute = "class",
defaultTheme = "dark",
enableSystem = true,
disableTransitionOnChange = true,
...props
}: ThemeProviderProps) {
return (
<NextThemesProvider
attribute={attribute}
defaultTheme={defaultTheme}
enableSystem={enableSystem}
disableTransitionOnChange={disableTransitionOnChange}
{...props}
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,115 @@
/**
* AttachmentGallery - Shows uploaded files with thumbnails and remove options
*/
import { useState } from "react";
import { FileText, Image, Trash2 } from "lucide-react";
export interface AttachmentItem {
id: string;
file: File;
preview?: string; // Data URL for preview
type: "image" | "pdf" | "other";
}
interface AttachmentGalleryProps {
attachments: AttachmentItem[];
onRemoveAttachment: (id: string) => void;
className?: string;
}
export function AttachmentGallery({
attachments,
onRemoveAttachment,
className = "",
}: AttachmentGalleryProps) {
if (attachments.length === 0) return null;
return (
<div className={`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${className}`}>
{attachments.map((attachment) => (
<AttachmentPreview
key={attachment.id}
attachment={attachment}
onRemove={() => onRemoveAttachment(attachment.id)}
/>
))}
</div>
);
}
interface AttachmentPreviewProps {
attachment: AttachmentItem;
onRemove: () => void;
}
function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
const [isHovered, setIsHovered] = useState(false);
const renderPreview = () => {
switch (attachment.type) {
case "image":
return attachment.preview ? (
<img
src={attachment.preview}
alt={attachment.file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full bg-gray-200">
<Image className="h-6 w-6 text-gray-400" />
</div>
);
case "pdf":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-red-50">
<FileText className="h-6 w-6 text-red-500 mb-1" />
<span className="text-xs text-red-600">PDF</span>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
<FileText className="h-6 w-6 text-gray-500 mb-1" />
<span className="text-xs text-gray-600">FILE</span>
</div>
);
}
};
return (
<div
className="relative w-16 h-16 rounded border overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={attachment.file.name}
>
{renderPreview()}
{/* Dark overlay with centered delete icon on hover */}
<div
className={`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${
isHovered
? 'opacity-100 backdrop-blur-sm'
: 'opacity-0 pointer-events-none'
}`}
onClick={onRemove}
>
<div className={`transition-all duration-200 ease-in-out ${
isHovered
? 'scale-100 opacity-100'
: 'scale-75 opacity-0'
}`}>
<Trash2 className="h-5 w-5 text-white drop-shadow-lg" />
</div>
</div>
{/* File name tooltip */}
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{attachment.file.name}
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge };
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,84 @@
import React from "react";
import { X } from "lucide-react";
import { Button } from "./button";
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
interface DialogContentProps {
children: React.ReactNode;
className?: string;
}
interface DialogHeaderProps {
children: React.ReactNode;
}
interface DialogTitleProps {
children: React.ReactNode;
}
interface DialogFooterProps {
children: React.ReactNode;
}
export function Dialog({ open, onOpenChange, children }: DialogProps) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => onOpenChange(false)}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" />
{/* Modal content */}
<div onClick={(e) => e.stopPropagation()}>{children}</div>
</div>
);
}
export function DialogContent({
children,
className = "",
}: DialogContentProps) {
return (
<div
className={`relative bg-background border rounded-lg shadow-lg max-w-lg w-full max-h-[90vh] overflow-hidden ${className}`}
>
{children}
</div>
);
}
export function DialogHeader({ children }: DialogHeaderProps) {
return (
<div className="flex items-center justify-between p-4 border-b">
{children}
</div>
);
}
export function DialogTitle({ children }: DialogTitleProps) {
return <h2 className="text-lg font-semibold">{children}</h2>;
}
export function DialogClose({ onClose }: { onClose: () => void }) {
return (
<Button variant="ghost" size="sm" onClick={onClose} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
);
}
export function DialogFooter({ children }: DialogFooterProps) {
return (
<div className="flex justify-end gap-2 p-4 border-t bg-muted/50">
{children}
</div>
);
}
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,141 @@
/**
* FileUpload - Upload button with drag & drop support
*/
import { useRef } from "react";
import { Upload } from "lucide-react";
import { Button } from "./button";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
accept?: string;
multiple?: boolean;
maxSize?: number; // in bytes
disabled?: boolean;
className?: string;
}
export function FileUpload({
onFilesSelected,
accept = "image/*,.pdf",
multiple = true,
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
disabled = false,
className = "",
}: FileUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (files: FileList | null) => {
if (!files || files.length === 0) return;
const validFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
// Size validation
if (file.size > maxSize) {
errors.push(`${file.name} is too large (max ${formatFileSize(maxSize)})`);
return;
}
// Type validation (basic)
if (accept && !isFileAccepted(file, accept)) {
errors.push(`${file.name} is not an accepted file type`);
return;
}
validFiles.push(file);
});
if (errors.length > 0) {
console.warn("File upload errors:", errors);
// In a production app, you might want to show these errors to the user
}
if (validFiles.length > 0) {
onFilesSelected(validFiles);
}
};
const handleButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFileSelect(e.target.files);
// Reset input to allow selecting the same file again
e.target.value = "";
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
const files = e.dataTransfer.files;
handleFileSelect(files);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileInputChange}
className="hidden"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleButtonClick}
disabled={disabled}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="shrink-0 transition-colors hover:bg-muted"
title="Upload files (images, PDFs)"
>
<Upload className="h-4 w-4" />
</Button>
</div>
);
}
// Helper functions
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function isFileAccepted(file: File, accept: string): boolean {
const acceptPatterns = accept.split(",").map((pattern) => pattern.trim());
return acceptPatterns.some((pattern) => {
if (pattern.startsWith(".")) {
// File extension check
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
} else if (pattern.includes("/*")) {
// MIME type wildcard check (e.g., "image/*")
const [mainType] = pattern.split("/");
return file.type.startsWith(mainType + "/");
} else {
// Exact MIME type check
return file.type === pattern;
}
});
}
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,23 @@
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg"
className?: string
}
export function LoadingSpinner({ size = "md", className }: LoadingSpinnerProps) {
return (
<Loader2
className={cn(
"animate-spin",
{
"h-4 w-4": size === "sm",
"h-6 w-6": size === "md",
"h-8 w-8": size === "lg",
},
className
)}
/>
)
}
@@ -0,0 +1,52 @@
import { LoadingSpinner } from "./loading-spinner"
import { cn } from "@/lib/utils"
interface LoadingStateProps {
message?: string
description?: string
size?: "sm" | "md" | "lg"
className?: string
fullPage?: boolean
}
export function LoadingState({
message = "Loading...",
description,
size = "md",
className,
fullPage = false
}: LoadingStateProps) {
const content = (
<div className={cn(
"flex flex-col items-center justify-center gap-3",
fullPage ? "min-h-[50vh]" : "py-8",
className
)}>
<LoadingSpinner size={size} className="text-muted-foreground" />
<div className="text-center space-y-1">
<p className={cn(
"font-medium text-muted-foreground",
size === "sm" && "text-sm",
size === "lg" && "text-lg"
)}>
{message}
</p>
{description && (
<p className="text-sm text-muted-foreground/80">
{description}
</p>
)}
</div>
</div>
)
if (fullPage) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
{content}
</div>
)
}
return content
}
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,277 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
CheckCircle,
XCircle,
Clock,
Loader2,
AlertCircle,
Play,
Flag,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type ExecutorState =
| "pending"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface ExecutorNodeData extends Record<string, unknown> {
executorId: string;
executorType?: string;
name?: string;
state: ExecutorState;
inputData?: unknown;
outputData?: unknown;
error?: string;
isSelected?: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
}
const getExecutorStateConfig = (state: ExecutorState) => {
switch (state) {
case "running":
return {
icon: Loader2,
text: "Running",
borderColor: "border-blue-500 dark:border-blue-400",
iconColor: "text-blue-600 dark:text-blue-400",
statusColor: "bg-blue-500 dark:bg-blue-400",
animate: "animate-spin",
glow: "shadow-lg shadow-blue-500/20",
};
case "completed":
return {
icon: CheckCircle,
text: "Completed",
borderColor: "border-green-500 dark:border-green-400",
iconColor: "text-green-600 dark:text-green-400",
statusColor: "bg-green-500 dark:bg-green-400",
animate: "",
glow: "shadow-lg shadow-green-500/20",
};
case "failed":
return {
icon: XCircle,
text: "Failed",
borderColor: "border-red-500 dark:border-red-400",
iconColor: "text-red-600 dark:text-red-400",
statusColor: "bg-red-500 dark:bg-red-400",
animate: "",
glow: "shadow-lg shadow-red-500/20",
};
case "cancelled":
return {
icon: AlertCircle,
text: "Cancelled",
borderColor: "border-orange-500 dark:border-orange-400",
iconColor: "text-orange-600 dark:text-orange-400",
statusColor: "bg-orange-500 dark:bg-orange-400",
animate: "",
glow: "shadow-lg shadow-orange-500/20",
};
case "pending":
default:
return {
icon: Clock,
text: "Pending",
borderColor: "border-gray-300 dark:border-gray-600",
iconColor: "text-gray-500 dark:text-gray-400",
statusColor: "bg-gray-400 dark:bg-gray-500",
animate: "",
glow: "",
};
}
};
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const IconComponent = config.icon;
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
// Helper to safely render data with full details
const renderDataDetails = () => {
const details = [];
if (nodeData.error && typeof nodeData.error === "string") {
details.push(
<div key="error" className="mb-2">
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
{nodeData.error}
</div>
</div>
);
}
if (nodeData.outputData) {
try {
const outputStr =
typeof nodeData.outputData === "string"
? nodeData.outputData
: JSON.stringify(nodeData.outputData, null, 2);
details.push(
<div key="output" className="mb-2">
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
<div className="text-xs text-gray-700 dark:text-gray-300 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800 max-h-20 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
</div>
</div>
);
} catch {
details.push(
<div key="output" className="mb-2">
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
<div className="text-xs text-gray-600 dark:text-gray-400 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800">
[Unable to display output data]
</div>
</div>
);
}
}
if (nodeData.inputData) {
try {
const inputStr =
typeof nodeData.inputData === "string"
? nodeData.inputData
: JSON.stringify(nodeData.inputData, null, 2);
details.push(
<div key="input" className="mb-2">
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
<div className="text-xs text-gray-700 dark:text-gray-300 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800 max-h-20 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{inputStr}</pre>
</div>
</div>
);
} catch {
details.push(
<div key="input" className="mb-2">
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
<div className="text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800">
[Unable to display input data]
</div>
</div>
);
}
}
return details.length > 0 ? details : null;
};
return (
<div
className={cn(
"group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200",
config.borderColor,
selected ? "ring-2 ring-blue-500 ring-offset-2" : "",
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Start/End Badge */}
{(nodeData.isStartNode || nodeData.isEndNode) && (
<div className={cn(
"absolute -top-6 left-2 px-2 py-1 rounded-t text-xs font-medium text-white flex items-center gap-1 z-10 shadow-sm",
nodeData.isStartNode ? "bg-green-600" : "bg-red-600"
)}>
{nodeData.isStartNode ? (
<>
<Play className="w-3 h-3" />
START
</>
) : (
<>
<Flag className="w-3 h-3" />
END
</>
)}
</div>
)}
{/* Only show target handle if not a start node */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={Position.Left}
className="!w-2 !h-5 !rounded-r-sm !-ml-1 !border-0 transition-colors"
style={{
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
}}
/>
)}
{/* Only show source handle if not an end node */}
{!nodeData.isEndNode && (
<Handle
type="source"
position={Position.Right}
className="!w-2 !h-5 !rounded-l-sm !-mr-1 !border-0 transition-colors"
style={{
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
}}
/>
)}
<div className="p-4">
{/* Header with icon and title */}
<div className="flex items-start gap-3 mb-3">
<div className="flex-shrink-0 mt-0.5">
<IconComponent
className={cn("w-5 h-5", config.iconColor, config.animate)}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{nodeData.executorType}
</p>
)}
</div>
</div>
{/* State indicator */}
<div className="flex items-center gap-2 mb-2">
<div
className={cn(
"w-2 h-2 rounded-full",
config.statusColor,
config.animate
)}
/>
<span className={cn("text-xs font-medium", config.iconColor)}>
{config.text}
</span>
</div>
{/* Data details */}
{hasData && (
<div className="mt-3">
{renderDataDetails()}
</div>
)}
{/* Running animation overlay */}
{isRunning && (
<div className="absolute inset-0 rounded border-2 border-blue-500/30 dark:border-blue-400/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
);
});
ExecutorNode.displayName = "ExecutorNode";
@@ -0,0 +1,463 @@
import { useMemo, useCallback, useEffect } from "react";
import {
MoreVertical,
Map,
Grid3X3,
RotateCcw,
Maximize,
Shuffle,
Zap,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
BackgroundVariant,
type NodeTypes,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
import {
convertWorkflowDumpToNodes,
convertWorkflowDumpToEdges,
applyDagreLayout,
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { Workflow } from "@/types/workflow";
const nodeTypes: NodeTypes = {
executor: ExecutorNode,
};
// ViewOptions panel component that renders inside ReactFlow
function ViewOptionsPanel({
workflowDump,
onNodeSelect,
viewOptions,
onToggleViewOption,
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
}) {
const { fitView, setViewport, setNodes } = useReactFlow();
const handleResetZoom = () => {
setViewport({ x: 0, y: 0, zoom: 1 });
};
const handleFitToScreen = () => {
fitView({ padding: 0.2 });
};
const handleAutoArrange = () => {
if (!workflowDump) return;
const currentNodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(currentNodes, currentEdges, "LR");
setNodes(layoutedNodes);
};
return (
<div className="absolute top-4 right-4 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">View options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showMinimap")}
>
<div className="flex items-center">
<Map className="mr-2 h-4 w-4" />
Show Minimap
</div>
<Checkbox checked={viewOptions.showMinimap} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showGrid")}
>
<div className="flex items-center">
<Grid3X3 className="mr-2 h-4 w-4" />
Show Grid
</div>
<Checkbox checked={viewOptions.showGrid} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("animateRun")}
>
<div className="flex items-center">
<Zap className="mr-2 h-4 w-4" />
Animate Run
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleResetZoom}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Zoom
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFitToScreen}>
<Maximize className="mr-2 h-4 w-4" />
Fit to Screen
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAutoArrange}>
<Shuffle className="mr-2 h-4 w-4" />
Auto-arrange
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
interface WorkflowFlowProps {
workflowDump?: Workflow;
events: ExtendedResponseStreamEvent[];
isStreaming: boolean;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
className?: string;
viewOptions?: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
};
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
}
// Animation handler component that runs inside ReactFlow context
function WorkflowAnimationHandler({
nodes,
nodeUpdates,
isStreaming,
animateRun,
}: {
nodes: Node<ExecutorNodeData>[];
nodeUpdates: Record<string, NodeUpdate>;
isStreaming: boolean;
animateRun: boolean;
}) {
const { fitView } = useReactFlow();
// Smooth animation to center on running node when workflow starts/progresses
useEffect(() => {
if (!animateRun) return;
if (isStreaming) {
// Zoom in on running nodes during execution
const runningNodes = nodes.filter(
(node) => node.data.state === "running"
);
if (runningNodes.length > 0) {
const targetNode = runningNodes[0];
// Use fitView to smoothly focus on the running node with animation
fitView({
nodes: [targetNode],
duration: 800,
padding: 0.3,
minZoom: 0.8,
maxZoom: 1.5,
});
}
} else if (nodes.length > 0) {
// Zoom back out to show full workflow when execution completes
fitView({
duration: 1000,
padding: 0.2,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeUpdates, isStreaming, animateRun, nodes]);
return null; // This component doesn't render anything
}
export function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
onToggleViewOption,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
if (!workflowDump) {
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0 ? applyDagreLayout(nodes, edges, "LR") : nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
};
}, [workflowDump, onNodeSelect]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Process events and update node/edge states
const nodeUpdates = useMemo(() => {
return processWorkflowEvents(events);
}, [events]);
// Update nodes and edges with real-time state from events
useMemo(() => {
if (Object.keys(nodeUpdates).length > 0) {
setNodes((currentNodes) =>
updateNodesWithEvents(currentNodes, nodeUpdates)
);
} else if (events.length === 0) {
// Reset all nodes to pending state when events are cleared
setNodes((currentNodes) =>
currentNodes.map((node) => ({
...node,
data: {
...node.data,
state: "pending" as const,
outputData: undefined,
error: undefined,
},
}))
);
}
}, [nodeUpdates, setNodes, events.length]);
// Update edges with sequence-based analysis (separate from nodeUpdates)
useMemo(() => {
if (events.length > 0) {
setEdges((currentEdges) => {
const updatedEdges = updateEdgesWithSequenceAnalysis(
currentEdges,
events
);
return updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) =>
currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}))
);
}
}, [events, setEdges]);
// Initialize nodes only when workflow structure changes (not on state updates)
useEffect(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowDump]); // Only re-initialize when workflowDump changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
event.stopPropagation();
onNodeSelect?.(node.data.executorId, node.data);
},
[onNodeSelect]
);
if (!workflowDump) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Workflow Data</div>
<div className="text-sm">Workflow dump is not available.</div>
</div>
</div>
);
}
if (initialNodes.length === 0) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Executors Found</div>
<div className="text-sm">
Could not extract executors from workflow dump.
</div>
<details className="mt-2 text-xs">
<summary className="cursor-pointer">Debug Info</summary>
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto">
{JSON.stringify(workflowDump, null, 2)}
</pre>
</details>
</div>
</div>
);
}
return (
<div className={`h-full w-full ${className}`}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={1.5}
defaultEdgeOptions={{
type: "default",
animated: false,
style: { stroke: "#6b7280", strokeWidth: 2 },
}}
nodesDraggable={!isStreaming} // Disable dragging during execution
nodesConnectable={false} // Disable connecting nodes
elementsSelectable={true}
proOptions={{ hideAttribution: true }}
>
{viewOptions.showGrid && (
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#e5e7eb"
className="dark:opacity-30"
/>
)}
<Controls
position="bottom-left"
showInteractive={false}
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "3px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
{viewOptions.showMinimap && (
<MiniMap
nodeColor={(node: Node) => {
const data = node.data as ExecutorNodeData;
const state = data?.state;
switch (state) {
case "running":
return "#3b82f6";
case "completed":
return "#10b981";
case "failed":
return "#ef4444";
case "cancelled":
return "#f97316";
default:
return "#6b7280";
}
}}
maskColor="rgba(0, 0, 0, 0.1)"
position="bottom-right"
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "8px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
)}
<WorkflowAnimationHandler
nodes={nodes}
nodeUpdates={nodeUpdates}
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
viewOptions={viewOptions}
onToggleViewOption={onToggleViewOption}
/>
</ReactFlow>
{/* CSS for custom edge animations and dark theme controls */}
<style>{`
.react-flow__edge-path {
transition: stroke 0.3s ease, stroke-width 0.3s ease;
}
.react-flow__edge.animated .react-flow__edge-path {
stroke-dasharray: 5 5;
animation: dash 1s linear infinite;
}
@keyframes dash {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -10; }
}
/* Dark theme styles for React Flow controls */
.dark .react-flow__controls {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
}
.dark .react-flow__controls-button {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
color: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover {
background-color: rgba(55, 65, 81, 0.9) !important;
color: rgb(255, 255, 255) !important;
}
.dark .react-flow__controls-button svg {
fill: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover svg {
fill: rgb(255, 255, 255) !important;
}
`}</style>
</div>
);
}
@@ -0,0 +1,503 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
DialogFooter,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { cn } from "@/lib/utils";
import type { JSONSchemaProperty } from "@/types";
interface FormFieldProps {
name: string;
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
}
function FormField({ name, schema, value, onChange }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// Determine if this field should span full width
const shouldSpanFullWidth =
schema.format === "textarea" ||
(description && description.length > 100) ||
type === "object" ||
type === "array";
const shouldSpanTwoColumns =
type === "object" ||
schema.format === "textarea" ||
(description && description.length > 50);
const fieldContent = (() => {
// Handle different field types based on JSON Schema
switch (type) {
case "string":
if (enumValues) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Select
value={
typeof value === "string" && value
? value
: typeof defaultValue === "string"
? defaultValue
: enumValues[0]
}
onValueChange={(val) => onChange(val)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${name}`} />
</SelectTrigger>
<SelectContent>
{enumValues.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else if (
schema.format === "textarea" ||
(description && description.length > 100)
) {
// Multi-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Input
id={name}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Input
id={name}
type="number"
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val = parseFloat(e.target.value);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "boolean":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>{name}</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Textarea
id={name}
value={
Array.isArray(value)
? value.join(", ")
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
const arrayValue = e.target.value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "object":
default:
// For complex objects or unknown types, use JSON textarea
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Textarea
id={name}
value={
typeof value === "object" && value !== null
? JSON.stringify(value, null, 2)
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
// Keep raw string value if not valid JSON
onChange(e.target.value);
}
}}
placeholder='{"key": "value"}'
rows={3}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
})();
// Return the field with appropriate grid column spanning
const getColumnSpan = () => {
if (shouldSpanFullWidth) return "md:col-span-3 xl:col-span-4";
if (shouldSpanTwoColumns) return "xl:col-span-2";
return "";
};
return <div className={getColumnSpan()}>{fieldContent}</div>;
}
interface WorkflowInputFormProps {
inputSchema: JSONSchemaProperty;
inputTypeName: string;
onSubmit: (formData: unknown) => void;
isSubmitting?: boolean;
className?: string;
}
export function WorkflowInputForm({
inputSchema,
inputTypeName,
onSubmit,
isSubmitting = false,
className,
}: WorkflowInputFormProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes('embedded');
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
// Determine field info
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
const primaryField = isSimpleInput ? "value" : fieldNames[0];
const canSubmit = primaryField
? formData[primaryField] !== undefined && formData[primaryField] !== ""
: Object.keys(formData).length > 0;
// Initialize form data
useEffect(() => {
if (inputSchema.type === "string") {
setFormData({ value: inputSchema.default || "" });
} else if (inputSchema.type === "object" && inputSchema.properties) {
const initialData: Record<string, unknown> = {};
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
if (fieldSchema.default !== undefined) {
initialData[key] = fieldSchema.default;
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
initialData[key] = fieldSchema.enum[0];
}
});
setFormData(initialData);
}
}, [inputSchema]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simplified submission logic
if (inputSchema.type === "string") {
onSubmit({ input: formData.value || "" });
} else if (inputSchema.type === "object") {
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
if (fieldNames.length === 1) {
const fieldName = fieldNames[0];
onSubmit({ [fieldName]: formData[fieldName] || "" });
} else {
onSubmit(formData);
}
} else {
onSubmit(formData);
}
// Only close modal if not embedded
if (!isEmbedded) {
setIsModalOpen(false);
}
setLoading(false);
};
const updateField = (fieldName: string, value: unknown) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
};
// If embedded, just show the form directly
if (isEmbedded) {
return (
<form onSubmit={handleSubmit} className={className}>
<div className="grid grid-cols-1 gap-4">
{/* Simple input */}
{isSimpleInput && primaryField && (
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
/>
)}
{/* Complex form fields */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
/>
))}
</>
)}
</div>
<div className="flex gap-2 mt-4 justify-end">
<Button
type="submit"
disabled={loading || !canSubmit}
size="default"
>
<Send className="h-4 w-4" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</div>
</form>
);
}
return (
<>
{/* Sidebar Form Component */}
<div className={cn("flex flex-col", className)}>
{/* Header with Run Button */}
<div className="border-b border-border px-4 py-3 bg-muted">
<CardTitle className="text-sm mb-3">Run Workflow</CardTitle>
{/* Run Button - Opens Modal */}
<Button
onClick={() => setIsModalOpen(true)}
disabled={isSubmitting}
className="w-full"
size="default"
>
<Send className="h-4 w-4 mr-2" />
{isSubmitting ? "Running..." : "Run Workflow"}
</Button>
</div>
{/* Info Section */}
<div className="px-4 py-3">
<div className="text-sm text-muted-foreground">
<strong>Input Type:</strong>{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{inputTypeName}
</code>
{inputSchema.type === "object" && inputSchema.properties && (
<span className="ml-2">
({Object.keys(inputSchema.properties).length} field
{Object.keys(inputSchema.properties).length !== 1 ? "s" : ""})
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Click "Run Workflow" to configure inputs and execute
</p>
</div>
</div>
{/* Modal with the actual form */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="w-full 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>
<DialogTitle>Run Workflow</DialogTitle>
<DialogClose onClose={() => setIsModalOpen(false)} />
</DialogHeader>
{/* Form Info */}
<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>
<code className="bg-muted px-3 py-1 text-xs font-mono">
{inputTypeName}
</code>
{inputSchema.type === "object" && (
<span className="text-xs text-muted-foreground">
{fieldNames.length} field
{fieldNames.length !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
{/* Scrollable Form Content */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<form id="workflow-modal-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-8 max-w-none">
{/* Simple input */}
{isSimpleInput && primaryField && (
<div className="md:col-span-3 xl:col-span-4">
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground mt-2">
{inputSchema.description}
</p>
)}
</div>
)}
{/* Complex form fields - Show all */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
/>
))}
</>
)}
</div>
</form>
</div>
{/* Footer */}
<div className="px-8 py-4 border-t flex-shrink-0">
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
form="workflow-modal-form"
disabled={loading || !canSubmit}
>
<Send className="h-4 w-4 mr-2" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,839 @@
/**
* WorkflowView - Complete workflow execution interface
* Features: Workflow visualization, input forms, execution monitoring
*/
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
CheckCircle,
AlertCircle,
Loader2,
Play,
Settings,
RotateCcw,
ChevronDown,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
import { Button } from "@/components/ui/button";
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
import { apiClient } from "@/services/api";
import type {
WorkflowInfo,
ExtendedResponseStreamEvent,
JSONSchemaProperty,
} from "@/types";
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
// Smart Run Workflow Button Component
interface RunWorkflowButtonProps {
inputSchema: JSONSchemaProperty;
onRun: (data: Record<string, unknown>) => void;
isSubmitting: boolean;
workflowState: "ready" | "running" | "completed" | "error";
executorHistory: Array<{
executorId: string;
message: string;
timestamp: string;
status: string;
}>;
workflowError?: string;
}
function RunWorkflowButton({
inputSchema,
onRun,
isSubmitting,
workflowState,
}: 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(() => {
if (!inputSchema)
return { needsInput: false, hasDefaults: false, fieldCount: 0 };
if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
};
}
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: fieldsWithDefaults.length === fields.length, // All fields have defaults
};
}
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
};
}, [inputSchema]);
const handleDirectRun = () => {
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 getButtonText = () => {
if (workflowState === "running") return "Running...";
if (workflowState === "completed") return "Run Again";
if (workflowState === "error") return "Retry";
if (inputAnalysis.fieldCount === 0) return "Run Workflow";
if (inputAnalysis.canRunDirectly) return "Run Workflow";
return "Configure & Run";
};
const getButtonIcon = () => {
if (workflowState === "running")
return <Loader2 className="w-4 h-4 animate-spin" />;
if (workflowState === "error") return <RotateCcw className="w-4 h-4" />;
if (inputAnalysis.needsInput && !inputAnalysis.canRunDirectly)
return <Settings className="w-4 h-4" />;
return <Play className="w-4 h-4" />;
};
const isButtonDisabled = workflowState === "running";
const buttonVariant = workflowState === "error" ? "destructive" : "primary";
return (
<>
<div className="flex items-center">
{/* Split button group using proper Button components */}
<div className="flex">
{/* Main button */}
<Button
onClick={handleDirectRun}
disabled={isButtonDisabled}
variant={
buttonVariant === "destructive" ? "destructive" : "default"
}
size="default"
className={inputAnalysis.needsInput ? "rounded-r-none" : ""}
>
{getButtonIcon()}
{getButtonText()}
</Button>
{/* Dropdown button - only show if inputs are available */}
{inputAnalysis.needsInput && (
<Button
onClick={() => setShowModal(true)}
disabled={isButtonDisabled}
variant={
buttonVariant === "destructive" ? "destructive" : "default"
}
size="icon"
className="rounded-l-none border-l-0 w-9"
title="Configure inputs"
>
<ChevronDown className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full 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>
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
{/* Form Info - matching the structure from WorkflowInputForm */}
<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>
<code className="bg-muted px-3 py-1 text-xs font-mono">
{inputAnalysis.fieldCount === 0
? "No Input"
: inputSchema.type === "string"
? "String"
: "Object"}
</code>
{inputSchema.type === "object" && inputSchema.properties && (
<span className="text-xs text-muted-foreground">
{Object.keys(inputSchema.properties).length} field
{Object.keys(inputSchema.properties).length !== 1
? "s"
: ""}
</span>
)}
</div>
</div>
</div>
{/* Scrollable Form Content - matching padding and structure */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
onSubmit={(data) => {
onRun(data as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
className="embedded"
/>
</div>
{/* Footer - no additional buttons needed since WorkflowInputForm embedded mode has its own */}
</DialogContent>
</Dialog>
</>
);
}
interface WorkflowViewProps {
selectedWorkflow: WorkflowInfo;
onDebugEvent: DebugEventHandler;
}
export function WorkflowView({
selectedWorkflow,
onDebugEvent,
}: WorkflowViewProps) {
const [workflowInfo, setWorkflowInfo] = useState<WorkflowInfo | null>(null);
const [workflowLoading, setWorkflowLoading] = useState(false);
const [openAIEvents, setOpenAIEvents] = useState<
ExtendedResponseStreamEvent[]
>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [selectedExecutor, setSelectedExecutor] =
useState<ExecutorNodeData | null>(null);
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
const savedHeight = localStorage.getItem("workflowBottomPanelHeight");
return savedHeight ? parseInt(savedHeight, 10) : 300;
});
const [isResizing, setIsResizing] = useState(false);
// View options state
const [viewOptions, setViewOptions] = useState(() => {
const saved = localStorage.getItem("workflowViewOptions");
return saved
? JSON.parse(saved)
: {
showMinimap: false,
showGrid: true,
animateRun: false,
};
});
const { selectExecutor, getExecutorData } = useWorkflowEventCorrelation(
openAIEvents,
isStreaming
);
// Save view options to localStorage
useEffect(() => {
localStorage.setItem("workflowViewOptions", JSON.stringify(viewOptions));
}, [viewOptions]);
// View option handlers
const toggleViewOption = (key: keyof typeof viewOptions) => {
setViewOptions((prev: typeof viewOptions) => ({
...prev,
[key]: !prev[key],
}));
};
// Load workflow info when selectedWorkflow changes
useEffect(() => {
const loadWorkflowInfo = async () => {
if (selectedWorkflow.type !== "workflow") return;
setWorkflowLoading(true);
try {
const info = await apiClient.getWorkflowInfo(selectedWorkflow.id);
setWorkflowInfo(info);
} catch (error) {
console.error("Failed to load workflow info:", error);
setWorkflowInfo(null);
} finally {
setWorkflowLoading(false);
}
};
// Clear state when workflow changes
setOpenAIEvents([]);
setIsStreaming(false);
setSelectedExecutor(null);
setWorkflowResult("");
setWorkflowError("");
accumulatedText.current = "";
loadWorkflowInfo();
}, [selectedWorkflow.id, selectedWorkflow.type]);
const handleNodeSelect = (executorId: string, data: ExecutorNodeData) => {
setSelectedExecutor(data);
selectExecutor(executorId);
};
// Extract workflow events from OpenAI events for executor tracking
const workflowEvents = useMemo(() => {
return openAIEvents.filter(
(event) => event.type === "response.workflow_event.complete"
);
}, [openAIEvents]);
// Extract executor history from workflow events
const executorHistory = useMemo(() => {
return workflowEvents.map((event) => {
if ("data" in event && event.data && typeof event.data === "object") {
const data = event.data as Record<string, unknown>;
return {
executorId: String(data.executor_id || "unknown"),
message: String(data.event_type || "Processing"),
timestamp: String(data.timestamp || new Date().toISOString()),
status: String(data.event_type || "").includes("Completed")
? ("completed" as const)
: String(data.event_type || "").includes("Error")
? ("error" as const)
: ("running" as const),
};
}
return {
executorId: "unknown",
message: "Processing",
timestamp: new Date().toISOString(),
status: "running" as const,
};
});
}, [workflowEvents]);
// Track active executors
const activeExecutors = useMemo(() => {
if (!isStreaming) return [];
const recent = executorHistory
.filter((h) => h.status === "running")
.slice(-2);
return recent.map((h) => h.executorId);
}, [executorHistory, isStreaming]);
// Save panel height to localStorage
useEffect(() => {
localStorage.setItem(
"workflowBottomPanelHeight",
bottomPanelHeight.toString()
);
}, [bottomPanelHeight]);
// Handle resize drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startY = e.clientY;
const startHeight = bottomPanelHeight;
const handleMouseMove = (e: MouseEvent) => {
const deltaY = startY - e.clientY;
const newHeight = Math.max(
200,
Math.min(window.innerHeight * 0.6, startHeight + deltaY)
);
setBottomPanelHeight(newHeight);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[bottomPanelHeight]
);
// Handle workflow data sending (structured input)
const handleSendWorkflowData = useCallback(
async (inputData: Record<string, unknown>) => {
if (!selectedWorkflow || selectedWorkflow.type !== "workflow") return;
setIsStreaming(true);
setOpenAIEvents([]); // Clear previous OpenAI events for new execution
setWorkflowResult("");
setWorkflowError("");
accumulatedText.current = "";
// Clear debug panel events for new workflow run
onDebugEvent("clear");
try {
const request = { input_data: inputData };
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamWorkflowExecutionOpenAI(
selectedWorkflow.id,
request
);
for await (const openAIEvent of streamGenerator) {
// Store all events for processing
setOpenAIEvents((prev) => [...prev, openAIEvent]);
// Pass to debug panel
onDebugEvent(openAIEvent);
// Handle text output for workflow result
if (
openAIEvent.type === "response.output_text.delta" &&
"delta" in openAIEvent &&
openAIEvent.delta
) {
accumulatedText.current += openAIEvent.delta;
setWorkflowResult(accumulatedText.current);
}
// Handle workflow completion with final result
if (
openAIEvent.type === "response.workflow_event.complete" &&
"data" in openAIEvent &&
openAIEvent.data
) {
const data = openAIEvent.data as {
event_type?: string;
data?: unknown;
};
if (data.event_type === "WorkflowCompletedEvent" && data.data) {
setWorkflowResult(String(data.data));
}
}
// Handle errors
if (openAIEvent.type === "error") {
setWorkflowError(
"error" in openAIEvent
? String(openAIEvent.error)
: "Unknown error"
);
break;
}
}
// Stream ended
setIsStreaming(false);
} catch (error) {
console.error("Workflow execution failed:", error);
setWorkflowError(
error instanceof Error ? error.message : "Unknown error"
);
setIsStreaming(false);
}
},
[selectedWorkflow, onDebugEvent]
);
// Show loading state when workflow is being loaded
if (workflowLoading) {
return (
<LoadingState
message="Loading workflow..."
description="Fetching workflow structure and configuration"
/>
);
}
if (!workflowInfo?.workflow_dump && !executorHistory.length) {
return (
<LoadingState
message="Initializing workflow..."
description="Setting up workflow execution environment"
/>
);
}
return (
<div className="workflow-view flex flex-col h-full">
{/* Top Panel - Workflow Visualization */}
<div className="flex-1 min-h-0 p-4">
{/* Workflow Diagram Section */}
{workflowInfo?.workflow_dump && (
<div className="border border-border rounded bg-card shadow-sm h-full flex flex-col">
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
Workflow Visualization
</h3>
{/* Smart Run Workflow CTA - Show for all states */}
{workflowInfo && (
<div className="flex items-center gap-3">
<RunWorkflowButton
inputSchema={workflowInfo.input_schema}
onRun={handleSendWorkflowData}
isSubmitting={isStreaming}
workflowState={
isStreaming
? "running"
: workflowError
? "error"
: executorHistory.length > 0
? "completed"
: "ready"
}
executorHistory={executorHistory}
workflowError={workflowError}
/>
</div>
)}
{/* Status is now handled by the RunWorkflowButton component */}
</div>
</div>
<div className="flex-1 min-h-0">
<WorkflowFlow
workflowDump={workflowInfo.workflow_dump}
events={openAIEvents}
isStreaming={isStreaming}
onNodeSelect={handleNodeSelect}
className="h-full"
viewOptions={viewOptions}
onToggleViewOption={toggleViewOption}
/>
</div>
</div>
)}
</div>
{/* Resize Handle */}
<div
className={`h-1 cursor-row-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
>
<div className="absolute inset-x-0 -top-2 -bottom-2 flex items-center justify-center">
<div
className={`w-12 h-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
{/* Bottom Panel - Execution Details */}
<div
className="flex-shrink-0 border-t"
style={{ height: `${bottomPanelHeight}px` }}
>
{/* Full Width - Execution Details */}
<div className="flex-1 min-w-0 p-4 overflow-auto">
{selectedExecutor ||
activeExecutors.length > 0 ||
executorHistory.length > 0 ||
workflowResult ||
workflowError ? (
<div className="h-full space-y-4">
{/* Current/Last Executor Panel */}
{(selectedExecutor ||
activeExecutors.length > 0 ||
executorHistory.length > 0) && (
<div className="border border-border rounded bg-card shadow-sm">
<div className="border-b border-border px-4 py-3 bg-muted rounded-t">
<h4 className="text-sm font-medium text-foreground">
{selectedExecutor
? `Executor: ${
selectedExecutor.name || selectedExecutor.executorId
}`
: isStreaming && activeExecutors.length > 0
? "Current Executor"
: "Last Executor"}
</h4>
</div>
<div className="p-4">
{selectedExecutor ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
selectedExecutor.state === "running"
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
: selectedExecutor.state === "completed"
? "bg-green-500 dark:bg-green-400"
: selectedExecutor.state === "failed"
? "bg-red-500 dark:bg-red-400"
: selectedExecutor.state === "cancelled"
? "bg-orange-500 dark:bg-orange-400"
: "bg-gray-400 dark:bg-gray-500"
}`}
/>
<span className="text-sm font-medium capitalize text-foreground">
{selectedExecutor.state}
</span>
{selectedExecutor.executorType && (
<span className="text-xs text-muted-foreground">
({selectedExecutor.executorType})
</span>
)}
</div>
{selectedExecutor.inputData !== undefined &&
selectedExecutor.inputData !== null && (
<div>
<h5 className="text-xs font-medium text-foreground mb-1">
Input Data:
</h5>
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto max-h-24">
{String(
typeof selectedExecutor.inputData === "string"
? selectedExecutor.inputData
: (() => {
try {
return JSON.stringify(
selectedExecutor.inputData,
null,
2
);
} catch {
return "[Unable to display data]";
}
})()
)}
</pre>
</div>
)}
{selectedExecutor.outputData !== undefined &&
selectedExecutor.outputData !== null && (
<div>
<h5 className="text-xs font-medium text-foreground mb-1">
Output Data:
</h5>
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto max-h-24">
{String(
typeof selectedExecutor.outputData ===
"string"
? selectedExecutor.outputData
: (() => {
try {
return JSON.stringify(
selectedExecutor.outputData,
null,
2
);
} catch {
return "[Unable to display data]";
}
})()
)}
</pre>
</div>
)}
{selectedExecutor.error && (
<div>
<h5 className="text-xs font-medium text-destructive mb-1">
Error:
</h5>
<pre className="text-xs bg-destructive/10 text-destructive p-2 rounded overflow-x-auto">
{selectedExecutor.error}
</pre>
</div>
)}
<button
onClick={() => setSelectedExecutor(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to current executor
</button>
</div>
) : (
(() => {
const currentExecutorId =
isStreaming && activeExecutors.length > 0
? activeExecutors[activeExecutors.length - 1]
: executorHistory.length > 0
? executorHistory[executorHistory.length - 1]
.executorId
: null;
if (!currentExecutorId) return null;
const executorData = getExecutorData(currentExecutorId);
const historyItem = executorHistory.find(
(h) => h.executorId === currentExecutorId
);
return (
<div
className="space-y-3 cursor-pointer hover:bg-muted/30 p-2 rounded transition-colors"
onClick={() => {
if (executorData) {
setSelectedExecutor({
executorId: executorData.executorId,
state: executorData.state,
inputData: executorData.inputData,
outputData: executorData.outputData,
error: executorData.error,
name: undefined,
executorType: undefined,
isSelected: true,
isStartNode: false,
onNodeClick: undefined,
});
}
}}
>
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isStreaming
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
: historyItem?.status === "completed"
? "bg-green-500 dark:bg-green-400"
: historyItem?.status === "error"
? "bg-red-500 dark:bg-red-400"
: "bg-gray-400 dark:bg-gray-500"
}`}
/>
<span className="text-sm font-medium text-foreground">
{currentExecutorId}
</span>
{historyItem && (
<span className="text-xs text-muted-foreground">
{new Date(
historyItem.timestamp
).toLocaleTimeString()}
</span>
)}
</div>
{historyItem && (
<p className="text-sm text-muted-foreground">
{isStreaming
? "Processing..."
: historyItem.message}
</p>
)}
</div>
);
})()
)}
</div>
</div>
)}
{/* Enhanced Result Display */}
{workflowResult && (
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow">
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t">
<div className="flex items-center gap-3">
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
Workflow Complete
</h4>
</div>
</div>
<div className="p-4">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
{workflowResult}
</div>
</div>
</div>
)}
{/* Enhanced Error Display */}
{workflowError && (
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow">
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t">
<div className="flex items-center gap-3">
<AlertCircle className="w-4 h-4 text-destructive" />
<h4 className="text-sm font-semibold text-destructive">
Workflow Failed
</h4>
</div>
</div>
<div className="p-4">
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
{workflowError}
</div>
</div>
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a workflow to see execution details</p>
</div>
)}
</div>
</div>
</div>
);
}