Python: DevUI - Internal Refactor, Conversations API support, and per… (#1235)

* Python: DevUI - Internal Refactor, Conversations API support, and performance improvements

Comprehensive refactor of DevUI package including samples relocation,
frontend reorganization, OpenAI Conversations API support, and critical
performance and code quality improvements.

Key Changes:

Architecture & Organization
- Moved DevUI samples to python/samples/getting_started/devui/
- Consolidated with other framework samples for better discoverability
- Added .env.example files and comprehensive README
- Restructured frontend components into feature-based folders (agent, workflow, gallery, layout)
- Created new OpenAI-compliant message renderers (devui should render oai responses types primarily)

New Features
- Added _conversations.py (467 lines) - Full conversation storage abstraction, replaces the /threads endpoint to better match oai conversations api
- Implements OpenAI Conversations API for thread management, Supports in-memory and extensible storage backends

API Simplification
- Use 'model' field as entity_id (agent/workflow name) instead of extra_body
- Use standard OpenAI 'conversation' field for conversation context.

Performance & Quality Improvements
- Improved context management in MessageMapper with bounded memory (~500KB max)
- Implemented hybrid LRU + cleanup approach to prevent unbounded memory growth
- General QOL improvement - Eliminated ~150 lines of dead/duplicate code, Consolidated helper functions into _utils.py, Extracted magic numbers to module-level constants, Optimized conversation item lookups with index-based approach

Testing
- Added test_conversations.py (13 tests)
- Added test_performance_fixes.py (9 tests)
- Updated existing tests for code consolidation
- 53 tests passing

Impact: 76 files changed: +4,106 insertions, -2,373 deletions
All linting and formatting checks passing. No breaking changes - backward compatible.

Migration: Samples moved to python/samples/getting_started/devui/

* readme lint fixes

* initial support for function approval and minor ui fixes
This commit is contained in:
Victor Dibia
2025-10-08 12:34:30 -07:00
committed by GitHub
Unverified
parent f5abbc67ae
commit c341ee7ed2
75 changed files with 4605 additions and 2646 deletions
@@ -0,0 +1,219 @@
/**
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Bot,
Package,
FileText,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import type { AgentInfo } from "@/types";
interface AgentDetailsModalProps {
agent: AgentInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function AgentDetailsModal({
agent,
open,
onOpenChange,
}: AgentDetailsModalProps) {
const sourceIcon =
agent.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : agent.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
agent.source === "directory"
? "Local"
: agent.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Agent Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<Bot className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{agent.name || agent.id}
</h2>
</div>
{agent.description && (
<p className="text-muted-foreground">{agent.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Model & Client */}
{(agent.model || agent.chat_client_type) && (
<DetailCard
title="Model & Client"
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
>
<div className="space-y-1">
{agent.model && (
<div className="font-mono text-foreground">{agent.model}</div>
)}
{agent.chat_client_type && (
<div className="text-xs">({agent.chat_client_type})</div>
)}
</div>
</DetailCard>
)}
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{agent.module_path && (
<div className="font-mono text-xs break-all">
{agent.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
agent.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
agent.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400"
}
>
{agent.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Full Width Sections */}
{agent.instructions && (
<DetailCard
title="Instructions"
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
className="mb-4"
>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{agent.instructions}
</div>
</DetailCard>
)}
{/* Tools and Middleware Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tools */}
<DetailCard
title={`Tools (${agent.tools.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{agent.tools.length > 0 ? (
<ul className="space-y-1">
{agent.tools.map((tool, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{tool}
</li>
))}
</ul>
) : (
<div className="text-muted-foreground">No tools configured</div>
)}
</DetailCard>
{/* Middleware */}
{agent.middleware && agent.middleware.length > 0 && (
<DetailCard
title={`Middleware (${agent.middleware.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
<ul className="space-y-1">
{agent.middleware.map((mw, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{mw}
</li>
))}
</ul>
</DetailCard>
)}
{/* Context Providers */}
{agent.context_providers && agent.context_providers.length > 0 && (
<DetailCard
title={`Context Providers (${agent.context_providers.length})`}
icon={<Database className="h-4 w-4 text-muted-foreground" />}
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
>
<ul className="space-y-1">
{agent.context_providers.map((cp, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{cp}
</li>
))}
</ul>
</DetailCard>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
/**
* Agent Feature - Exports
*/
export { AgentView } from "./agent-view";
export { AgentDetailsModal } from "./agent-details-modal";
export * from "./message-renderers";
@@ -0,0 +1,289 @@
/**
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
* This is the CORRECT implementation that works with OpenAI types only
*/
import { useState } from "react";
import {
Download,
FileText,
Code,
ChevronDown,
ChevronUp,
Music,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
interface ContentRendererProps {
content: MessageContent;
className?: string;
isStreaming?: boolean;
}
// Text content renderer
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "text") return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
{shouldTruncate && (
<div className="flex justify-end mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="inline-flex items-center gap-1 text-xs
bg-background/80 hover:bg-background border border-border/50 hover:border-border
text-muted-foreground hover:text-foreground
transition-colors cursor-pointer px-2 py-1 rounded"
>
{isExpanded ? (
<>
less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
)}
</div>
);
}
// Image content renderer
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image") return null;
const imageUrl = content.image_url;
if (imageError) {
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>Image could not be loaded</span>
</div>
</div>
);
}
return (
<div className={`my-2 ${className || ""}`}>
<img
src={imageUrl}
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)}
/>
{isExpanded && (
<div className="text-xs text-muted-foreground mt-1">
Click to collapse
</div>
)}
</div>
);
}
// File content renderer
function FileContentRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "input_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
// Determine file type from filename or data URI
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
// For PDFs, try to embed
if (isPdf && fileUrl) {
return (
<div className={`my-2 ${className || ""}`}>
<div className="border rounded-lg overflow-hidden">
<iframe
src={fileUrl}
className="w-full h-96"
title={filename}
/>
</div>
<div className="flex items-center gap-2 mt-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{filename}</span>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="ml-auto text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</a>
)}
</div>
</div>
);
}
// For audio files
if (isAudio && fileUrl) {
return (
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{filename}</span>
</div>
<audio controls className="w-full">
<source src={fileUrl} />
Your browser does not support audio playback.
</audio>
</div>
);
}
// Generic file display
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{filename}</span>
</div>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</a>
)}
</div>
</div>
);
}
// Main content renderer that delegates to specific renderers
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
switch (content.type) {
case "text":
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
case "input_image":
return <ImageContentRenderer content={content} className={className} />;
case "input_file":
return <FileContentRenderer content={content} className={className} />;
default:
return null;
}
}
// Function call renderer (for displaying function calls in chat)
interface FunctionCallRendererProps {
name: string;
arguments: string;
className?: string;
}
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedArgs;
try {
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
} catch {
parsedArgs = args;
}
return (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
Function Call: {name}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Function result renderer
interface FunctionResultRendererProps {
output: string;
call_id: string;
className?: string;
}
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedOutput;
try {
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
} catch {
parsedOutput = output;
}
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-800 dark:text-green-300">
Function Result
</span>
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedOutput, null, 2)}
</pre>
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
/**
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
* This replaces the legacy AgentFramework-based renderer
*/
import type { ConversationItem } from "@/types/openai";
import {
OpenAIContentRenderer,
FunctionCallRenderer,
FunctionResultRenderer,
} from "./OpenAIContentRenderer";
interface OpenAIMessageRendererProps {
item: ConversationItem;
className?: string;
}
export function OpenAIMessageRenderer({
item,
className,
}: OpenAIMessageRendererProps) {
// Handle message items (user/assistant with content)
if (item.type === "message") {
// Determine if message is actively streaming
const isStreaming = item.status === "in_progress";
const hasContent = item.content.length > 0;
return (
<div className={className}>
{item.content.map((content, index) => (
<OpenAIContentRenderer
key={index}
content={content}
className={index > 0 ? "mt-2" : ""}
isStreaming={isStreaming}
/>
))}
{/* Show typing indicator when streaming with no content yet */}
{isStreaming && !hasContent && (
<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>
);
}
// Handle function call items
if (item.type === "function_call") {
return (
<FunctionCallRenderer
name={item.name}
arguments={item.arguments}
className={className}
/>
);
}
// Handle function result items
if (item.type === "function_call_output") {
return (
<FunctionResultRenderer
output={item.output}
call_id={item.call_id}
className={className}
/>
);
}
// Unknown item type
return null;
}
@@ -0,0 +1,7 @@
/**
* Message Renderer - Exports
* Uses OpenAI Responses API types exclusively
*/
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
@@ -0,0 +1,412 @@
/**
* GalleryView - Consolidated gallery component with card and grid logic
* Supports inline (empty state) and modal variants
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Bot,
Workflow,
Plus,
Loader2,
User,
TriangleAlert,
AlertCircle,
X,
Key,
ChevronDown,
ArrowLeft,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
SAMPLE_ENTITIES,
type SampleEntity,
getDifficultyColor,
} from "@/data/gallery";
interface GalleryViewProps {
onAdd: (sample: SampleEntity) => Promise<void>;
addingEntityId?: string | null;
errorEntityId?: string | null;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
onClose?: () => void;
variant?: "inline" | "route" | "modal";
hasExistingEntities?: boolean;
}
// Internal: Sample Entity Card Component
function SampleEntityCard({
sample,
onAdd,
isAdding = false,
hasError = false,
errorMessage,
onClearError,
}: {
sample: SampleEntity;
onAdd: (sample: SampleEntity) => Promise<void>;
isAdding?: boolean;
hasError?: boolean;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
}) {
const [isLoading, setIsLoading] = useState(false);
const handleAdd = async () => {
if (isLoading || isAdding) return;
setIsLoading(true);
try {
await onAdd(sample);
} finally {
setIsLoading(false);
}
};
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
const isDisabled = isLoading || isAdding;
return (
<Card
className={cn(
"hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full",
hasError && "border-destructive"
)}
>
<CardHeader className="pb-3 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5" />
<Badge variant="secondary" className="text-xs">
{sample.type}
</Badge>
</div>
<Badge
variant="outline"
className={cn(
"text-xs border",
getDifficultyColor(sample.difficulty)
)}
>
{sample.difficulty}
</Badge>
</div>
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
<CardDescription className="text-sm line-clamp-3">
{sample.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
{/* Error Banner */}
{hasError && errorMessage && (
<div className="mb-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs text-destructive font-medium mb-1">
Failed to add
</p>
<p className="text-xs text-muted-foreground">{errorMessage}</p>
</div>
{onClearError && (
<button
onClick={() => onClearError(sample.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Dismiss error"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
)}
<div className="space-y-3 min-w-0">
{/* Tags */}
<div className="flex flex-wrap gap-1">
{sample.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{sample.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{sample.tags.length - 3}
</Badge>
)}
</div>
{/* Environment Variables Required - Collapsible */}
{sample.requiredEnvVars && sample.requiredEnvVars.length > 0 && (
<details className="group min-w-0 max-w-full overflow-hidden">
<summary className="cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Key className="h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
<span className="text-xs font-medium text-amber-900 dark:text-amber-100 truncate">
Requires {sample.requiredEnvVars.length} env var
{sample.requiredEnvVars.length > 1 ? "s" : ""}
</span>
</div>
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform" />
</summary>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden">
{sample.requiredEnvVars.map((envVar) => (
<div key={envVar.name} className="text-xs min-w-0 max-w-full overflow-hidden">
<div className="font-mono font-medium text-amber-900 dark:text-amber-100 break-words">
{envVar.name}
</div>
<div className="text-amber-700 dark:text-amber-300 mt-0.5 break-words">
{envVar.description}
</div>
{envVar.example && (
<div className="font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all">
{envVar.example}
</div>
)}
</div>
))}
</div>
</details>
)}
{/* Features */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Key Features:
</div>
<ul className="text-xs space-y-1">
{sample.features.slice(0, 3).map((feature) => (
<li key={feature} className="flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-current opacity-50" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</div>
</CardContent>
<CardFooter className="pt-3 flex-col gap-3">
{/* Metadata */}
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{sample.author}</span>
</div>
</div>
{/* Add Button - Full width on its own line */}
<Button
onClick={handleAdd}
disabled={isDisabled}
className="w-full"
size="sm"
variant={hasError ? "outline" : "default"}
>
{isDisabled ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
) : hasError ? (
<>
<Plus className="h-4 w-4 mr-2" />
Retry
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add Sample
</>
)}
</Button>
</CardFooter>
</Card>
);
}
// Internal: Sample Entity Grid Component
function SampleEntityGrid({
samples,
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
}: {
samples: SampleEntity[];
onAdd: (sample: SampleEntity) => Promise<void>;
addingEntityId?: string | null;
errorEntityId?: string | null;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{samples.map((sample) => (
<div key={sample.id} className="min-w-0">
<SampleEntityCard
sample={sample}
onAdd={onAdd}
isAdding={addingEntityId === sample.id}
hasError={errorEntityId === sample.id}
errorMessage={errorMessage}
onClearError={onClearError}
/>
</div>
))}
</div>
);
}
// Main: Gallery View Component
export function GalleryView({
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
onClose,
variant = "inline",
hasExistingEntities = false,
}: GalleryViewProps) {
// Inline variant - for empty state in main app
if (variant === "inline") {
return (
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Info Banner */}
<div className="mb-8 p-4 bg-muted/50 border border-border rounded-lg">
<div className="flex items-start gap-3">
<TriangleAlert className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold mb-1">
No agents or workflows configured yet!
</h3>
<p className="text-sm text-muted-foreground mb-2">
You can configure agents or workflows by running{" "}
<code className="px-1.5 py-0.5 bg-background rounded text-xs">
devui
</code>{" "}
in a directory containing them.
</p>
<p className="text-sm text-muted-foreground">
You can also import any of the sample agents and workflows
below to get started quickly.
</p>
</div>
</div>
</div>
{/* Sample Gallery */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
</div>
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
<p className="text-sm text-muted-foreground">
Want to create your own agents or workflows? Check out the{" "}
<a
href="https://github.com/microsoft/agent-framework"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</p>
</div>
</div>
</div>
);
}
// Route variant - for /gallery page
if (variant === "route") {
return (
<div className="h-full overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
{hasExistingEntities && (
<div className="mb-4">
<Button variant="ghost" onClick={onClose} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
)}
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Browse and add sample agents and workflows to learn the Agent
Framework. These are curated examples ranging from beginner to
advanced.
</p>
</div>
</div>
{/* Sample Gallery */}
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
<p className="text-sm text-muted-foreground">
Want to create your own agents or workflows? Check out the{" "}
<a
href="https://github.com/microsoft/agent-framework"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</p>
</div>
</div>
</div>
);
}
// Modal variant - for dropdown trigger (simplified, just the grid)
return (
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
);
}
@@ -0,0 +1,5 @@
/**
* Gallery component exports
*/
export { GalleryView } from './gallery-view';
@@ -0,0 +1,235 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
} 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;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
}
const getExecutorStateConfig = (state: ExecutorState) => {
switch (state) {
case "running":
return {
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
glow: "shadow-lg shadow-[#643FB2]/20",
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
};
case "completed":
return {
borderColor: "border-green-500 dark:border-green-400",
glow: "shadow-lg shadow-green-500/20",
badgeColor: "bg-green-500 dark:bg-green-400",
};
case "failed":
return {
borderColor: "border-red-500 dark:border-red-400",
glow: "shadow-lg shadow-red-500/20",
badgeColor: "bg-red-500 dark:bg-red-400",
};
case "cancelled":
return {
borderColor: "border-orange-500 dark:border-orange-400",
glow: "shadow-lg shadow-orange-500/20",
badgeColor: "bg-orange-500 dark:bg-orange-400",
};
case "pending":
default:
return {
borderColor: "border-gray-300 dark:border-gray-600",
glow: "",
badgeColor: "bg-gray-400 dark:bg-gray-500",
};
}
};
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
// Determine handle positions based on layout direction
const isVertical = nodeData.layoutDirection === "TB";
const targetPosition = isVertical ? Position.Top : Position.Left;
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
// 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",
)}
>
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={targetPosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
{!nodeData.isEndNode && (
<Handle
type="source"
position={sourcePosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<div className="p-3">
{/* Header with icon and title */}
<div className="flex items-start gap-3">
<div className="flex-shrink-0 relative">
{/* Icon container with dark background */}
<div className="w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center">
{nodeData.isStartNode ? (
<Home className="w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]" />
) : (
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
)}
</div>
{/* Small status badge for running state */}
{isRunning && (
<div className={cn(
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
config.badgeColor
)} />
)}
</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 mt-0.5">
{nodeData.executorType}
</p>
)}
</div>
</div>
{/* Data details */}
{hasData && (
<div className="mt-3">
{renderDataDetails()}
</div>
)}
{/* Running animation overlay */}
{isRunning && (
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
);
});
ExecutorNode.displayName = "ExecutorNode";
@@ -0,0 +1,9 @@
/**
* Workflow Feature - Exports
*/
export { WorkflowView } from "./workflow-view";
export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
@@ -0,0 +1,168 @@
/**
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Workflow as WorkflowIcon,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
PlayCircle,
} from "lucide-react";
import type { WorkflowInfo } from "@/types";
interface WorkflowDetailsModalProps {
workflow: WorkflowInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function WorkflowDetailsModal({
workflow,
open,
onOpenChange,
}: WorkflowDetailsModalProps) {
const sourceIcon =
workflow.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : workflow.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
workflow.source === "directory"
? "Local"
: workflow.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Workflow Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<WorkflowIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{workflow.name || workflow.id}
</h2>
</div>
{workflow.description && (
<p className="text-muted-foreground">{workflow.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Start Executor */}
<DetailCard
title="Start Executor"
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
>
<div className="font-mono text-foreground">
{workflow.start_executor_id}
</div>
</DetailCard>
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{workflow.module_path && (
<div className="font-mono text-xs break-all">
{workflow.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
workflow.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
workflow.has_env
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{workflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Executors */}
<DetailCard
title={`Executors (${workflow.executors.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{workflow.executors.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{workflow.executors.map((executor, index) => (
<div
key={index}
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded"
>
{executor}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">No executors configured</div>
)}
</DetailCard>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,517 @@
import { useMemo, useCallback, useEffect, memo } from "react";
import {
MoreVertical,
Map,
Grid3X3,
RotateCcw,
Maximize,
Shuffle,
Zap,
ArrowDown,
} 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,
layoutDirection,
onLayoutDirectionChange,
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => 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,
layoutDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
layoutDirection
);
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
className="flex items-center justify-between"
onClick={() => {
const newDirection = layoutDirection === "LR" ? "TB" : "LR";
onLayoutDirectionChange?.(newDirection);
// Re-apply layout with new direction
if (workflowDump) {
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
newDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
newDirection
);
setNodes(layoutedNodes);
}
}}
>
<div className="flex items-center">
<ArrowDown className="mr-2 h-4 w-4" />
Vertical Layout
</div>
<Checkbox checked={layoutDirection === "TB"} 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;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => 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 const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
onToggleViewOption,
layoutDirection = "LR",
onLayoutDirectionChange,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
if (!workflowDump) {
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0
? applyDagreLayout(nodes, edges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
};
}, [workflowDump, onNodeSelect, layoutDirection]);
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, workflowDump?.start_executor_id);
}, [events, workflowDump?.start_executor_id]);
// 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 "#643FB2";
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}
layoutDirection={layoutDirection}
onLayoutDirectionChange={onLayoutDirectionChange}
/>
</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,707 @@
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, ChevronDown, ChevronUp } 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;
isRequired?: boolean;
}
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// For text/message/content fields, treat as textarea for better UX
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
// Determine if this field should span full width
// Only span full if it's a textarea or has very long description
const shouldSpanFullWidth =
schema.format === "textarea" ||
isTextContentField || // text/message fields span full width
(description && description.length > 150);
const shouldSpanTwoColumns =
schema.format === "textarea" ||
isTextContentField ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
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}
{isRequired && <span className="text-destructive ml-1">*</span>}
</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" ||
isTextContentField ||
(description && description.length > 100)
) {
// Multi-line text (including text/message/content fields)
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={isTextContentField ? 4 : 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}
{isRequired && <span className="text-destructive ml-1">*</span>}
</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}
{isRequired && <span className="text-destructive ml-1">*</span>}
</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}
{isRequired && <span className="text-destructive ml-1">*</span>}
</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}
{isRequired && <span className="text-destructive ml-1">*</span>}
</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}
{isRequired && <span className="text-destructive ml-1">*</span>}
</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-2 lg: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);
const [showAdvancedFields, setShowAdvancedFields] = 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 requiredFields = inputSchema.required || [];
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
// Plan D: Separate required and optional fields first
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
// Detect ChatMessage-like pattern
const isChatMessageLike =
requiredFields.includes('role') &&
allOptionalFieldNames.some(f => ['text', 'message', 'content'].includes(f)) &&
properties['role']?.type === 'string';
// For ChatMessage: hide 'role' field (will be auto-filled)
const requiredFieldNames = fieldNames.filter(name =>
requiredFields.includes(name) && !(isChatMessageLike && name === 'role')
);
const optionalFieldNames = allOptionalFieldNames;
// For ChatMessage: prioritize text/message/content field to show first
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
['text', 'message', 'content'].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Always show ALL required fields + fill to minimum visible with optional fields
// For ChatMessage: show only 1 optional field (text)
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
const visibleOptionalCount = Math.max(0, MIN_VISIBLE_FIELDS - requiredFieldNames.length);
const visibleOptionalFields = sortedOptionalFields.slice(0, visibleOptionalCount);
const collapsedOptionalFields = sortedOptionalFields.slice(visibleOptionalCount);
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;
// Update canSubmit to check required fields properly
// For ChatMessage: role is auto-filled, so it's always valid
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
: requiredFields.length > 0
? requiredFields.every(fieldName => {
// Auto-filled fields are always valid
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
return true;
}
return formData[fieldName] !== undefined && formData[fieldName] !== "";
})
: 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];
}
});
// Auto-fill role="user" for ChatMessage-like inputs
if (isChatMessageLike && !initialData['role']) {
initialData['role'] = 'user';
}
setFormData(initialData);
}
}, [inputSchema, isChatMessageLike]);
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 {
// Filter out empty optional fields before submission
const filteredData: Record<string, unknown> = {};
Object.keys(formData).forEach(key => {
const value = formData[key];
// Include if: 1) required field, OR 2) has non-empty value
if (requiredFields.includes(key) || (value !== undefined && value !== "" && value !== null)) {
filteredData[key] = value;
}
});
onSubmit(filteredData);
}
} 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 sm:grid-cols-2 gap-4">
{/* Simple input */}
{isSimpleInput && (
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
/>
)}
{/* Complex form fields - Plan D: Required + Optional separation */}
{!isSimpleInput && (
<>
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
/>
))}
{/* Separator between required and optional (only if both exist) */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="sm:col-span-2 border-t border-border my-2"></div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="sm:col-span-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
)}
</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-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 md:gap-8 max-w-none">
{/* Simple input */}
{isSimpleInput && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground mt-2">
{inputSchema.description}
</p>
)}
</div>
)}
{/* Complex form fields - Plan D: Required + Optional separation */}
{!isSimpleInput && (
<>
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
/>
))}
{/* Separator between required and optional (only if both exist) */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div className="border-t border-border"></div>
</div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
)}
</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>
</>
);
}