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,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>
);
}